Skip to main content

skippy_cache/resident/
prefix.rs

1use std::collections::HashMap;
2
3use anyhow::{Result, bail};
4
5use crate::ResidentCacheConfig;
6
7#[derive(Debug)]
8pub struct ResidentPrefixCache {
9    max_entries: usize,
10    max_bytes: u64,
11    /// 0 means "unlimited (legacy)".
12    max_resident_tokens: u64,
13    min_tokens: u64,
14    reserved_seq_count: i32,
15    next_seq_id: i32,
16    clock: u64,
17    resident_tokens: u64,
18    estimated_bytes: u64,
19    entries: HashMap<String, ResidentPrefixEntry>,
20    free_seq_ids: Vec<i32>,
21}
22
23#[derive(Debug)]
24struct ResidentPrefixEntry {
25    seq_id: i32,
26    token_count: u64,
27    estimated_bytes: u64,
28    last_used: u64,
29    borrowed: bool,
30}
31
32#[derive(Debug, Clone)]
33pub struct ResidentPrefixLookup {
34    pub seq_id: i32,
35    pub entries: usize,
36}
37
38#[derive(Debug, Clone)]
39pub struct ResidentPrefixEviction {
40    pub page_id: String,
41    pub seq_id: i32,
42    pub token_count: u64,
43}
44
45#[derive(Debug, Clone)]
46pub struct ResidentPrefixAllocation {
47    pub seq_id: i32,
48    pub evictions: Vec<ResidentPrefixEviction>,
49    pub should_save: bool,
50    pub should_retain: bool,
51}
52
53impl ResidentPrefixAllocation {
54    fn existing(seq_id: i32) -> Self {
55        Self {
56            seq_id,
57            evictions: Vec::new(),
58            should_save: false,
59            should_retain: true,
60        }
61    }
62
63    fn new_record(seq_id: i32, evictions: Vec<ResidentPrefixEviction>) -> Self {
64        Self {
65            seq_id,
66            evictions,
67            should_save: true,
68            should_retain: true,
69        }
70    }
71
72    fn uncacheable() -> Self {
73        Self {
74            seq_id: -1,
75            evictions: Vec::new(),
76            should_save: false,
77            should_retain: false,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy, Default)]
83pub struct ResidentPrefixCacheStats {
84    pub entries: usize,
85    pub resident_tokens: u64,
86    pub estimated_bytes: u64,
87    pub max_entries: usize,
88    pub max_bytes: u64,
89}
90
91impl ResidentPrefixCache {
92    pub fn new(config: ResidentCacheConfig) -> Self {
93        Self {
94            max_entries: config.max_entries,
95            max_bytes: config.max_bytes,
96            max_resident_tokens: config.max_resident_tokens,
97            min_tokens: config.min_tokens,
98            reserved_seq_count: config.reserved_seq_count,
99            next_seq_id: config.reserved_seq_count,
100            clock: 0,
101            resident_tokens: 0,
102            estimated_bytes: 0,
103            entries: HashMap::new(),
104            free_seq_ids: Vec::new(),
105        }
106    }
107
108    pub fn lookup(&mut self, page_id: &str) -> Option<ResidentPrefixLookup> {
109        self.clock = self.clock.saturating_add(1);
110        let entries = self.entries.len();
111        let entry = self.entries.get_mut(page_id)?;
112        if entry.borrowed {
113            return None;
114        }
115        entry.last_used = self.clock;
116        Some(ResidentPrefixLookup {
117            seq_id: entry.seq_id,
118            entries,
119        })
120    }
121
122    pub fn acquire(&mut self, page_id: &str) -> Option<ResidentPrefixLookup> {
123        self.clock = self.clock.saturating_add(1);
124        let entries = self.entries.len();
125        let entry = self.entries.get_mut(page_id)?;
126        if entry.borrowed {
127            return None;
128        }
129        entry.borrowed = true;
130        entry.last_used = self.clock;
131        Some(ResidentPrefixLookup {
132            seq_id: entry.seq_id,
133            entries,
134        })
135    }
136
137    pub fn release(&mut self, page_id: &str) {
138        if let Some(entry) = self.entries.get_mut(page_id) {
139            entry.borrowed = false;
140            self.clock = self.clock.saturating_add(1);
141            entry.last_used = self.clock;
142        }
143    }
144
145    pub fn allocate_for_record(
146        &mut self,
147        page_id: &str,
148        token_count: u64,
149        estimated_bytes: u64,
150        mut drop_evicted: impl FnMut(i32) -> Result<()>,
151    ) -> Result<ResidentPrefixAllocation> {
152        if token_count < self.min_tokens {
153            bail!("resident prefix has fewer tokens than cache minimum");
154        }
155        self.clock = self.clock.saturating_add(1);
156        if let Some(entry) = self.entries.get(page_id) {
157            return Ok(ResidentPrefixAllocation::existing(entry.seq_id));
158        }
159        if self.candidate_exceeds_single_record_budget(estimated_bytes, token_count) {
160            return Ok(ResidentPrefixAllocation::uncacheable());
161        }
162
163        let evictions =
164            self.evict_until_room_for(estimated_bytes, token_count, &mut drop_evicted)?;
165        let seq_id = self.next_sequence_id()?;
166        Ok(ResidentPrefixAllocation::new_record(seq_id, evictions))
167    }
168
169    pub fn commit_record(
170        &mut self,
171        page_id: String,
172        seq_id: i32,
173        token_count: u64,
174        estimated_bytes: u64,
175    ) {
176        self.clock = self.clock.saturating_add(1);
177        if let Some(previous) = self.entries.remove(&page_id) {
178            self.resident_tokens = self.resident_tokens.saturating_sub(previous.token_count);
179            self.estimated_bytes = self
180                .estimated_bytes
181                .saturating_sub(previous.estimated_bytes);
182        }
183        self.resident_tokens = self.resident_tokens.saturating_add(token_count);
184        self.estimated_bytes = self.estimated_bytes.saturating_add(estimated_bytes);
185        self.entries.insert(
186            page_id,
187            ResidentPrefixEntry {
188                seq_id,
189                token_count,
190                estimated_bytes,
191                last_used: self.clock,
192                borrowed: false,
193            },
194        );
195    }
196
197    /// Evict one LRU entry (the entry with the smallest `last_used`
198    /// that is not currently borrowed). Returns the evicted entry
199    /// metadata, or `None` if there is nothing to evict.
200    ///
201    /// This is the **proactive eviction** path: it runs outside the
202    /// normal `allocate_for_record` flow so that the decode loop can
203    /// free KV cells before grammar-triggered retries need them.
204    pub fn evict_one_lru_entry(
205        &mut self,
206        drop_evicted: &mut impl FnMut(i32) -> Result<()>,
207    ) -> Result<Option<ResidentPrefixEviction>> {
208        self.evict_lru_entry(drop_evicted)
209    }
210
211    /// Evict LRU entries until at least `min_tokens` have been released,
212    /// or until no non-borrowed entry remains. Returns the entries that
213    /// were actually evicted.
214    pub fn evict_lru_until_tokens(
215        &mut self,
216        min_tokens: u64,
217        drop_evicted: &mut impl FnMut(i32) -> Result<()>,
218    ) -> Result<Vec<ResidentPrefixEviction>> {
219        let mut evictions = Vec::new();
220        let mut evicted_tokens = 0_u64;
221        while evicted_tokens < min_tokens {
222            let Some(eviction) = self.evict_lru_entry(drop_evicted)? else {
223                break;
224            };
225            evicted_tokens = evicted_tokens.saturating_add(eviction.token_count);
226            evictions.push(eviction);
227        }
228        Ok(evictions)
229    }
230
231    fn evict_lru_entry(
232        &mut self,
233        drop_evicted: &mut impl FnMut(i32) -> Result<()>,
234    ) -> Result<Option<ResidentPrefixEviction>> {
235        let victim = self
236            .entries
237            .iter()
238            .filter(|(_, entry)| !entry.borrowed)
239            .min_by_key(|(_, entry)| entry.last_used)
240            .map(|(key, _)| key.clone());
241        let Some(victim) = victim else {
242            return Ok(None);
243        };
244        let entry = self
245            .entries
246            .get(&victim)
247            .expect("selected resident prefix victim should exist");
248        drop_evicted(entry.seq_id)?;
249        let entry = self
250            .entries
251            .remove(&victim)
252            .expect("selected resident prefix victim should still exist after native drop");
253        self.free_seq_ids.push(entry.seq_id);
254        self.resident_tokens = self.resident_tokens.saturating_sub(entry.token_count);
255        self.estimated_bytes = self.estimated_bytes.saturating_sub(entry.estimated_bytes);
256        Ok(Some(ResidentPrefixEviction {
257            page_id: victim,
258            seq_id: entry.seq_id,
259            token_count: entry.token_count,
260        }))
261    }
262
263    pub fn stats(&self) -> ResidentPrefixCacheStats {
264        ResidentPrefixCacheStats {
265            entries: self.entries.len(),
266            resident_tokens: self.resident_tokens,
267            estimated_bytes: self.estimated_bytes,
268            max_entries: self.max_entries,
269            max_bytes: self.max_bytes,
270        }
271    }
272
273    fn evict_until_room_for(
274        &mut self,
275        estimated_bytes: u64,
276        token_count: u64,
277        drop_evicted: &mut impl FnMut(i32) -> Result<()>,
278    ) -> Result<Vec<ResidentPrefixEviction>> {
279        let mut evictions = Vec::new();
280        loop {
281            let over_entries = self.entries.len().saturating_add(1) > self.max_entries;
282            let over_bytes = self.max_bytes > 0
283                && self.estimated_bytes.saturating_add(estimated_bytes) > self.max_bytes;
284            // Under unified-KV serving the prefix cache shares the
285            // `n_ctx` cell pool with the active lanes. `max_entries`
286            // and `max_bytes` alone do not bound the cell footprint:
287            // 12 entries averaging 10k tokens each pin 120k cells in
288            // a 131k-`n_ctx` pool with no LRU pressure. Add an
289            // explicit token budget so eviction kicks in before the
290            // cells run out.
291            let over_tokens = self.max_resident_tokens > 0
292                && self.resident_tokens.saturating_add(token_count) > self.max_resident_tokens;
293            if !over_entries && !over_bytes && !over_tokens {
294                break;
295            }
296            let Some(eviction) = self.evict_lru_entry(drop_evicted)? else {
297                bail!("resident prefix cache has no releasable entries");
298            };
299            evictions.push(eviction);
300        }
301        Ok(evictions)
302    }
303
304    fn candidate_exceeds_single_record_budget(
305        &self,
306        estimated_bytes: u64,
307        token_count: u64,
308    ) -> bool {
309        let over_bytes = self.max_bytes > 0 && estimated_bytes > self.max_bytes;
310        let over_tokens = self.max_resident_tokens > 0 && token_count > self.max_resident_tokens;
311        over_bytes || over_tokens
312    }
313
314    fn next_sequence_id(&mut self) -> Result<i32> {
315        if let Some(seq_id) = self.free_seq_ids.pop() {
316            return Ok(seq_id);
317        }
318        let seq_id = self.next_seq_id;
319        self.next_seq_id = self
320            .next_seq_id
321            .checked_add(1)
322            .ok_or_else(|| anyhow::anyhow!("resident prefix sequence id overflow"))?;
323        if seq_id < self.reserved_seq_count || seq_id >= 1024 {
324            bail!("resident prefix sequence id capacity exhausted");
325        }
326        Ok(seq_id)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn cfg(max_entries: usize, max_bytes: u64, max_resident_tokens: u64) -> ResidentCacheConfig {
335        ResidentCacheConfig {
336            max_entries,
337            max_bytes,
338            max_resident_tokens,
339            min_tokens: 256,
340            reserved_seq_count: 2,
341        }
342    }
343
344    #[test]
345    fn token_budget_triggers_lru_before_entry_cap_under_unified_kv() {
346        // Regression: under skippy `kv_unified = true` the prefix cache
347        // shares the model's `n_ctx` cell pool with the active lanes.
348        // Before this fix, the cache only evicted on `max_entries` and
349        // `max_bytes`. Live data on a 131k-`n_ctx` MiniMax showed the
350        // cache happily filling to ~124k pinned tokens (~95% of the
351        // pool) across just 12 entries with no LRU pressure, starving
352        // the lanes and surfacing as HTTP 502
353        // `RuntimeError: llama_decode failed`.
354        //
355        // With `max_resident_tokens = 4096` and entries of 1500 tokens
356        // each, the third record must trigger eviction even though
357        // we're well under `max_entries = 16`.
358        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 4096));
359        let mut dropped: Vec<i32> = Vec::new();
360
361        let alloc1 = cache
362            .allocate_for_record("page-1", 1500, 100, |sid| {
363                dropped.push(sid);
364                Ok(())
365            })
366            .unwrap();
367        assert!(alloc1.should_save);
368        cache.commit_record("page-1".to_string(), alloc1.seq_id, 1500, 100);
369        assert_eq!(cache.stats().entries, 1);
370        assert_eq!(cache.stats().resident_tokens, 1500);
371
372        let alloc2 = cache
373            .allocate_for_record("page-2", 1500, 100, |sid| {
374                dropped.push(sid);
375                Ok(())
376            })
377            .unwrap();
378        cache.commit_record("page-2".to_string(), alloc2.seq_id, 1500, 100);
379        assert_eq!(cache.stats().entries, 2);
380        assert_eq!(cache.stats().resident_tokens, 3000);
381        // Two entries fit; we are at 3000 / 4096 tokens.
382        assert!(dropped.is_empty(), "should not have evicted yet");
383
384        let alloc3 = cache
385            .allocate_for_record("page-3", 1500, 100, |sid| {
386                dropped.push(sid);
387                Ok(())
388            })
389            .unwrap();
390        cache.commit_record("page-3".to_string(), alloc3.seq_id, 1500, 100);
391        // 3000 + 1500 = 4500 > 4096 — must have evicted at least one
392        // entry to make room. LRU picks page-1.
393        assert_eq!(dropped, vec![alloc1.seq_id], "LRU should evict oldest");
394        assert_eq!(cache.stats().entries, 2);
395        assert_eq!(cache.stats().resident_tokens, 3000);
396    }
397
398    #[test]
399    fn oversized_resident_prefix_candidate_is_nonfatal() {
400        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 4096));
401        let small = cache
402            .allocate_for_record("small", 1000, 100, |_| Ok(()))
403            .unwrap();
404        cache.commit_record("small".to_string(), small.seq_id, 1000, 100);
405
406        let allocation = cache
407            .allocate_for_record("huge", 5000, 100, |_| {
408                panic!("oversized candidates should not evict existing entries")
409            })
410            .expect("oversized candidates should be treated as uncacheable, not fatal");
411
412        assert!(!allocation.should_save);
413        assert!(!allocation.should_retain);
414        assert!(allocation.evictions.is_empty());
415        let stats = cache.stats();
416        assert_eq!(stats.entries, 1);
417        assert_eq!(stats.resident_tokens, 1000);
418        assert!(cache.lookup("small").is_some());
419    }
420
421    #[test]
422    fn small_ctx_smoke_test_scenario_records_without_eviction_loop() {
423        // Regression for skippy-ci-smoke `prompt exact-prefix hit and
424        // live-session reuse` (CI run 26193173851). The smoke test
425        // ships SmolLM2-135M with `PROMPT_CTX_SIZE=768` and a 533-token
426        // prompt. With a naive `n_ctx / 2 = 384` cap, the cap was
427        // *smaller than a single prompt*, so the first record attempt
428        // would call `evict_until_room_for` with `over_tokens`
429        // permanently true on an empty cache and bail with
430        // "no releasable entries".
431        //
432        // `ResidentCacheConfig::from_stage` derives the cap from the
433        // model's `n_ctx` via `derive_max_resident_tokens`, which uses
434        // a hard `MIN_CTX_FOR_CELL_CAP = 8192` floor: below that, the
435        // cap is 0 (disabled) regardless of `min_tokens`. The smoke
436        // test runs with `ctx_size = 768`, well below the floor, so
437        // its derived cap is 0. This unit test pins that path: cap=0,
438        // record a 533-token prompt against a 256-token min, no
439        // eviction loop.
440        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
441        let alloc = cache
442            .allocate_for_record("page-0", 533, 100, |_| Ok(()))
443            .unwrap();
444        cache.commit_record("page-0".to_string(), alloc.seq_id, 533, 100);
445        assert_eq!(cache.stats().resident_tokens, 533);
446        assert_eq!(cache.stats().entries, 1);
447    }
448
449    #[test]
450    fn zero_token_budget_disables_the_check() {
451        // max_resident_tokens = 0 means "unlimited" — legacy behavior.
452        // 12 entries at 10k tokens each = 120k tokens, which the
453        // previous unbounded cache happily accepted.
454        let mut cache = ResidentPrefixCache::new(cfg(64, 0, 0));
455        let mut dropped: Vec<i32> = Vec::new();
456
457        for i in 0..12 {
458            let alloc = cache
459                .allocate_for_record(&format!("page-{i}"), 10_000, 100, |sid| {
460                    dropped.push(sid);
461                    Ok(())
462                })
463                .unwrap();
464            cache.commit_record(format!("page-{i}"), alloc.seq_id, 10_000, 100);
465        }
466        assert!(
467            dropped.is_empty(),
468            "zero token budget should not trigger evictions"
469        );
470        assert_eq!(cache.stats().entries, 12);
471        assert_eq!(cache.stats().resident_tokens, 120_000);
472    }
473
474    #[test]
475    fn evict_one_lru_evicts_least_recently_used() {
476        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
477        let mut dropped: Vec<i32> = Vec::new();
478
479        // Insert 3 entries.
480        for i in 0..3 {
481            let alloc = cache
482                .allocate_for_record(&format!("page-{i}"), 500, 100, |sid| {
483                    dropped.push(sid);
484                    Ok(())
485                })
486                .unwrap();
487            cache.commit_record(format!("page-{i}"), alloc.seq_id, 500, 100);
488        }
489        assert_eq!(cache.stats().entries, 3);
490        assert_eq!(cache.stats().resident_tokens, 1500);
491
492        // Touch page-1 and page-2 (LRU should now be page-0).
493        cache.lookup("page-1");
494        cache.lookup("page-2");
495
496        let evicted = cache
497            .evict_one_lru_entry(&mut |sid| {
498                dropped.push(sid);
499                Ok(())
500            })
501            .unwrap()
502            .expect("should have evicted one entry");
503
504        assert_eq!(evicted.page_id, "page-0", "LRU should be page-0");
505        assert_eq!(evicted.token_count, 500);
506        assert_eq!(cache.stats().entries, 2);
507        assert_eq!(cache.stats().resident_tokens, 1000);
508    }
509
510    #[test]
511    fn evict_one_lru_skips_borrowed_entries() {
512        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
513        let mut dropped: Vec<i32> = Vec::new();
514
515        let alloc0 = cache
516            .allocate_for_record("page-0", 500, 100, |sid| {
517                dropped.push(sid);
518                Ok(())
519            })
520            .unwrap();
521        cache.commit_record("page-0".to_string(), alloc0.seq_id, 500, 100);
522
523        let alloc1 = cache
524            .allocate_for_record("page-1", 500, 100, |sid| {
525                dropped.push(sid);
526                Ok(())
527            })
528            .unwrap();
529        cache.commit_record("page-1".to_string(), alloc1.seq_id, 500, 100);
530
531        // Borrow page-0 (the oldest entry).
532        cache.acquire("page-0");
533
534        let evicted = cache
535            .evict_one_lru_entry(&mut |sid| {
536                dropped.push(sid);
537                Ok(())
538            })
539            .unwrap()
540            .expect("should have evicted one entry");
541
542        // page-0 is borrowed, so LRU should skip it and evict page-1.
543        assert_eq!(evicted.page_id, "page-1", "should skip borrowed entry");
544        assert_eq!(cache.stats().entries, 1);
545    }
546
547    #[test]
548    fn evict_one_lru_empty_cache_returns_none() {
549        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
550        let result = cache.evict_one_lru_entry(&mut |_| Ok(())).unwrap();
551        assert!(result.is_none(), "empty cache should return None");
552    }
553
554    #[test]
555    fn evict_one_lru_all_borrowed_returns_none() {
556        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
557        let mut dropped: Vec<i32> = Vec::new();
558
559        let alloc = cache
560            .allocate_for_record("page-0", 500, 100, |sid| {
561                dropped.push(sid);
562                Ok(())
563            })
564            .unwrap();
565        cache.commit_record("page-0".to_string(), alloc.seq_id, 500, 100);
566        cache.acquire("page-0");
567
568        let result = cache.evict_one_lru_entry(&mut |_| Ok(())).unwrap();
569        assert!(
570            result.is_none(),
571            "cache with only borrowed entries should return None"
572        );
573        assert_eq!(cache.stats().entries, 1);
574    }
575
576    #[test]
577    fn evict_one_lru_multiple_calls_drain_all_non_borrowed() {
578        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
579        let mut dropped: Vec<i32> = Vec::new();
580
581        let mut seq_ids = Vec::new();
582        for i in 0..4 {
583            let alloc = cache
584                .allocate_for_record(&format!("page-{i}"), 300, 50, |_| Ok(()))
585                .unwrap();
586            seq_ids.push(alloc.seq_id);
587            cache.commit_record(format!("page-{i}"), alloc.seq_id, 300, 50);
588        }
589        assert_eq!(cache.stats().entries, 4);
590
591        // Touch two to shift LRU ordering.
592        cache.lookup("page-2");
593        cache.lookup("page-3");
594
595        cache.acquire("page-2");
596
597        let e1 = cache
598            .evict_one_lru_entry(&mut |sid| {
599                dropped.push(sid);
600                Ok(())
601            })
602            .unwrap()
603            .expect("first eviction");
604        assert_eq!(e1.page_id, "page-0");
605        assert_eq!(cache.stats().entries, 3);
606
607        let e2 = cache
608            .evict_one_lru_entry(&mut |sid| {
609                dropped.push(sid);
610                Ok(())
611            })
612            .unwrap()
613            .expect("second eviction");
614        assert_eq!(e2.page_id, "page-1");
615        assert_eq!(cache.stats().entries, 2);
616
617        let e3 = cache
618            .evict_one_lru_entry(&mut |sid| {
619                dropped.push(sid);
620                Ok(())
621            })
622            .unwrap()
623            .expect("third eviction");
624        assert_eq!(e3.page_id, "page-3");
625        assert_eq!(cache.stats().entries, 1);
626
627        let e4 = cache.evict_one_lru_entry(&mut |_| Ok(())).unwrap();
628        assert!(e4.is_none(), "only borrowed remains");
629        assert_eq!(cache.stats().entries, 1);
630
631        assert!(dropped.contains(&seq_ids[0]), "page-0 seq_id dropped");
632        assert!(dropped.contains(&seq_ids[1]), "page-1 seq_id dropped");
633        assert!(dropped.contains(&seq_ids[3]), "page-3 seq_id dropped");
634    }
635
636    #[test]
637    fn evict_one_lru_updates_internal_accounting() {
638        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
639        let alloc = cache
640            .allocate_for_record("page-0", 1000, 200, |_| Ok(()))
641            .unwrap();
642        cache.commit_record("page-0".to_string(), alloc.seq_id, 1000, 200);
643
644        assert_eq!(cache.stats().resident_tokens, 1000);
645        assert_eq!(cache.stats().estimated_bytes, 200);
646        assert_eq!(cache.stats().entries, 1);
647
648        let evicted = cache
649            .evict_one_lru_entry(&mut |_| Ok(()))
650            .unwrap()
651            .expect("should evict");
652
653        assert_eq!(cache.stats().resident_tokens, 0);
654        assert_eq!(cache.stats().estimated_bytes, 0);
655        assert_eq!(cache.stats().entries, 0);
656        assert_eq!(evicted.token_count, 1000);
657        assert_eq!(evicted.seq_id, alloc.seq_id);
658        assert!(
659            cache.free_seq_ids.contains(&alloc.seq_id),
660            "evicted seq_id should be reusable"
661        );
662    }
663
664    #[test]
665    fn evict_one_lru_drop_failure_preserves_cache_state() {
666        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
667        let alloc = cache
668            .allocate_for_record("page-0", 1000, 200, |_| Ok(()))
669            .unwrap();
670        cache.commit_record("page-0".to_string(), alloc.seq_id, 1000, 200);
671
672        let error = cache.evict_one_lru_entry(&mut |_| Err(anyhow::anyhow!("native drop failed")));
673
674        assert!(error.is_err());
675        assert_eq!(cache.stats().entries, 1);
676        assert_eq!(cache.stats().resident_tokens, 1000);
677        assert_eq!(cache.stats().estimated_bytes, 200);
678        assert!(cache.lookup("page-0").is_some());
679        assert!(cache.free_seq_ids.is_empty());
680    }
681
682    #[test]
683    fn allocate_for_record_drop_failure_preserves_existing_cache_state() {
684        let mut cache = ResidentPrefixCache::new(cfg(1, 0, 0));
685        let alloc = cache
686            .allocate_for_record("page-0", 1000, 200, |_| Ok(()))
687            .unwrap();
688        cache.commit_record("page-0".to_string(), alloc.seq_id, 1000, 200);
689
690        let error = cache.allocate_for_record("page-1", 1000, 200, |_| {
691            Err(anyhow::anyhow!("native drop failed"))
692        });
693
694        assert!(error.is_err());
695        assert_eq!(cache.stats().entries, 1);
696        assert_eq!(cache.stats().resident_tokens, 1000);
697        assert_eq!(cache.stats().estimated_bytes, 200);
698        assert!(cache.lookup("page-0").is_some());
699        assert!(cache.lookup("page-1").is_none());
700        assert!(cache.free_seq_ids.is_empty());
701    }
702
703    #[test]
704    fn evict_lru_until_tokens_evicts_multiple_entries_until_target() {
705        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
706        let mut seq_ids = Vec::new();
707        for (index, token_count) in [300, 400, 500].into_iter().enumerate() {
708            let alloc = cache
709                .allocate_for_record(&format!("page-{index}"), token_count, 100, |_| Ok(()))
710                .unwrap();
711            seq_ids.push(alloc.seq_id);
712            cache.commit_record(format!("page-{index}"), alloc.seq_id, token_count, 100);
713        }
714
715        let mut dropped = Vec::new();
716        let evictions = cache
717            .evict_lru_until_tokens(700, &mut |seq_id| {
718                dropped.push(seq_id);
719                Ok(())
720            })
721            .unwrap();
722
723        assert_eq!(evictions.len(), 2);
724        assert_eq!(evictions[0].page_id, "page-0");
725        assert_eq!(evictions[1].page_id, "page-1");
726        assert_eq!(dropped, vec![seq_ids[0], seq_ids[1]]);
727        assert_eq!(cache.stats().entries, 1);
728        assert_eq!(cache.stats().resident_tokens, 500);
729        assert!(cache.lookup("page-2").is_some());
730    }
731
732    #[test]
733    fn evict_lru_until_tokens_stops_when_no_releasable_entries_remain() {
734        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
735        for (index, token_count) in [300, 400].into_iter().enumerate() {
736            let alloc = cache
737                .allocate_for_record(&format!("page-{index}"), token_count, 100, |_| Ok(()))
738                .unwrap();
739            cache.commit_record(format!("page-{index}"), alloc.seq_id, token_count, 100);
740        }
741        cache.acquire("page-1");
742
743        let evictions = cache.evict_lru_until_tokens(1024, &mut |_| Ok(())).unwrap();
744
745        assert_eq!(evictions.len(), 1);
746        assert_eq!(evictions[0].page_id, "page-0");
747        assert_eq!(cache.stats().entries, 1);
748        assert_eq!(cache.stats().resident_tokens, 400);
749        assert!(cache.lookup("page-1").is_none());
750    }
751
752    #[test]
753    fn evict_one_lru_seq_id_reused_on_subsequent_allocation() {
754        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
755        let alloc = cache
756            .allocate_for_record("page-0", 500, 100, |_| Ok(()))
757            .unwrap();
758        let orig_seq_id = alloc.seq_id;
759        cache.commit_record("page-0".to_string(), orig_seq_id, 500, 100);
760
761        cache.evict_one_lru_entry(&mut |_| Ok(())).unwrap();
762
763        let alloc2 = cache
764            .allocate_for_record("page-1", 500, 100, |_| Ok(()))
765            .unwrap();
766        assert_eq!(
767            alloc2.seq_id, orig_seq_id,
768            "should reuse evicted seq_id before allocating new one"
769        );
770    }
771
772    #[test]
773    fn evict_one_lru_then_recommit_same_page_id() {
774        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
775        let alloc = cache
776            .allocate_for_record("page-0", 500, 100, |_| Ok(()))
777            .unwrap();
778        cache.commit_record("page-0".to_string(), alloc.seq_id, 500, 100);
779
780        cache.evict_one_lru_entry(&mut |_| Ok(())).unwrap();
781
782        assert_eq!(cache.stats().entries, 0);
783        assert_eq!(cache.stats().resident_tokens, 0);
784
785        let alloc2 = cache
786            .allocate_for_record("page-0", 800, 150, |_| Ok(()))
787            .unwrap();
788        cache.commit_record("page-0".to_string(), alloc2.seq_id, 800, 150);
789
790        assert_eq!(cache.stats().entries, 1);
791        assert_eq!(cache.stats().resident_tokens, 800);
792        assert_eq!(cache.stats().estimated_bytes, 150);
793    }
794
795    #[test]
796    fn evict_one_lru_preserves_allocate_for_record_eviction_logic() {
797        let mut cache = ResidentPrefixCache::new(cfg(4, 0, 4096));
798        let mut dropped: Vec<i32> = Vec::new();
799
800        for i in 0..4 {
801            let alloc = cache
802                .allocate_for_record(&format!("page-{i}"), 500, 100, |_| Ok(()))
803                .unwrap();
804            cache.commit_record(format!("page-{i}"), alloc.seq_id, 500, 100);
805        }
806        assert_eq!(cache.stats().entries, 4);
807
808        cache
809            .evict_one_lru_entry(&mut |sid| {
810                dropped.push(sid);
811                Ok(())
812            })
813            .unwrap()
814            .expect("should evict");
815        assert_eq!(cache.stats().entries, 3);
816
817        let alloc = cache
818            .allocate_for_record("page-4", 500, 100, |sid| {
819                dropped.push(sid);
820                Ok(())
821            })
822            .unwrap();
823        assert!(
824            alloc.evictions.is_empty(),
825            "allocate after proactive eviction should not need more evictions"
826        );
827        cache.commit_record("page-4".to_string(), alloc.seq_id, 500, 100);
828        assert_eq!(cache.stats().entries, 4);
829    }
830
831    #[test]
832    fn evict_one_lru_after_release_shifts_lru_ordering() {
833        let mut cache = ResidentPrefixCache::new(cfg(16, 0, 0));
834        let mut dropped: Vec<i32> = Vec::new();
835
836        let alloc0 = cache
837            .allocate_for_record("page-0", 500, 100, |_| Ok(()))
838            .unwrap();
839        cache.commit_record("page-0".to_string(), alloc0.seq_id, 500, 100);
840
841        let alloc1 = cache
842            .allocate_for_record("page-1", 500, 100, |_| Ok(()))
843            .unwrap();
844        cache.commit_record("page-1".to_string(), alloc1.seq_id, 500, 100);
845
846        // Release bumps last_used, so page-0 becomes strictly newer.
847        cache.acquire("page-0");
848        cache.release("page-0");
849
850        let evicted = cache
851            .evict_one_lru_entry(&mut |sid| {
852                dropped.push(sid);
853                Ok(())
854            })
855            .unwrap()
856            .expect("should evict");
857        assert_eq!(
858            evicted.page_id, "page-1",
859            "recently released entry should not be LRU immediately"
860        );
861    }
862
863    #[test]
864    fn evict_one_lru_from_cache_at_entry_cap_does_not_panic() {
865        let mut cache = ResidentPrefixCache::new(cfg(4, 0, 0));
866        let mut dropped: Vec<i32> = Vec::new();
867        for i in 0..4 {
868            let alloc = cache
869                .allocate_for_record(&format!("page-{i}"), 500, 100, |_| Ok(()))
870                .unwrap();
871            cache.commit_record(format!("page-{i}"), alloc.seq_id, 500, 100);
872        }
873        assert_eq!(cache.stats().entries, 4);
874
875        let evicted = cache
876            .evict_one_lru_entry(&mut |sid| {
877                dropped.push(sid);
878                Ok(())
879            })
880            .unwrap()
881            .expect("should evict at cap");
882        assert_eq!(evicted.page_id, "page-0");
883        assert_eq!(cache.stats().entries, 3);
884    }
885}