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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}