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//! A hit takes a shard read lock, clones the cached value, and marks it as
6//! accessed. There is no read-operation channel or inline eviction work.
7
8use std::pin::Pin;
9
10use async_trait::async_trait;
11use futures::Future;
12
13use super::backend::{CacheBackend, CacheEntry};
14use super::moka::key_footprint;
15use super::{CacheCodec, InternalCacheKey};
16use crate::Result;
17use crate::deepsize::Context;
18
19#[derive(Clone)]
20struct QuickEntry {
21    entry: CacheEntry,
22    size_bytes: usize,
23}
24
25#[derive(Clone)]
26struct EntryWeighter;
27
28impl quick_cache::Weighter<InternalCacheKey, QuickEntry> for EntryWeighter {
29    fn weight(&self, key: &InternalCacheKey, value: &QuickEntry) -> u64 {
30        // Same accounting as the moka backend.
31        key_footprint(key).saturating_add(value.size_bytes).max(1) as u64
32    }
33}
34
35pub struct QuickCacheBackend {
36    cache: quick_cache::sync::Cache<InternalCacheKey, QuickEntry, EntryWeighter>,
37    capacity: usize,
38}
39
40/// Controls how a [`QuickCacheBackend`] divides its weight budget.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42pub enum QuickCacheShardPolicy {
43    /// Choose a shard count from the cache capacity and available parallelism.
44    #[default]
45    Recommended,
46    /// Give every entry access to one shared weight budget.
47    ///
48    /// This avoids capacity fragmentation for a small number of large,
49    /// unequal entries. Concurrent hits can still take the shard read lock.
50    Single,
51}
52
53impl std::fmt::Debug for QuickCacheBackend {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("QuickCacheBackend")
56            .field("entry_count", &self.cache.len())
57            .finish()
58    }
59}
60
61/// Minimum weight budget (4 GiB) per shard: shards don't borrow capacity, and
62/// an entry heavier than ~its shard's budget is silently refused admission.
63const MIN_SHARD_SHARE: usize = 4 << 30;
64
65/// Recommended shard count: `min(cpus / 2, capacity / 4 GiB)`, power of two
66/// in `[1, 1024]`. The cpu term bounds lock contention; the capacity term
67/// keeps each shard's budget >= 4 GiB so large entries stay admissible.
68/// Rounded down because quick_cache rounds requests up.
69pub fn recommended_cache_shards(capacity: usize) -> usize {
70    let available_parallelism = std::thread::available_parallelism()
71        .map(|n| n.get())
72        .unwrap_or(2);
73    recommended_cache_shards_for_parallelism(capacity, available_parallelism)
74}
75
76fn recommended_cache_shards_for_parallelism(
77    capacity: usize,
78    available_parallelism: usize,
79) -> usize {
80    let by_cpu = available_parallelism / 2;
81    let shards = (capacity / MIN_SHARD_SHARE).min(by_cpu).max(1);
82    let shards = if shards.is_power_of_two() {
83        shards
84    } else {
85        shards.next_power_of_two() / 2
86    };
87    shards.clamp(1, 1024)
88}
89
90/// Assumed average entry size for pre-allocation sizing.
91const ESTIMATED_AVG_ENTRY_BYTES: usize = 64 << 10;
92
93impl QuickCacheBackend {
94    /// Create a backend holding up to `capacity` bytes of weighted entries
95    /// (weight = key footprint + declared size), sharded per
96    /// [`recommended_cache_shards`].
97    pub fn with_capacity(capacity: usize) -> Self {
98        Self::with_shard_policy(capacity, QuickCacheShardPolicy::Recommended)
99    }
100
101    /// Create a weighted cache with an explicit shard policy.
102    ///
103    /// `capacity` bounds the sum of key footprints and declared entry sizes.
104    /// Each shard has an independent share of that bound. In addition, the
105    /// default Quick admission policy rejects an unpinned entry heavier than
106    /// approximately 97% of one shard's share.
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// use lance_core::cache::{QuickCacheBackend, QuickCacheShardPolicy};
112    ///
113    /// let cache = QuickCacheBackend::with_shard_policy(
114    ///     8 << 30,
115    ///     QuickCacheShardPolicy::Single,
116    /// );
117    /// ```
118    pub fn with_shard_policy(capacity: usize, shard_policy: QuickCacheShardPolicy) -> Self {
119        let shards = match shard_policy {
120            QuickCacheShardPolicy::Recommended => recommended_cache_shards(capacity),
121            QuickCacheShardPolicy::Single => 1,
122        };
123        Self::with_shards(capacity, shards)
124    }
125
126    fn with_shards(capacity: usize, shards: usize) -> Self {
127        // Floor protects the shard count from quick_cache's items-per-shard
128        // heuristic; ceiling bounds pre-allocation.
129        let estimated_items = (capacity / ESTIMATED_AVG_ENTRY_BYTES).clamp(shards * 32, 1_000_000);
130        let options = quick_cache::OptionsBuilder::new()
131            .estimated_items_capacity(estimated_items)
132            .weight_capacity(capacity as u64)
133            .shards(shards)
134            .build()
135            // Only errors when weight/item capacity is missing; both are set.
136            .expect("quick_cache options");
137        let cache = quick_cache::sync::Cache::with_options(
138            options,
139            EntryWeighter,
140            Default::default(),
141            Default::default(),
142        );
143        Self { cache, capacity }
144    }
145
146    #[cfg(test)]
147    fn num_shards(&self) -> usize {
148        self.cache.num_shards()
149    }
150
151    #[cfg(test)]
152    fn shard_index(&self, key: &InternalCacheKey) -> usize {
153        self.cache.shard_index(key)
154    }
155}
156
157#[async_trait]
158impl CacheBackend for QuickCacheBackend {
159    async fn get(&self, key: &InternalCacheKey, _codec: Option<CacheCodec>) -> Option<CacheEntry> {
160        self.cache.get(key).map(|v| v.entry)
161    }
162
163    async fn insert(
164        &self,
165        key: &InternalCacheKey,
166        entry: CacheEntry,
167        size_bytes: usize,
168        _codec: Option<CacheCodec>,
169    ) {
170        self.cache.insert(*key, QuickEntry { entry, size_bytes });
171    }
172
173    async fn get_or_insert<'a>(
174        &self,
175        key: &InternalCacheKey,
176        loader: Pin<Box<dyn Future<Output = Result<(CacheEntry, usize)>> + Send + 'a>>,
177        _codec: Option<CacheCodec>,
178    ) -> Result<(CacheEntry, bool)> {
179        match self.cache.get_value_or_guard_async(key).await {
180            Ok(value) => Ok((value.entry, true)),
181            Err(guard) => {
182                let (entry, size_bytes) = loader.await?;
183                let _ = guard.insert(QuickEntry {
184                    entry: entry.clone(),
185                    size_bytes,
186                });
187                Ok((entry, false))
188            }
189        }
190    }
191
192    async fn clear(&self) {
193        self.cache.clear();
194    }
195
196    async fn num_entries(&self) -> usize {
197        self.cache.len()
198    }
199
200    async fn size_bytes(&self) -> usize {
201        self.cache.weight() as usize
202    }
203
204    fn capacity_bytes(&self) -> Option<usize> {
205        Some(self.capacity)
206    }
207
208    fn approx_num_entries(&self) -> usize {
209        self.cache.len()
210    }
211
212    fn approx_size_bytes(&self) -> usize {
213        self.cache.weight() as usize
214    }
215
216    fn deep_size_of_entries(
217        &self,
218        context: &mut Context,
219        size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option<usize>,
220    ) -> Option<usize> {
221        Some(
222            self.cache
223                .iter()
224                .map(|(key, record)| {
225                    key_footprint(&key)
226                        + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes)
227                })
228                .sum(),
229        )
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::marker::PhantomData;
236    use std::sync::Arc;
237    use std::sync::atomic::{AtomicUsize, Ordering};
238
239    use super::*;
240    use crate::cache::{CacheKey, LanceCache};
241
242    const TEST_CAPACITY: usize = 1_000;
243
244    struct TestKey<T: 'static> {
245        key: String,
246        _phantom: PhantomData<T>,
247    }
248
249    impl<T: 'static> TestKey<T> {
250        fn new(key: &str) -> Self {
251            Self {
252                key: key.to_string(),
253                _phantom: PhantomData,
254            }
255        }
256    }
257
258    impl<T: 'static> CacheKey for TestKey<T> {
259        type ValueType = T;
260        fn key(&self) -> std::borrow::Cow<'_, str> {
261            std::borrow::Cow::Borrowed(&self.key)
262        }
263        fn type_name() -> &'static str {
264            std::any::type_name::<T>()
265        }
266    }
267
268    #[test]
269    fn entry_weight_includes_fixed_key() {
270        let key = InternalCacheKey::from_bytes([0; 16]);
271        let entry = QuickEntry {
272            entry: Arc::new(()),
273            size_bytes: 7,
274        };
275        assert_eq!(
276            quick_cache::Weighter::weight(&EntryWeighter, &key, &entry),
277            23
278        );
279    }
280
281    #[test]
282    fn capacity_bytes_reports_configured_capacity() {
283        assert_eq!(
284            QuickCacheBackend::with_capacity(1 << 20).capacity_bytes(),
285            Some(1 << 20)
286        );
287    }
288
289    fn keys_in_shard(
290        cache: &QuickCacheBackend,
291        shard_index: usize,
292        count: usize,
293    ) -> Vec<InternalCacheKey> {
294        let mut keys = Vec::with_capacity(count);
295        for value in 0_u128.. {
296            let key = InternalCacheKey::from_bytes(value.to_le_bytes());
297            if cache.shard_index(&key) == shard_index {
298                keys.push(key);
299                if keys.len() == count {
300                    return keys;
301                }
302            }
303        }
304        unreachable!("the key space must contain enough keys for every shard")
305    }
306
307    async fn load_declared_value(
308        cache: &QuickCacheBackend,
309        key: &InternalCacheKey,
310        value: usize,
311        size_bytes: usize,
312        loads: &AtomicUsize,
313    ) -> CacheEntry {
314        let (entry, _) = cache
315            .get_or_insert(
316                key,
317                Box::pin(async {
318                    loads.fetch_add(1, Ordering::SeqCst);
319                    Ok((Arc::new(value) as CacheEntry, size_bytes))
320                }),
321                None,
322            )
323            .await
324            .unwrap();
325        assert_eq!(*entry.downcast_ref::<usize>().unwrap(), value);
326        entry
327    }
328
329    #[test]
330    fn recommended_shards_cover_large_capacity_boundaries() {
331        assert_eq!(recommended_cache_shards_for_parallelism(8 << 30, 4), 2);
332        assert_eq!(
333            recommended_cache_shards_for_parallelism((8 << 30) - 1, 4),
334            1
335        );
336        assert_eq!(recommended_cache_shards_for_parallelism(16 << 30, 8), 4);
337        assert_eq!(
338            recommended_cache_shards_for_parallelism((16 << 30) - 1, 8),
339            2
340        );
341    }
342
343    #[tokio::test]
344    async fn single_shard_avoids_fragmentation_for_unequal_entries() {
345        // Declared entry weights, including the 16-byte key, total 660. They
346        // fit the whole cache but not one 500-byte share of a two-shard cache.
347        let sizes = [168, 188, 256];
348        let sharded = QuickCacheBackend::with_shards(TEST_CAPACITY, 2);
349        assert_eq!(sharded.num_shards(), 2);
350        let sharded_keys = keys_in_shard(&sharded, 0, sizes.len());
351        let sharded_loads = AtomicUsize::new(0);
352        for _ in 0..3 {
353            for (value, (key, size_bytes)) in sharded_keys.iter().zip(sizes).enumerate() {
354                load_declared_value(&sharded, key, value, size_bytes, &sharded_loads).await;
355            }
356        }
357        assert!(sharded_loads.load(Ordering::SeqCst) > sizes.len());
358        assert!(sharded.num_entries().await < sizes.len());
359        assert!(sharded.size_bytes().await <= TEST_CAPACITY);
360
361        let single =
362            QuickCacheBackend::with_shard_policy(TEST_CAPACITY, QuickCacheShardPolicy::Single);
363        let single_loads = AtomicUsize::new(0);
364        for _ in 0..3 {
365            for (value, (key, size_bytes)) in sharded_keys.iter().zip(sizes).enumerate() {
366                load_declared_value(&single, key, value, size_bytes, &single_loads).await;
367            }
368        }
369        assert_eq!(single_loads.load(Ordering::SeqCst), sizes.len());
370        assert_eq!(single.num_entries().await, sizes.len());
371        assert_eq!(single.size_bytes().await, 660);
372    }
373
374    #[tokio::test]
375    async fn direct_insert_obeys_the_same_shard_budget() {
376        let sizes = [168, 188, 256];
377        for (shards, expected_entries) in [(2, 2), (1, 3)] {
378            let cache = QuickCacheBackend::with_shards(TEST_CAPACITY, shards);
379            let keys = keys_in_shard(&cache, 0, sizes.len());
380            for (value, (key, size_bytes)) in keys.iter().zip(sizes).enumerate() {
381                cache.insert(key, Arc::new(value), size_bytes, None).await;
382            }
383            assert_eq!(cache.num_entries().await, expected_entries);
384            assert!(cache.size_bytes().await <= TEST_CAPACITY);
385        }
386    }
387
388    #[tokio::test]
389    async fn single_shard_preserves_capacity_and_hot_admission_limits() {
390        let cache = QuickCacheBackend::with_shards(TEST_CAPACITY, 1);
391        let keys = keys_in_shard(&cache, 0, 4);
392
393        // The default hot target is 97% of capacity. Weight includes the key,
394        // so a declared size of 954 is admitted at weight 970, while 955 is not.
395        cache.insert(&keys[0], Arc::new(0_usize), 954, None).await;
396        assert!(cache.get(&keys[0], None).await.is_some());
397        cache.clear().await;
398        cache.insert(&keys[1], Arc::new(1_usize), 955, None).await;
399        assert!(cache.get(&keys[1], None).await.is_none());
400
401        // Individually admissible entries whose working set exceeds the total
402        // budget must still reload and remain bounded.
403        let loads = AtomicUsize::new(0);
404        for _ in 0..2 {
405            for (value, key) in keys[1..].iter().enumerate() {
406                load_declared_value(&cache, key, value, 384, &loads).await;
407            }
408        }
409        assert!(loads.load(Ordering::SeqCst) > 3);
410        assert!(cache.size_bytes().await <= TEST_CAPACITY);
411    }
412
413    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
414    async fn concurrent_misses_share_a_load_and_retry_failure() {
415        let cache = Arc::new(QuickCacheBackend::with_shards(4096, 1));
416        let key = InternalCacheKey::from_bytes([42; 16]);
417        let loads = Arc::new(AtomicUsize::new(0));
418        let release = Arc::new(tokio::sync::Notify::new());
419        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
420
421        let owner = {
422            let cache = cache.clone();
423            let loads = loads.clone();
424            let release = release.clone();
425            tokio::spawn(async move {
426                cache
427                    .get_or_insert(
428                        &key,
429                        Box::pin(async move {
430                            loads.fetch_add(1, Ordering::SeqCst);
431                            let _ = started_tx.send(());
432                            release.notified().await;
433                            Err(crate::Error::timeout("test loader failed"))
434                        }),
435                        None,
436                    )
437                    .await
438            })
439        };
440        started_rx.await.unwrap();
441
442        let contender = {
443            let cache = cache.clone();
444            let loads = loads.clone();
445            tokio::spawn(async move {
446                cache
447                    .get_or_insert(
448                        &key,
449                        Box::pin(async move {
450                            loads.fetch_add(1, Ordering::SeqCst);
451                            Ok((Arc::new(7_usize) as CacheEntry, 8))
452                        }),
453                        None,
454                    )
455                    .await
456            })
457        };
458        tokio::task::yield_now().await;
459        assert_eq!(loads.load(Ordering::SeqCst), 1);
460        release.notify_one();
461        assert!(owner.await.unwrap().is_err());
462        let (value, is_hit) = contender.await.unwrap().unwrap();
463        assert_eq!(*value.downcast_ref::<usize>().unwrap(), 7);
464        assert!(!is_hit);
465        assert_eq!(loads.load(Ordering::SeqCst), 2);
466    }
467
468    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
469    async fn cancelled_loader_releases_concurrent_miss() {
470        let cache = Arc::new(QuickCacheBackend::with_shards(4096, 1));
471        let key = InternalCacheKey::from_bytes([24; 16]);
472        let loads = Arc::new(AtomicUsize::new(0));
473        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
474
475        let owner = {
476            let cache = cache.clone();
477            let loads = loads.clone();
478            tokio::spawn(async move {
479                cache
480                    .get_or_insert(
481                        &key,
482                        Box::pin(async move {
483                            loads.fetch_add(1, Ordering::SeqCst);
484                            let _ = started_tx.send(());
485                            std::future::pending::<()>().await;
486                            Ok((Arc::new(1_usize) as CacheEntry, 8))
487                        }),
488                        None,
489                    )
490                    .await
491            })
492        };
493        started_rx.await.unwrap();
494
495        let contender = {
496            let cache = cache.clone();
497            let loads = loads.clone();
498            tokio::spawn(async move {
499                cache
500                    .get_or_insert(
501                        &key,
502                        Box::pin(async move {
503                            loads.fetch_add(1, Ordering::SeqCst);
504                            Ok((Arc::new(2_usize) as CacheEntry, 8))
505                        }),
506                        None,
507                    )
508                    .await
509            })
510        };
511        tokio::task::yield_now().await;
512        assert_eq!(loads.load(Ordering::SeqCst), 1);
513
514        owner.abort();
515        assert!(owner.await.unwrap_err().is_cancelled());
516        let (value, is_hit) = tokio::time::timeout(std::time::Duration::from_secs(1), contender)
517            .await
518            .expect("contender remained blocked after loader cancellation")
519            .unwrap()
520            .unwrap();
521        assert_eq!(*value.downcast_ref::<usize>().unwrap(), 2);
522        assert!(!is_hit);
523        assert_eq!(loads.load(Ordering::SeqCst), 2);
524    }
525
526    #[tokio::test]
527    async fn test_quick_backend_roundtrip_singleflight_and_eviction() {
528        // Capacity must be large relative to one entry: quick_cache shards
529        // its weight budget, and an entry heavier than its shard's share is
530        // not admitted at all.
531        const CAPACITY: usize = 1 << 20;
532        let item = Arc::new(vec![1u8, 2, 3]);
533        let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY)));
534
535        // insert + get roundtrip and weighted accounting
536        cache
537            .insert_with_key(&TestKey::<Vec<u8>>::new("a"), item.clone())
538            .await;
539        assert_eq!(
540            cache
541                .get_with_key(&TestKey::<Vec<u8>>::new("a"))
542                .await
543                .as_deref(),
544            Some(&vec![1u8, 2, 3])
545        );
546        assert_eq!(cache.approx_size(), 1);
547        assert!(cache.size_bytes().await > 0);
548
549        // get_or_insert runs the loader only on a miss
550        let loads = Arc::new(AtomicUsize::new(0));
551        for _ in 0..2 {
552            let loads = loads.clone();
553            let value = cache
554                .get_or_insert_with_key(TestKey::<Vec<u8>>::new("b"), || async move {
555                    loads.fetch_add(1, Ordering::SeqCst);
556                    Ok(vec![7u8])
557                })
558                .await
559                .unwrap();
560            assert_eq!(value.as_ref(), &vec![7u8]);
561        }
562        assert_eq!(loads.load(Ordering::SeqCst), 1);
563
564        // capacity is enforced: overfill with 4x capacity of 16KiB entries
565        // and confirm eviction kept the weighted size within budget
566        for i in 0..256 {
567            cache
568                .insert_with_key(
569                    &TestKey::<Vec<u8>>::new(&format!("fill-{i}")),
570                    Arc::new(vec![0u8; 16 << 10]),
571                )
572                .await;
573        }
574        assert!(cache.size_bytes().await <= CAPACITY);
575        assert!(cache.size().await < 258);
576
577        cache.clear().await;
578        assert_eq!(cache.size().await, 0);
579    }
580
581    #[tokio::test]
582    async fn test_quick_backend_tiny_capacity() {
583        // A tiny cache must not over-provision item metadata and must still
584        // admit and evict correctly within its weight budget.
585        const CAPACITY: usize = 64 << 10;
586        let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY)));
587        for i in 0..64 {
588            cache
589                .insert_with_key(
590                    &TestKey::<Vec<u8>>::new(&format!("k-{i}")),
591                    Arc::new(vec![0u8; 4 << 10]),
592                )
593                .await;
594        }
595        assert!(cache.size_bytes().await <= CAPACITY);
596        assert!(cache.size().await >= 1);
597        let hit = cache
598            .get_with_key(&TestKey::<Vec<u8>>::new("k-63"))
599            .await
600            .is_some()
601            || cache
602                .get_with_key(&TestKey::<Vec<u8>>::new("k-62"))
603                .await
604                .is_some();
605        assert!(hit, "recently inserted entries should be resident");
606    }
607}