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