Skip to main content

lance_core/cache/
quick.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! [`CacheBackend`] backed by [quick_cache](https://crates.io/crates/quick_cache),
5//! whose hit path is one atomic bit — no read-op channel or inline
6//! housekeeping. Used for the session index and metadata caches; the index
7//! cache sees thousands of cache reads per query.
8
9use std::pin::Pin;
10
11use async_trait::async_trait;
12use futures::Future;
13
14use super::backend::{CacheBackend, CacheEntry};
15use super::moka::key_footprint;
16use super::{CacheCodec, InternalCacheKey};
17use crate::Result;
18use crate::deepsize::Context;
19
20#[derive(Clone)]
21struct QuickEntry {
22    entry: CacheEntry,
23    size_bytes: usize,
24}
25
26#[derive(Clone)]
27struct EntryWeighter;
28
29impl quick_cache::Weighter<InternalCacheKey, QuickEntry> for EntryWeighter {
30    fn weight(&self, key: &InternalCacheKey, value: &QuickEntry) -> u64 {
31        // Same accounting as the moka backend.
32        key_footprint(key).saturating_add(value.size_bytes).max(1) as u64
33    }
34}
35
36pub struct QuickCacheBackend {
37    cache: quick_cache::sync::Cache<InternalCacheKey, QuickEntry, EntryWeighter>,
38}
39
40impl std::fmt::Debug for QuickCacheBackend {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("QuickCacheBackend")
43            .field("entry_count", &self.cache.len())
44            .finish()
45    }
46}
47
48/// Minimum weight budget (4 GiB) per shard: shards don't borrow capacity, and
49/// an entry heavier than ~its shard's budget is silently refused admission.
50const MIN_SHARD_SHARE: usize = 4 << 30;
51
52/// Recommended shard count: `min(cpus / 2, capacity / 4 GiB)`, power of two
53/// in `[1, 1024]`. The cpu term bounds lock contention; the capacity term
54/// keeps each shard's budget >= 4 GiB so large entries stay admissible.
55/// Rounded down because quick_cache rounds requests up.
56pub fn recommended_cache_shards(capacity: usize) -> usize {
57    let by_cpu = std::thread::available_parallelism()
58        .map(|n| n.get())
59        .unwrap_or(2)
60        / 2;
61    let shards = (capacity / MIN_SHARD_SHARE).min(by_cpu).max(1);
62    let shards = if shards.is_power_of_two() {
63        shards
64    } else {
65        shards.next_power_of_two() / 2
66    };
67    shards.clamp(1, 1024)
68}
69
70/// Assumed average entry size for pre-allocation sizing.
71const ESTIMATED_AVG_ENTRY_BYTES: usize = 64 << 10;
72
73impl QuickCacheBackend {
74    /// Create a backend holding up to `capacity` bytes of weighted entries
75    /// (weight = key footprint + declared size), sharded per
76    /// [`recommended_cache_shards`].
77    pub fn with_capacity(capacity: usize) -> Self {
78        let shards = recommended_cache_shards(capacity);
79        // Floor protects the shard count from quick_cache's items-per-shard
80        // heuristic; ceiling bounds pre-allocation.
81        let estimated_items = (capacity / ESTIMATED_AVG_ENTRY_BYTES).clamp(shards * 32, 1_000_000);
82        let options = quick_cache::OptionsBuilder::new()
83            .estimated_items_capacity(estimated_items)
84            .weight_capacity(capacity as u64)
85            .shards(shards)
86            .build()
87            // Only errors when weight/item capacity is missing; both are set.
88            .expect("quick_cache options");
89        let cache = quick_cache::sync::Cache::with_options(
90            options,
91            EntryWeighter,
92            Default::default(),
93            Default::default(),
94        );
95        Self { cache }
96    }
97}
98
99#[async_trait]
100impl CacheBackend for QuickCacheBackend {
101    async fn get(&self, key: &InternalCacheKey, _codec: Option<CacheCodec>) -> Option<CacheEntry> {
102        self.cache.get(key).map(|v| v.entry)
103    }
104
105    async fn insert(
106        &self,
107        key: &InternalCacheKey,
108        entry: CacheEntry,
109        size_bytes: usize,
110        _codec: Option<CacheCodec>,
111    ) {
112        self.cache.insert(*key, QuickEntry { entry, size_bytes });
113    }
114
115    async fn get_or_insert<'a>(
116        &self,
117        key: &InternalCacheKey,
118        loader: Pin<Box<dyn Future<Output = Result<(CacheEntry, usize)>> + Send + 'a>>,
119        _codec: Option<CacheCodec>,
120    ) -> Result<(CacheEntry, bool)> {
121        match self.cache.get_value_or_guard_async(key).await {
122            Ok(value) => Ok((value.entry, true)),
123            Err(guard) => {
124                let (entry, size_bytes) = loader.await?;
125                let _ = guard.insert(QuickEntry {
126                    entry: entry.clone(),
127                    size_bytes,
128                });
129                Ok((entry, false))
130            }
131        }
132    }
133
134    async fn clear(&self) {
135        self.cache.clear();
136    }
137
138    async fn num_entries(&self) -> usize {
139        self.cache.len()
140    }
141
142    async fn size_bytes(&self) -> usize {
143        self.cache.weight() as usize
144    }
145
146    fn approx_num_entries(&self) -> usize {
147        self.cache.len()
148    }
149
150    fn approx_size_bytes(&self) -> usize {
151        self.cache.weight() as usize
152    }
153
154    fn deep_size_of_entries(
155        &self,
156        context: &mut Context,
157        size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option<usize>,
158    ) -> Option<usize> {
159        Some(
160            self.cache
161                .iter()
162                .map(|(key, record)| {
163                    key_footprint(&key)
164                        + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes)
165                })
166                .sum(),
167        )
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use std::marker::PhantomData;
174    use std::sync::Arc;
175    use std::sync::atomic::{AtomicUsize, Ordering};
176
177    use super::*;
178    use crate::cache::{CacheKey, LanceCache};
179
180    struct TestKey<T: 'static> {
181        key: String,
182        _phantom: PhantomData<T>,
183    }
184
185    impl<T: 'static> TestKey<T> {
186        fn new(key: &str) -> Self {
187            Self {
188                key: key.to_string(),
189                _phantom: PhantomData,
190            }
191        }
192    }
193
194    impl<T: 'static> CacheKey for TestKey<T> {
195        type ValueType = T;
196        fn key(&self) -> std::borrow::Cow<'_, str> {
197            std::borrow::Cow::Borrowed(&self.key)
198        }
199        fn type_name() -> &'static str {
200            std::any::type_name::<T>()
201        }
202    }
203
204    #[test]
205    fn entry_weight_includes_fixed_key() {
206        let key = InternalCacheKey::from_bytes([0; 16]);
207        let entry = QuickEntry {
208            entry: Arc::new(()),
209            size_bytes: 7,
210        };
211        assert_eq!(
212            quick_cache::Weighter::weight(&EntryWeighter, &key, &entry),
213            23
214        );
215    }
216
217    #[tokio::test]
218    async fn test_quick_backend_roundtrip_singleflight_and_eviction() {
219        // Capacity must be large relative to one entry: quick_cache shards
220        // its weight budget, and an entry heavier than its shard's share is
221        // not admitted at all.
222        const CAPACITY: usize = 1 << 20;
223        let item = Arc::new(vec![1u8, 2, 3]);
224        let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY)));
225
226        // insert + get roundtrip and weighted accounting
227        cache
228            .insert_with_key(&TestKey::<Vec<u8>>::new("a"), item.clone())
229            .await;
230        assert_eq!(
231            cache
232                .get_with_key(&TestKey::<Vec<u8>>::new("a"))
233                .await
234                .as_deref(),
235            Some(&vec![1u8, 2, 3])
236        );
237        assert_eq!(cache.approx_size(), 1);
238        assert!(cache.size_bytes().await > 0);
239
240        // get_or_insert runs the loader only on a miss
241        let loads = Arc::new(AtomicUsize::new(0));
242        for _ in 0..2 {
243            let loads = loads.clone();
244            let value = cache
245                .get_or_insert_with_key(TestKey::<Vec<u8>>::new("b"), || async move {
246                    loads.fetch_add(1, Ordering::SeqCst);
247                    Ok(vec![7u8])
248                })
249                .await
250                .unwrap();
251            assert_eq!(value.as_ref(), &vec![7u8]);
252        }
253        assert_eq!(loads.load(Ordering::SeqCst), 1);
254
255        // capacity is enforced: overfill with 4x capacity of 16KiB entries
256        // and confirm eviction kept the weighted size within budget
257        for i in 0..256 {
258            cache
259                .insert_with_key(
260                    &TestKey::<Vec<u8>>::new(&format!("fill-{i}")),
261                    Arc::new(vec![0u8; 16 << 10]),
262                )
263                .await;
264        }
265        assert!(cache.size_bytes().await <= CAPACITY);
266        assert!(cache.size().await < 258);
267
268        cache.clear().await;
269        assert_eq!(cache.size().await, 0);
270    }
271
272    #[tokio::test]
273    async fn test_quick_backend_tiny_capacity() {
274        // A tiny cache must not over-provision item metadata and must still
275        // admit and evict correctly within its weight budget.
276        const CAPACITY: usize = 64 << 10;
277        let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY)));
278        for i in 0..64 {
279            cache
280                .insert_with_key(
281                    &TestKey::<Vec<u8>>::new(&format!("k-{i}")),
282                    Arc::new(vec![0u8; 4 << 10]),
283                )
284                .await;
285        }
286        assert!(cache.size_bytes().await <= CAPACITY);
287        assert!(cache.size().await >= 1);
288        let hit = cache
289            .get_with_key(&TestKey::<Vec<u8>>::new("k-63"))
290            .await
291            .is_some()
292            || cache
293                .get_with_key(&TestKey::<Vec<u8>>::new("k-62"))
294                .await
295                .is_some();
296        assert!(hit, "recently inserted entries should be resident");
297    }
298}