Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-util/src/evicting_map.rs
Line
Count
Source
1
// Copyright 2024 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use core::borrow::Borrow;
16
use core::cmp::Eq;
17
use core::fmt::{Debug, Display};
18
use core::future::Future;
19
use core::hash::Hash;
20
use core::marker::PhantomData;
21
use core::ops::RangeBounds;
22
use core::pin::Pin;
23
use std::collections::{BTreeSet, HashMap};
24
use std::sync::{Arc, OnceLock};
25
26
use futures::StreamExt;
27
use futures::stream::FuturesUnordered;
28
use lru::LruCache;
29
use nativelink_config::stores::EvictionPolicy;
30
use nativelink_metric::MetricsComponent;
31
use opentelemetry::KeyValue;
32
use parking_lot::Mutex;
33
use serde::{Deserialize, Serialize};
34
use tracing::{debug, info};
35
36
use crate::instant_wrapper::InstantWrapper;
37
use crate::metrics::{record_cache_entries_delta, saturating_i64};
38
use crate::metrics_utils::{Counter, CounterWithTime};
39
40
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
41
pub struct SerializedLRU<K> {
42
    pub data: Vec<(K, i32)>,
43
    pub anchor_time: u64,
44
}
45
46
#[derive(Debug)]
47
struct EvictionItem<T: LenEntry + Debug> {
48
    seconds_since_anchor: i32,
49
    data: T,
50
}
51
52
pub trait LenEntry: 'static {
53
    /// Length of referenced data.
54
    fn len(&self) -> u64;
55
56
    /// Length of the content a read of the entry returns. This, not `len()`,
57
    /// is the size `sizes_for_keys` reports, since callers size reads by it.
58
    #[inline]
59
34.2k
    fn content_len(&self) -> u64 {
60
34.2k
        self.len()
61
34.2k
    }
62
63
    /// Returns `true` if `self` has zero length.
64
    fn is_empty(&self) -> bool;
65
66
    /// This will be called when object is removed from map.
67
    /// Note: There may still be a reference to it held somewhere else, which
68
    /// is why it can't be mutable. This is a good place to mark the item
69
    /// to be deleted and then in the Drop call actually do the deleting.
70
    /// This will ensure nowhere else in the program still holds a reference
71
    /// to this object.
72
    /// You should not rely only on the Drop trait. Doing so might result in the
73
    /// program safely shutting down and calling the Drop method on each object,
74
    /// which if you are deleting items you may not want to do.
75
    /// It is undefined behavior to have `unref()` called more than once.
76
    /// Runs outside the map lock. Another task may insert a replacement for
77
    /// the same key before this call finishes; cleanup must only affect the
78
    /// removed entry's resources.
79
    #[inline]
80
16.3k
    fn unref(&self) -> impl Future<Output = ()> + Send {
81
16.3k
        core::future::ready(())
82
16.3k
    }
83
}
84
85
impl<T: LenEntry + Send + Sync> LenEntry for Arc<T> {
86
    #[inline]
87
28.4k
    fn len(&self) -> u64 {
88
28.4k
        T::len(self.as_ref())
89
28.4k
    }
90
91
    #[inline]
92
978
    fn content_len(&self) -> u64 {
93
978
        T::content_len(self.as_ref())
94
978
    }
95
96
    #[inline]
97
0
    fn is_empty(&self) -> bool {
98
0
        T::is_empty(self.as_ref())
99
0
    }
100
101
    #[inline]
102
59
    async fn unref(&self) {
103
59
        self.as_ref().unref().await;
104
59
    }
105
}
106
107
// Callback to be called when the EvictingMap removes an item
108
// either via eviction or direct deletion. This will be called with
109
// whatever key type the EvictingMap uses.
110
pub trait RemoveItemCallback<Q>: Debug + Send + Sync {
111
    fn callback(&self, store_key: &Q) -> Pin<Box<dyn Future<Output = ()> + Send>>;
112
}
113
114
#[derive(Debug, MetricsComponent)]
115
struct State<
116
    K: Ord + Hash + Eq + Clone + Debug + Send + Borrow<Q>,
117
    Q: Ord + Hash + Eq + Debug,
118
    T: LenEntry + Debug + Send,
119
    C: RemoveItemCallback<Q>,
120
> {
121
    lru: LruCache<K, EvictionItem<T>>,
122
    btree: Option<BTreeSet<K>>,
123
    /// A mirror of `lru` that contains only currently unleased keys.
124
    /// Keeping this index in LRU order lets eviction visit candidates without
125
    /// scanning entries that an active operation has pinned.
126
    evictable_lru: LruCache<K, ()>,
127
    /// Reference counts for keys that must not be evicted while an active
128
    /// operation is using them. A key can be leased before it is inserted,
129
    /// which closes the admission-to-insert race for cache populations.
130
    leases: HashMap<K, u32>,
131
    #[metric(help = "Total size of all items in the store")]
132
    sum_store_size: u64,
133
    #[metric(help = "Number of items currently leased and not evictable")]
134
    leased_items: u64,
135
    #[metric(help = "Number of bytes currently leased and not evictable")]
136
    leased_bytes: u64,
137
138
    #[metric(help = "Number of bytes evicted from the store")]
139
    evicted_bytes: Counter,
140
    #[metric(help = "Number of items evicted from the store")]
141
    evicted_items: CounterWithTime,
142
    #[metric(help = "Number of bytes replaced in the store")]
143
    replaced_bytes: Counter,
144
    #[metric(help = "Number of items replaced in the store")]
145
    replaced_items: CounterWithTime,
146
    #[metric(help = "Number of bytes inserted into the store since it was created")]
147
    lifetime_inserted_bytes: Counter,
148
149
    _key_type: PhantomData<Q>,
150
    remove_callbacks: Vec<C>,
151
    /// Size and entry-count changes waiting to be reported as `cache.size` and
152
    /// `cache.entries`. Recording to `OpenTelemetry` costs far more than the
153
    /// plain atomic counters beside it, so the mutation paths only do integer
154
    /// arithmetic here and the totals are flushed once the lock is released.
155
    /// A flush that is missed only delays the next update, it never loses a
156
    /// change.
157
    pending_size_delta: i64,
158
    pending_entries_delta: i64,
159
}
160
161
type RemoveFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
162
163
impl<
164
    K: Ord + Hash + Eq + Clone + Debug + Send + Sync + Borrow<Q>,
165
    Q: Ord + Hash + Eq + Debug + Sync,
166
    T: LenEntry + Debug + Sync + Send,
167
    C: RemoveItemCallback<Q>,
168
> State<K, Q, T, C>
169
{
170
326k
    fn is_leased(&self, key: &Q) -> bool {
171
326k
        self.leases.contains_key(key)
172
326k
    }
173
174
    /// Keep candidate recency aligned with a normal read from `lru`.
175
    /// Returns whether the key is leased so callers do not repeat the lookup.
176
168k
    fn touch_evictable(&mut self, key: &Q) -> bool {
177
168k
        if self.is_leased(key) {
178
103
            return true;
179
168k
        }
180
168k
        self.evictable_lru.get(key);
181
168k
        false
182
168k
    }
183
184
    /// Add a newly written key to the candidate index unless it is reserved
185
    /// by a lease that was acquired before insertion.
186
70.7k
    fn insert_evictable(&mut self, key: K) {
187
70.7k
        if !self.is_leased(key.borrow()) {
188
70.7k
            self.evictable_lru.put(key, ());
189
70.7k
        
}11
190
70.7k
    }
191
192
49
    fn lease(&mut self, key: K) {
193
49
        if let Some(
lease_count2
) = self.leases.get_mut(key.borrow()) {
194
2
            *lease_count = lease_count.checked_add(1).expect("lease count overflow");
195
2
            return;
196
47
        }
197
198
47
        self.evictable_lru.pop(key.borrow());
199
47
        if let Some(
entry28
) = self.lru.peek(key.borrow()) {
200
28
            self.leased_bytes += entry.data.len();
201
28
        
}19
202
47
        self.leased_items += 1;
203
47
        self.leases.insert(key, 1);
204
49
    }
205
206
    /// Release one reference and return the owned key when the lease ends.
207
46
    fn release_lease(&mut self, key: &Q) -> Option<K> {
208
46
        let lease_count = self.leases.get_mut(key)
?0
;
209
46
        if *lease_count > 1 {
210
2
            *lease_count -= 1;
211
2
            return None;
212
44
        }
213
214
44
        let leased_key = self
215
44
            .leases
216
44
            .remove_entry(key)
217
44
            .expect("Lease must exist when its final reference is released")
218
44
            .0;
219
44
        self.leased_items -= 1;
220
44
        if let Some(
entry35
) = self.lru.peek(leased_key.borrow()) {
221
35
            self.leased_bytes -= entry.data.len();
222
35
        
}9
223
44
        Some(leased_key)
224
46
    }
225
226
    /// Re-enter a resident key as the newest eviction candidate. Its age is
227
    /// intentionally preserved, so a long lease does not extend the TTL.
228
44
    fn reinsert_evictable(&mut self, key: K) {
229
44
        if self.lru.peek(key.borrow()).is_some() {
230
35
            self.evictable_lru.put(key, ());
231
35
        
}9
232
44
    }
233
234
141k
    fn peek_evictable(&self) -> Option<&EvictionItem<T>> {
235
141k
        let (
key141k
, ()) = self.evictable_lru.peek_lru()
?29
;
236
141k
        Some(
237
141k
            self.lru
238
141k
                .peek(key.borrow())
239
141k
                .expect("Evictable LRU key must be resident in the main LRU"),
240
141k
        )
241
141k
    }
242
243
52
    fn pop_evictable(&mut self) -> Option<(K, EvictionItem<T>)> {
244
52
        let (candidate_key, ()) = self.evictable_lru.pop_lru()
?0
;
245
52
        let (key, entry) = self
246
52
            .lru
247
52
            .pop_entry(candidate_key.borrow())
248
52
            .expect("Evictable LRU key must be resident in the main LRU");
249
52
        Some((key, entry))
250
52
    }
251
252
    /// Removes an item from the cache and returns the data for deferred cleanup.
253
    /// The caller is responsible for calling `unref()` on the returned data outside of the lock.
254
    #[must_use]
255
16.3k
    fn remove(
256
16.3k
        &mut self,
257
16.3k
        key: &Q,
258
16.3k
        eviction_item: &EvictionItem<T>,
259
16.3k
        replaced: bool,
260
16.3k
    ) -> (T, Vec<RemoveFuture>)
261
16.3k
    where
262
16.3k
        T: Clone,
263
    {
264
16.3k
        if let Some(
btree1
) = &mut self.btree {
265
1
            btree.remove(key);
266
16.3k
        }
267
        // Keep every auxiliary resident index in sync with the main LRU.
268
16.3k
        self.evictable_lru.pop(key);
269
16.3k
        if self.is_leased(key) {
270
1
            self.leased_bytes -= eviction_item.data.len();
271
16.3k
        }
272
16.3k
        self.sum_store_size -= eviction_item.data.len();
273
16.3k
        self.pending_size_delta -= saturating_i64(eviction_item.data.len());
274
16.3k
        self.pending_entries_delta -= 1;
275
16.3k
        if replaced {
276
16.2k
            self.replaced_items.inc();
277
16.2k
            self.replaced_bytes.add(eviction_item.data.len());
278
16.2k
        } else {
279
126
            self.evicted_items.inc();
280
126
            self.evicted_bytes.add(eviction_item.data.len());
281
126
        }
282
283
16.3k
        let callbacks = self
284
16.3k
            .remove_callbacks
285
16.3k
            .iter()
286
16.3k
            .map(|callback| 
callback51
.
callback51
(
key51
))
287
16.3k
            .collect();
288
289
        // Return the data for deferred unref outside of lock
290
16.3k
        (eviction_item.data.clone(), callbacks)
291
16.3k
    }
292
293
    /// Inserts a new item into the cache. If the key already exists, the old item is returned
294
    /// for deferred cleanup.
295
    #[must_use]
296
70.7k
    fn put(&mut self, key: &K, eviction_item: EvictionItem<T>) -> Option<(T, Vec<RemoveFuture>)>
297
70.7k
    where
298
70.7k
        K: Clone,
299
70.7k
        T: Clone,
300
    {
301
70.7k
        let is_leased = self.is_leased(key.borrow());
302
70.7k
        let new_item_size = eviction_item.data.len();
303
70.7k
        let replaced_item = self
304
70.7k
            .lru
305
70.7k
            .put(key.clone(), eviction_item)
306
70.7k
            .map(|old_item| 
self16.2k
.
remove16.2k
(
key.borrow()16.2k
,
&old_item16.2k
, true));
307
308
70.7k
        if is_leased {
309
11
            self.leased_bytes += new_item_size;
310
70.7k
        }
311
        // `remove()` drops the old key from both indexes. Reinsert it after
312
        // replacement so an unleased write is MRU in both LRU indexes.
313
70.7k
        self.insert_evictable(key.clone());
314
70.7k
        if let Some(
btree6
) = &mut self.btree {
315
6
            btree.insert(key.clone());
316
70.7k
        }
317
318
70.7k
        replaced_item
319
70.7k
    }
320
321
217
    fn add_remove_callback(&mut self, callback: C) {
322
217
        self.remove_callbacks.push(callback);
323
217
    }
324
}
325
326
#[derive(Debug, Clone, Copy)]
327
pub struct NoopRemove;
328
329
impl<Q> RemoveItemCallback<Q> for NoopRemove {
330
0
    fn callback(&self, _store_key: &Q) -> Pin<Box<dyn Future<Output = ()> + Send>> {
331
0
        Box::pin(async {})
332
0
    }
333
}
334
335
#[derive(Debug, MetricsComponent)]
336
pub struct EvictingMap<
337
    K: Ord + Hash + Eq + Clone + Debug + Send + Borrow<Q>,
338
    Q: Ord + Hash + Eq + Debug,
339
    T: LenEntry + Debug + Send,
340
    I: InstantWrapper,
341
    C: RemoveItemCallback<Q> = NoopRemove,
342
> {
343
    #[metric]
344
    state: Mutex<State<K, Q, T, C>>,
345
    anchor_time: I,
346
    #[metric(help = "Maximum size of the store in bytes")]
347
    max_bytes: u64,
348
    #[metric(help = "Number of bytes to evict when the store is full")]
349
    evict_bytes: u64,
350
    #[metric(help = "Maximum number of seconds to keep an item in the store")]
351
    max_seconds: i32,
352
    #[metric(help = "Maximum number of items to keep in the store")]
353
    max_count: u64,
354
    /// Attributes to report `cache.size` and `cache.entries` under. Unset until
355
    /// a `cache_metrics` wrapper enables it, which is what keeps those two
356
    /// instruments off by default.
357
    cache_size_attrs: OnceLock<Vec<KeyValue>>,
358
}
359
360
// debugging helper used mostly to get a snapshot of what eviction threshold might be causing issues
361
#[derive(Debug, Copy, Clone)]
362
pub struct EvictionSnapshot {
363
    max_bytes: u64,
364
    current_bytes: u64,
365
    max_items: u64,
366
    current_items: usize,
367
    max_seconds: i32,
368
}
369
370
impl Display for EvictionSnapshot {
371
4
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
372
4
        if self.max_bytes != 0 {
373
2
            write!(
374
2
                f,
375
                "Bytes: {} of {} ({:.3}%); ",
376
                self.current_bytes,
377
                self.max_bytes,
378
2
                (self.current_bytes as f64 * 100.0) / (self.max_bytes as f64)
379
0
            )?;
380
        } else {
381
2
            write!(f, "Bytes: {} of unlimited; ", self.current_bytes)
?0
;
382
        }
383
4
        if self.max_items != 0 {
384
2
            write!(
385
2
                f,
386
                "Items: {} of {} ({:.3}%); ",
387
                self.current_items,
388
                self.max_items,
389
2
                (self.current_items as f64 * 100.0) / (self.max_items as f64)
390
0
            )?;
391
        } else {
392
2
            write!(f, "Items: {} of unlimited; ", self.current_items)
?0
;
393
        }
394
4
        if self.max_seconds > 0 {
395
1
            write!(f, "Timeout: {}s", self.max_seconds)
?0
;
396
        } else {
397
3
            write!(f, "Timeout: unlimited")
?0
;
398
        }
399
4
        Ok(())
400
4
    }
401
}
402
403
impl<K, Q, T, I, C> EvictingMap<K, Q, T, I, C>
404
where
405
    K: Ord + Hash + Eq + Clone + Debug + Send + Sync + Borrow<Q>,
406
    Q: Ord + Hash + Eq + Debug + Sync,
407
    T: LenEntry + Debug + Clone + Send + Sync,
408
    I: InstantWrapper,
409
    C: RemoveItemCallback<Q>,
410
{
411
1.49k
    pub fn new(config: &EvictionPolicy, anchor_time: I) -> Self {
412
1.49k
        Self {
413
1.49k
            // We use unbounded because if we use the bounded version we can't call the delete
414
1.49k
            // function on the LenEntry properly.
415
1.49k
            state: Mutex::new(State {
416
1.49k
                lru: LruCache::unbounded(),
417
1.49k
                btree: None,
418
1.49k
                evictable_lru: LruCache::unbounded(),
419
1.49k
                leases: HashMap::new(),
420
1.49k
                sum_store_size: 0,
421
1.49k
                leased_items: 0,
422
1.49k
                leased_bytes: 0,
423
1.49k
                evicted_bytes: Counter::default(),
424
1.49k
                evicted_items: CounterWithTime::default(),
425
1.49k
                replaced_bytes: Counter::default(),
426
1.49k
                replaced_items: CounterWithTime::default(),
427
1.49k
                lifetime_inserted_bytes: Counter::default(),
428
1.49k
                _key_type: PhantomData,
429
1.49k
                remove_callbacks: Vec::new(),
430
1.49k
                pending_size_delta: 0,
431
1.49k
                pending_entries_delta: 0,
432
1.49k
            }),
433
1.49k
            anchor_time,
434
1.49k
            max_bytes: config.max_bytes as u64,
435
1.49k
            evict_bytes: config.evict_bytes as u64,
436
1.49k
            max_seconds: config.max_seconds.try_into().unwrap_or(i32::MAX),
437
1.49k
            max_count: config.max_count,
438
1.49k
            cache_size_attrs: OnceLock::new(),
439
1.49k
        }
440
1.49k
    }
441
442
    /// Reports this map's size and entry count as `cache.size` and
443
    /// `cache.entries` under `attrs`, starting from what it already holds.
444
    /// Only the first call takes effect, so a map is never counted twice.
445
6
    pub fn enable_cache_size_metrics(&self, attrs: Vec<KeyValue>) {
446
6
        if self.cache_size_attrs.set(attrs).is_err() {
447
2
            return;
448
4
        }
449
        // Nothing has been reported yet, so the first flush is the map's
450
        // current totals. Overwrite rather than add: the mutations that filled
451
        // the map accumulated deltas too, and counting those as well would
452
        // report everything already resident twice.
453
4
        {
454
4
            let mut state = self.state.lock();
455
4
            state.pending_size_delta = saturating_i64(state.sum_store_size);
456
4
            state.pending_entries_delta = i64::try_from(state.lru.len()).unwrap_or(i64::MAX);
457
4
        }
458
4
        self.flush_cache_size_metrics();
459
6
    }
460
461
    /// Records the size and entry-count changes accumulated under the lock.
462
    ///
463
    /// Call after releasing the state lock: reporting to `OpenTelemetry` is
464
    /// much costlier than the counters updated inside it, and the state lock is
465
    /// contended across the whole store.
466
149k
    fn flush_cache_size_metrics(&self) {
467
149k
        let Some(
attrs17
) = self.cache_size_attrs.get() else {
468
149k
            return;
469
        };
470
17
        let (size_delta, entries_delta) = {
471
17
            let mut state = self.state.lock();
472
17
            (
473
17
                core::mem::take(&mut state.pending_size_delta),
474
17
                core::mem::take(&mut state.pending_entries_delta),
475
17
            )
476
17
        };
477
17
        if size_delta == 0 && 
entries_delta == 08
{
478
8
            return;
479
9
        }
480
9
        record_cache_entries_delta(size_delta, entries_delta, attrs);
481
149k
    }
482
483
    // Only used for tests
484
1
    pub fn enable_filtering(&self) {
485
1
        let mut state = self.state.lock();
486
1
        if state.btree.is_none() {
487
1
            Self::rebuild_btree_index(&mut state);
488
1
        
}0
489
1
    }
490
491
6
    fn rebuild_btree_index(state: &mut State<K, Q, T, C>) {
492
6
        state.btree = Some(state.lru.iter().map(|(k, _)| k).cloned().collect());
493
6
    }
494
495
    /// Run the `handler` function on each key-value pair that matches the `prefix_range`
496
    /// and return the number of items that were processed.
497
    /// The `handler` function should return `true` to continue processing the next item
498
    /// or `false` to stop processing.
499
18
    pub fn range<F>(&self, prefix_range: impl RangeBounds<Q> + Send, mut handler: F) -> u64
500
18
    where
501
18
        F: FnMut(&K, &T) -> bool + Send,
502
18
        K: Ord,
503
    {
504
18
        let mut state = self.state.lock();
505
18
        let btree = if let Some(
ref btree13
) = state.btree {
506
13
            btree
507
        } else {
508
5
            Self::rebuild_btree_index(&mut state);
509
5
            state.btree.as_ref().unwrap()
510
        };
511
18
        let mut continue_count = 0;
512
176
        for key in 
btree18
.
range18
(
prefix_range18
) {
513
176
            let value = &state.lru.peek(key.borrow()).unwrap().data;
514
176
            let should_continue = handler(key, value);
515
176
            if !should_continue {
516
1
                break;
517
175
            }
518
175
            continue_count += 1;
519
        }
520
18
        continue_count
521
18
    }
522
523
    /// Returns the number of key-value pairs that are currently in the the cache.
524
    /// Function is not for production code paths.
525
36
    pub fn len_for_test(&self) -> usize {
526
36
        self.state.lock().lru.len()
527
36
    }
528
529
163k
    fn should_evict(
530
163k
        &self,
531
163k
        lru_len: usize,
532
163k
        peek_entry: &EvictionItem<T>,
533
163k
        sum_store_size: u64,
534
163k
        max_bytes: u64,
535
163k
    ) -> bool {
536
163k
        let is_over_size = max_bytes != 0 && 
sum_store_size >= max_bytes92.5k
;
537
538
163k
        let elapsed_seconds = self.elapsed_seconds();
539
163k
        let evict_older_than_seconds = elapsed_seconds.saturating_sub(self.max_seconds);
540
163k
        let old_item_exists =
541
163k
            self.max_seconds != 0 && 
peek_entry.seconds_since_anchor < evict_older_than_seconds465
;
542
543
163k
        let is_over_count =
544
163k
            self.max_count != 0 && 
u64::try_from195
(
lru_len195
).unwrap_or(u64::MAX) > self.max_count;
545
546
163k
        is_over_size || 
old_item_exists163k
||
is_over_count163k
547
163k
    }
548
549
326k
    fn elapsed_seconds(&self) -> i32 {
550
326k
        i32::try_from(self.anchor_time.elapsed().as_secs()).unwrap_or(i32::MAX)
551
326k
    }
552
553
    // Gets a debugging snapshot of the state of the map. It's inevitably out of date by the time it gets sent to the user
554
    // but does provide a momentary glimpse into possible issues e.g. if current_bytes is close to max_bytes
555
4
    pub fn get_snapshot(&self) -> EvictionSnapshot {
556
4
        let state = self.state.lock();
557
4
        EvictionSnapshot {
558
4
            max_bytes: self.max_bytes,
559
4
            current_bytes: state.sum_store_size,
560
4
            max_items: self.max_count,
561
4
            current_items: state.lru.len(),
562
4
            max_seconds: self.max_seconds,
563
4
        }
564
4
    }
565
566
    #[must_use]
567
70.8k
    fn evict_items(&self, state: &mut State<K, Q, T, C>) -> (Vec<T>, Vec<RemoveFuture>) {
568
70.8k
        let Some(
first_entry70.8k
) = state.peek_evictable() else {
569
19
            if !state.lru.is_empty() {
570
9
                debug!(
571
9
                    resident_items = state.lru.len(),
572
9
                    evictable_items = state.evictable_lru.len(),
573
9
                    lease_keys = state.leases.len(),
574
                    leased_items = state.leased_items,
575
                    leased_bytes = state.leased_bytes,
576
                    resident_bytes = state.sum_store_size,
577
                    "Eviction requested, but every resident entry is leased",
578
                );
579
10
            }
580
19
            return (Vec::new(), Vec::new());
581
        };
582
583
        // Preserve the configured low-watermark behavior: once pressure is
584
        // detected, keep evicting until `evict_bytes` is reclaimed.
585
70.8k
        let max_bytes = if self.max_bytes != 0
586
28
            && self.evict_bytes != 0
587
4
            && self.should_evict(
588
4
                state.lru.len(),
589
4
                first_entry,
590
4
                state.sum_store_size,
591
4
                self.max_bytes,
592
            ) {
593
1
            self.max_bytes.saturating_sub(self.evict_bytes)
594
        } else {
595
70.8k
            self.max_bytes
596
        };
597
598
70.8k
        let mut items_to_unref = Vec::new();
599
70.8k
        let mut removal_futures = Vec::new();
600
601
        // Pop the candidate index directly instead of collecting and cloning
602
        // all victim keys. Both indexes are protected by the same lock.
603
70.8k
        while let Some(
entry70.8k
) = state.peek_evictable() {
604
70.8k
            if !self.should_evict(state.lru.len(), entry, state.sum_store_size, max_bytes) {
605
70.8k
                break;
606
52
            }
607
608
52
            let (key, eviction_item) = state
609
52
                .pop_evictable()
610
52
                .expect("Evictable LRU key disappeared while state was locked");
611
52
            debug!(?key, "Evicting");
612
52
            let (data, futures) = state.remove(key.borrow(), &eviction_item, false);
613
52
            items_to_unref.push(data);
614
52
            removal_futures.extend(futures);
615
        }
616
617
70.8k
        (items_to_unref, removal_futures)
618
70.8k
    }
619
620
    /// Return the size of a `key`, if not found `None` is returned.
621
50
    pub async fn size_for_key(&self, key: &Q) -> Option<u64> {
622
50
        let mut results = [None];
623
50
        self.sizes_for_keys([key], &mut results[..], false).await;
624
50
        results[0]
625
50
    }
626
627
    /// Return the sizes of a collection of `keys`. Expects `results` collection
628
    /// to be provided for storing the resulting key sizes. Each index value in
629
    /// `keys` maps directly to the size value for the key in `results`.
630
    /// The size is the entry's `content_len()`, not its eviction `len()`.
631
    /// If no key is found in the internal map, `None` is filled in its place.
632
    /// If `peek` is set to `true`, the items are not promoted to the front of the
633
    /// LRU cache. Note: peek may still evict, but won't promote.
634
21.4k
    pub async fn sizes_for_keys<It, R>(&self, keys: It, results: &mut [Option<u64>], peek: bool)
635
21.4k
    where
636
21.4k
        It: IntoIterator<Item = R> + Send,
637
21.4k
        // Note: It's not enough to have the inserts themselves be Send. The
638
21.4k
        // returned iterator should be Send as well.
639
21.4k
        <It as IntoIterator>::IntoIter: Send,
640
21.4k
        // This may look strange, but what we are doing is saying:
641
21.4k
        // * `K` must be able to borrow `Q`
642
21.4k
        // * `R` (the input stream item type) must also be able to borrow `Q`
643
21.4k
        // Note: That K and R do not need to be the same type, they just both need
644
21.4k
        // to be able to borrow a `Q`.
645
21.4k
        R: Borrow<Q> + Send,
646
21.4k
    {
647
21.4k
        let (removal_futures, data_to_unref) = {
648
21.4k
            let mut state = self.state.lock();
649
650
            // Working copy of the resident count, decremented as entries are
651
            // reaped below. Evaluating `should_evict` against a length
652
            // snapshotted once for the whole batch would keep reporting
653
            // over-count for every remaining key after the first reap brings
654
            // the map back to its limit, cascading into evicting every
655
            // queried entry — reachable from a plain `has()` batch.
656
21.4k
            let mut lru_len = state.lru.len();
657
21.4k
            let mut data_to_unref = Vec::new();
658
21.4k
            let mut removal_futures = Vec::new();
659
37.7k
            for (key, result) in 
keys21.4k
.
into_iter21.4k
().
zip21.4k
(
results21.4k
.
iter_mut21.4k
()) {
660
37.7k
                let is_leased = if peek {
661
25
                    state.is_leased(key.borrow())
662
                } else {
663
37.6k
                    state.touch_evictable(key.borrow())
664
                };
665
37.7k
                let maybe_entry = if peek {
666
25
                    state.lru.peek_mut(key.borrow())
667
                } else {
668
37.6k
                    state.lru.get_mut(key.borrow())
669
                };
670
37.7k
                match maybe_entry {
671
35.2k
                    Some(entry) => {
672
                        // Note: We need to check eviction because the item might be expired
673
                        // based on the current time. In such case, we remove the item while
674
                        // we are here.
675
35.2k
                        if !is_leased && 
self35.2k
.
should_evict35.2k
(
lru_len35.2k
,
entry35.2k
, 0, u64::MAX) {
676
3
                            *result = None;
677
3
                            if let Some((key, eviction_item)) = state.lru.pop_entry(key.borrow()) {
678
3
                                lru_len -= 1;
679
3
                                info!(?key, "Item expired, evicting");
680
3
                                let (data, futures) =
681
3
                                    state.remove(key.borrow(), &eviction_item, false);
682
                                // Store data for later unref - we can't drop state here as we're still iterating
683
3
                                data_to_unref.push(data);
684
3
                                removal_futures.extend(futures);
685
0
                            }
686
                        } else {
687
35.2k
                            if !peek {
688
35.2k
                                entry.seconds_since_anchor = self.elapsed_seconds();
689
35.2k
                            
}11
690
35.2k
                            *result = Some(entry.data.content_len());
691
                        }
692
                    }
693
2.44k
                    None => *result = None,
694
                }
695
            }
696
21.4k
            (removal_futures, data_to_unref)
697
        };
698
699
        // Perform the async callbacks outside of the lock
700
21.4k
        self.flush_cache_size_metrics();
701
21.4k
        let mut callbacks: FuturesUnordered<_> = removal_futures.into_iter().collect();
702
21.4k
        while callbacks.next().await.is_some() 
{}0
703
21.4k
        let mut callbacks: FuturesUnordered<_> =
704
21.4k
            data_to_unref.iter().map(LenEntry::unref).collect();
705
21.4k
        while callbacks.next().await.is_some() 
{}3
706
21.4k
    }
707
708
    /// Fires the registered remove callbacks for `key` without touching the map.
709
    /// A store uses this to invalidate downstream listeners (e.g. an
710
    /// `ExistenceCacheStore`) when a write is rejected outright and never
711
    /// inserted — mirroring the callbacks an insert-then-immediate-evict would
712
    /// otherwise have fired. It only *notifies*; it does not remove anything from
713
    /// the map (there is nothing to remove). A no-op when no callbacks are
714
    /// registered.
715
3
    pub async fn fire_remove_callbacks(&self, key: &Q) {
716
3
        let mut callbacks: FuturesUnordered<_> = {
717
3
            let state = self.state.lock();
718
3
            state
719
3
                .remove_callbacks
720
3
                .iter()
721
3
                .map(|callback| 
callback2
.
callback2
(
key2
))
722
3
                .collect()
723
        };
724
5
        while callbacks.next().await.is_some() 
{}2
725
3
    }
726
727
    /// Returns the value for `key` if present and not expired, refreshing
728
    /// its LRU/atime position. If the entry is present but TTL- or
729
    /// count-expired, it is reaped and `None` is returned.
730
    ///
731
    /// A read never cascades into other entries — only the queried key is
732
    /// ever touched. Global eviction (size/count overflow trim) runs on
733
    /// inserts; it is not driven by reads, since `sum_store_size` cannot
734
    /// grow without an insert.
735
60.0k
    pub async fn get(&self, key: &Q) -> Option<T> {
736
        // Lazily reap *only* the requested entry if it is itself expired;
737
        // leave the rest for inserts (which already run the global eviction
738
        // loop).
739
57.2k
        let (data, expired_data, removal_futures) = {
740
60.0k
            let mut state = self.state.lock();
741
60.0k
            let lru_len = state.lru.len();
742
60.0k
            let is_leased = state.touch_evictable(key.borrow());
743
60.0k
            let 
entry57.2k
= state.lru.get_mut(key.borrow())
?2.75k
;
744
            // Pass `sum_store_size=0` and `max_bytes=u64::MAX` so we only
745
            // consult TTL / count predicates — never the global byte budget.
746
            // Mirrors the per-key reap path in `sizes_for_keys`.
747
57.2k
            if !is_leased && 
self57.2k
.
should_evict57.2k
(
lru_len57.2k
,
entry57.2k
, 0, u64::MAX) {
748
3
                let (popped_key, eviction_item) = state
749
3
                    .lru
750
3
                    .pop_entry(key.borrow())
751
3
                    .expect("entry was just observed via get_mut");
752
3
                info!(?popped_key, "Item expired, evicting");
753
3
                let (data, futures) = state.remove(popped_key.borrow(), &eviction_item, false);
754
3
                (None, Some(data), futures)
755
            } else {
756
57.2k
                entry.seconds_since_anchor = self.elapsed_seconds();
757
57.2k
                (Some(entry.data.clone()), None, Vec::new())
758
            }
759
        };
760
761
        // Drain remove_callbacks and unref the reaped entry outside the lock.
762
57.2k
        self.flush_cache_size_metrics();
763
57.2k
        let mut callbacks: FuturesUnordered<_> = removal_futures.into_iter().collect();
764
57.2k
        while callbacks.next().await.is_some() 
{}0
765
57.2k
        if let Some(
d3
) = expired_data {
766
3
            d.unref().await;
767
57.2k
        }
768
769
57.2k
        data
770
60.0k
    }
771
772
    /// Returns the replaced item if any.
773
68.1k
    pub async fn insert(&self, key: K, data: T) -> Option<T>
774
68.1k
    where
775
68.1k
        K: 'static,
776
68.1k
    {
777
68.1k
        self.insert_with_time(key, data, self.elapsed_seconds())
778
68.1k
            .await
779
68.1k
    }
780
781
    /// Same as `insert()`, but allows for a conditional to be applied to the
782
    /// entry before insertion in an atomic fashion.
783
2.33k
    pub async fn insert_if<F>(&self, key: K, data: T, cond: F) -> (bool, Option<T>)
784
2.33k
    where
785
2.33k
        F: FnOnce(&T, &T) -> bool + Send,
786
2.33k
    {
787
2.33k
        self.insert_with_time_if(key, data, cond, self.elapsed_seconds())
788
2.33k
            .await
789
2.33k
    }
790
791
    /// Returns the replaced item if any.
792
68.1k
    pub async fn insert_with_time(&self, key: K, data: T, seconds_since_anchor: i32) -> Option<T> {
793
68.1k
        self.insert_with_time_if(key, data, |_, _| true, seconds_since_anchor)
794
68.1k
            .await
795
            .1
796
68.1k
    }
797
798
    /// Conditional insertion with an explicit timestamp. The predicate runs
799
    /// under the map lock; a rejected value is left for the caller to clean up.
800
70.7k
    pub async fn insert_with_time_if<F>(
801
70.7k
        &self,
802
70.7k
        key: K,
803
70.7k
        data: T,
804
70.7k
        cond: F,
805
70.7k
        seconds_since_anchor: i32,
806
70.7k
    ) -> (bool, Option<T>)
807
70.7k
    where
808
70.7k
        F: FnOnce(&T, &T) -> bool + Send,
809
70.7k
    {
810
70.7k
        let (items_to_unref, removal_futures) = {
811
70.7k
            let mut state = self.state.lock();
812
813
70.7k
            state.touch_evictable(key.borrow());
814
70.7k
            if let Some(
old_entry16.2k
) = state.lru.get(key.borrow())
815
16.2k
                && !cond(&old_entry.data, &data)
816
            {
817
2
                return (false, None);
818
70.7k
            }
819
820
70.7k
            self.inner_insert_many(&mut state, [(key, data)], seconds_since_anchor)
821
        };
822
823
70.7k
        self.flush_cache_size_metrics();
824
70.7k
        let mut futures: FuturesUnordered<_> = removal_futures.into_iter().collect();
825
70.7k
        while futures.next().await.is_some() 
{}35
826
827
        // Unref items outside of lock
828
70.7k
        let futures: FuturesUnordered<_> = items_to_unref
829
70.7k
            .into_iter()
830
70.7k
            .map(|item| async move 
{16.3k
831
16.3k
                item.unref().await;
832
16.3k
                item
833
32.6k
            })
834
70.7k
            .collect();
835
70.7k
        (true, futures.collect::<Vec<_>>().await.into_iter().next())
836
70.7k
    }
837
838
    /// Same as `insert()`, but optimized for multiple inserts.
839
    /// Returns the replaced items if any.
840
9
    pub async fn insert_many<It>(&self, inserts: It) -> Vec<T>
841
9
    where
842
9
        It: IntoIterator<Item = (K, T)> + Send,
843
9
        // Note: It's not enough to have the inserts themselves be Send. The
844
9
        // returned iterator should be Send as well.
845
9
        <It as IntoIterator>::IntoIter: Send,
846
9
        K: 'static,
847
9
    {
848
9
        let mut inserts = inserts.into_iter().peekable();
849
        // Shortcut for cases where there are no inserts, so we don't need to lock.
850
9
        if inserts.peek().is_none() {
851
5
            return Vec::new();
852
4
        }
853
854
4
        let (items_to_unref, removal_futures) = {
855
4
            let mut state = self.state.lock();
856
4
            self.inner_insert_many(&mut state, inserts, self.elapsed_seconds())
857
4
        };
858
859
4
        self.flush_cache_size_metrics();
860
4
        let mut futures: FuturesUnordered<_> = removal_futures.into_iter().collect();
861
4
        while futures.next().await.is_some() 
{}0
862
863
        // Unref items outside of lock
864
4
        items_to_unref
865
4
            .into_iter()
866
4
            .map(|item| async move 
{0
867
0
                item.unref().await;
868
0
                item
869
0
            })
870
4
            .collect::<FuturesUnordered<_>>()
871
4
            .collect::<Vec<_>>()
872
4
            .await
873
9
    }
874
875
    /// Lease a key so automatic size, count, and expiry eviction cannot remove it.
876
    ///
877
    /// Leasing before the value is inserted is intentional: an action can
878
    /// reserve a digest before populating the fast tier, so insertion and
879
    /// eviction cannot race with the start of input materialization. Explicit
880
    /// `remove` and `remove_if` calls still remove leased entries; those paths
881
    /// are used for deliberate deletion and filesystem self-healing.
882
48
    pub fn lease_key(&self, key: K) {
883
48
        self.state.lock().lease(key);
884
48
    }
885
886
    /// Release one lease and trim any entries that were retained while the
887
    /// lease was active.
888
5
    pub async fn release_key(&self, key: &Q) {
889
5
        self.release_keys([key]).await;
890
5
    }
891
892
    /// Release a batch of leases and trim retained entries once.
893
    ///
894
    /// Action input leases commonly contain thousands of digests. Batching
895
    /// avoids rescanning the LRU and running deferred cleanup once per digest
896
    /// during action teardown.
897
16
    pub async fn release_keys<It, R>(&self, keys: It)
898
16
    where
899
16
        It: IntoIterator<Item = R> + Send,
900
16
        <It as IntoIterator>::IntoIter: Send,
901
16
        R: Borrow<Q> + Send,
902
16
    {
903
16
        let (items_to_unref, removal_futures) = {
904
16
            let mut state = self.state.lock();
905
16
            let mut released_any = false;
906
46
            for key in 
keys16
{
907
46
                if let Some(
leased_key44
) = state.release_lease(key.borrow()) {
908
44
                    // A released key re-enters as MRU, giving an actively used
909
44
                    // input a fair chance to remain cached after its action.
910
44
                    state.reinsert_evictable(leased_key);
911
44
                    released_any = true;
912
44
                
}2
913
            }
914
16
            if released_any {
915
15
                self.evict_items(&mut state)
916
            } else {
917
1
                (Vec::new(), Vec::new())
918
            }
919
        };
920
921
16
        self.flush_cache_size_metrics();
922
16
        let mut futures: FuturesUnordered<_> = removal_futures.into_iter().collect();
923
17
        while futures.next().await.is_some() 
{}1
924
925
16
        let mut futures: FuturesUnordered<_> = items_to_unref.iter().map(LenEntry::unref).collect();
926
19
        while futures.next().await.is_some() 
{}3
927
16
    }
928
929
70.7k
    fn inner_insert_many<It>(
930
70.7k
        &self,
931
70.7k
        state: &mut State<K, Q, T, C>,
932
70.7k
        inserts: It,
933
70.7k
        seconds_since_anchor: i32,
934
70.7k
    ) -> (Vec<T>, Vec<RemoveFuture>)
935
70.7k
    where
936
70.7k
        It: IntoIterator<Item = (K, T)> + Send,
937
70.7k
        // Note: It's not enough to have the inserts themselves be Send. The
938
70.7k
        // returned iterator should be Send as well.
939
70.7k
        <It as IntoIterator>::IntoIter: Send,
940
    {
941
70.7k
        let mut replaced_items = Vec::new();
942
70.7k
        let mut removal_futures = Vec::new();
943
70.7k
        for (key, data) in inserts {
944
70.7k
            let new_item_size = data.len();
945
70.7k
            let eviction_item = EvictionItem {
946
70.7k
                seconds_since_anchor,
947
70.7k
                data,
948
70.7k
            };
949
950
70.7k
            if let Some((
old_item16.2k
,
futures16.2k
)) = state.put(&key, eviction_item) {
951
16.2k
                removal_futures.extend(futures);
952
16.2k
                debug!(?key, "Evicting old item");
953
16.2k
                replaced_items.push(old_item);
954
54.5k
            }
955
70.7k
            state.sum_store_size += new_item_size;
956
70.7k
            state.lifetime_inserted_bytes.add(new_item_size);
957
70.7k
            state.pending_size_delta += saturating_i64(new_item_size);
958
70.7k
            state.pending_entries_delta += 1;
959
        }
960
961
        // Perform eviction after all insertions
962
70.7k
        let (items_to_unref, futures) = self.evict_items(state);
963
70.7k
        removal_futures.extend(futures);
964
965
        // Note: We cannot drop the state lock here since we're borrowing it,
966
        // but the caller will handle unreffing these items after releasing the lock
967
70.7k
        replaced_items.extend(items_to_unref);
968
969
70.7k
        (replaced_items, removal_futures)
970
70.7k
    }
971
972
71
    pub async fn remove(&self, key: &Q) -> bool {
973
71
        let (items_to_unref, removed_item, removal_futures) = {
974
71
            let mut state = self.state.lock();
975
976
            // First perform eviction
977
71
            let (evicted_items, mut removal_futures) = self.evict_items(&mut *state);
978
979
            // Then try to remove the requested item
980
71
            let removed = if let Some(
entry60
) = state.lru.pop(key.borrow()) {
981
60
                let (removed_item, more_removal_futures) = state.remove(key, &entry, false);
982
60
                removal_futures.extend(more_removal_futures);
983
60
                Some(removed_item)
984
            } else {
985
11
                None
986
            };
987
988
71
            (evicted_items, removed, removal_futures)
989
        };
990
991
71
        self.flush_cache_size_metrics();
992
71
        let mut callbacks: FuturesUnordered<_> = removal_futures.into_iter().collect();
993
78
        while callbacks.next().await.is_some() 
{}7
994
995
        // Unref evicted items outside of lock
996
71
        let mut callbacks: FuturesUnordered<_> =
997
71
            items_to_unref.iter().map(LenEntry::unref).collect();
998
72
        while callbacks.next().await.is_some() 
{}1
999
1000
        // Unref removed item if any
1001
71
        if let Some(
item60
) = removed_item {
1002
60
            debug!(?key, "Evicting (direct remove)");
1003
60
            item.unref().await;
1004
60
            return true;
1005
11
        }
1006
1007
11
        false
1008
71
    }
1009
1010
    /// Same as `remove()`, but allows for a conditional to be applied to the
1011
    /// entry before removal in an atomic fashion.
1012
10
    pub async fn remove_if<F>(&self, key: &Q, cond: F) -> bool
1013
10
    where
1014
10
        F: FnOnce(&T) -> bool + Send,
1015
10
    {
1016
8
        let (evicted_items, removal_futures, removed_item) = {
1017
10
            let mut state = self.state.lock();
1018
10
            state.touch_evictable(key.borrow());
1019
10
            let Some(entry) = state.lru.get(key.borrow()) else {
1020
0
                return false;
1021
            };
1022
10
            if !cond(&entry.data) {
1023
2
                return false;
1024
8
            }
1025
1026
            // First perform eviction
1027
8
            let (evicted_items, mut removal_futures) = self.evict_items(&mut state);
1028
1029
            // Then try to remove the requested item
1030
8
            let removed_item = if let Some(entry) = state.lru.pop(key.borrow()) {
1031
8
                let (item, more_removal_futures) = state.remove(key, &entry, false);
1032
8
                removal_futures.extend(more_removal_futures);
1033
8
                Some(item)
1034
            } else {
1035
0
                None
1036
            };
1037
1038
8
            (evicted_items, removal_futures, removed_item)
1039
        };
1040
1041
        // Perform the async callbacks outside of the lock
1042
8
        self.flush_cache_size_metrics();
1043
8
        let mut removal_futures: FuturesUnordered<_> = removal_futures.into_iter().collect();
1044
16
        while removal_futures.next().await.is_some() 
{}8
1045
1046
        // Unref evicted items
1047
8
        let mut callbacks: FuturesUnordered<_> =
1048
8
            evicted_items.iter().map(LenEntry::unref).collect();
1049
8
        while callbacks.next().await.is_some() 
{}0
1050
1051
        // Unref removed item if any
1052
8
        if let Some(item) = removed_item {
1053
8
            debug!(?key, "Evicting (conditional remove)");
1054
8
            item.unref().await;
1055
8
            true
1056
        } else {
1057
0
            false
1058
        }
1059
10
    }
1060
1061
217
    pub fn add_remove_callback(&self, callback: C) {
1062
217
        self.state.lock().add_remove_callback(callback);
1063
217
    }
1064
}
1065
1066
#[cfg(test)]
1067
mod tests {
1068
    use nativelink_config::stores::EvictionPolicy;
1069
    use nativelink_macro::nativelink_test;
1070
    use pretty_assertions::assert_eq;
1071
1072
    use super::{EvictingMap, EvictionItem, LenEntry};
1073
    use crate::instant_wrapper::MockInstantWrapped;
1074
1075
    #[derive(Clone, Debug, PartialEq, Eq)]
1076
    struct TestEntry(u64);
1077
1078
    impl LenEntry for TestEntry {
1079
14
        fn len(&self) -> u64 {
1080
14
            self.0
1081
14
        }
1082
0
        fn is_empty(&self) -> bool {
1083
0
            self.0 == 0
1084
0
        }
1085
    }
1086
1087
    /// A batch existence check (`has()` → `sizes_for_keys`) over resident
1088
    /// keys, run while the map is pinned one entry over `max_count` by a
1089
    /// leased entry, must evict at most the genuine overage — never every
1090
    /// queried key.
1091
    ///
1092
    /// The bug: `sizes_for_keys` snapshotted `lru.len()` once for the whole
1093
    /// batch and re-used that stale value in `should_evict` for every key.
1094
    /// After the first queried entry was reaped (bringing the map back to
1095
    /// `max_count`), every remaining queried key still saw the stale
1096
    /// over-count and was popped too. A plain `has()` batch would then
1097
    /// destroy all N resident blobs and report them all missing.
1098
    ///
1099
    /// White-box: the pinning entry is planted with a raw leased `State::put`
1100
    /// (no eviction pass), modeling the window in which an action-input lease
1101
    /// holds the map over its count limit while unleased blobs stay resident.
1102
    #[nativelink_test("crate")]
1103
    async fn size_check_batch_evicts_at_most_the_overage() {
1104
        let map = EvictingMap::<String, String, TestEntry, MockInstantWrapped>::new(
1105
            &EvictionPolicy {
1106
                max_bytes: 0,
1107
                evict_bytes: 0,
1108
                max_seconds: 0,
1109
                max_count: 3,
1110
            },
1111
            MockInstantWrapped::default(),
1112
        );
1113
1114
        map.insert("k1".to_string(), TestEntry(10)).await;
1115
        map.insert("k2".to_string(), TestEntry(20)).await;
1116
        map.insert("k3".to_string(), TestEntry(30)).await;
1117
1118
        // Pin the map one over `max_count` with a leased entry, bypassing the
1119
        // insert-time eviction pass exactly as an already-held lease does.
1120
        {
1121
            let mut state = map.state.lock();
1122
            state.lease("pinned".to_string());
1123
            let replaced = state.put(
1124
                &"pinned".to_string(),
1125
                EvictionItem {
1126
                    seconds_since_anchor: 0,
1127
                    data: TestEntry(1),
1128
                },
1129
            );
1130
            assert!(replaced.is_none());
1131
            state.sum_store_size += 1;
1132
        }
1133
        assert_eq!(map.len_for_test(), 4);
1134
1135
        let keys = ["k1".to_string(), "k2".to_string(), "k3".to_string()];
1136
        let mut results = [None, None, None];
1137
        map.sizes_for_keys(keys.iter(), &mut results[..], false)
1138
            .await;
1139
1140
        // The overage is a single entry, so at most the first queried key may
1141
        // be reaped. The rest of the batch must stay resident and report size.
1142
        assert_eq!(
1143
            results[1],
1144
            Some(20),
1145
            "k2 must survive a batch size check: {results:?}"
1146
        );
1147
        assert_eq!(
1148
            results[2],
1149
            Some(30),
1150
            "k3 must survive a batch size check: {results:?}"
1151
        );
1152
        assert_eq!(
1153
            map.len_for_test(),
1154
            3,
1155
            "only the genuine overage may be evicted by a size query"
1156
        );
1157
        assert_eq!(map.size_for_key(&"k2".to_string()).await, Some(20));
1158
        assert_eq!(map.size_for_key(&"k3".to_string()).await, Some(30));
1159
    }
1160
}