Skip to main content

lance_core/cache/
moka.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::pin::Pin;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7
8use async_trait::async_trait;
9use futures::Future;
10
11use crate::Result;
12use crate::error::CloneableError;
13
14use super::backend::{CacheBackend, CacheEntry};
15use super::{CacheCodec, InternalCacheKey};
16
17/// Internal record stored in the moka cache.
18#[derive(Clone, Debug)]
19struct MokaCacheEntry {
20    entry: CacheEntry,
21    size_bytes: usize,
22}
23
24/// Per-entry key cost for eviction.
25pub(super) fn key_footprint(_key: &InternalCacheKey) -> usize {
26    std::mem::size_of::<InternalCacheKey>()
27}
28
29fn physical_size(key: &InternalCacheKey, size_bytes: usize) -> usize {
30    key_footprint(key).saturating_add(size_bytes)
31}
32
33/// Number of physical bytes represented by one Moka weight unit.
34///
35/// Moka limits each entry's weight to `u32`, so capacities above 4 GiB need
36/// coarser units to account for a single large entry without undercharging it.
37fn weight_unit(capacity: usize) -> usize {
38    capacity.div_ceil(u32::MAX as usize).max(1)
39}
40
41fn entry_weight(key: &InternalCacheKey, size_bytes: usize, weight_unit: usize) -> u32 {
42    physical_size(key, size_bytes)
43        .div_ceil(weight_unit)
44        .try_into()
45        .unwrap_or(u32::MAX)
46}
47
48/// Default [`CacheBackend`] backed by a [moka](https://crates.io/crates/moka) cache.
49///
50/// Provides weighted-capacity eviction and concurrent-load deduplication
51/// via moka's built-in `optionally_get_with`.
52pub struct MokaCacheBackend {
53    cache: moka::future::Cache<InternalCacheKey, MokaCacheEntry>,
54    weight_unit: usize,
55}
56
57impl std::fmt::Debug for MokaCacheBackend {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("MokaCacheBackend")
60            .field("entry_count", &self.cache.entry_count())
61            .finish()
62    }
63}
64
65impl MokaCacheBackend {
66    pub fn with_capacity(capacity: usize) -> Self {
67        let weight_unit = weight_unit(capacity);
68        let capacity_weight = capacity.div_ceil(weight_unit) as u64;
69        let cache = moka::future::Cache::builder()
70            .max_capacity(capacity_weight)
71            .weigher(move |key: &InternalCacheKey, entry: &MokaCacheEntry| {
72                entry_weight(key, entry.size_bytes, weight_unit)
73            })
74            .build();
75        Self { cache, weight_unit }
76    }
77
78    pub fn no_cache() -> Self {
79        Self {
80            cache: moka::future::Cache::new(0),
81            weight_unit: 1,
82        }
83    }
84
85    fn weighted_size_bytes(&self) -> usize {
86        self.cache
87            .weighted_size()
88            .saturating_mul(self.weight_unit as u64)
89            .try_into()
90            .unwrap_or(usize::MAX)
91    }
92}
93
94#[async_trait]
95impl CacheBackend for MokaCacheBackend {
96    async fn get(&self, key: &InternalCacheKey, _codec: Option<CacheCodec>) -> Option<CacheEntry> {
97        self.cache.get(key).await.map(|r| r.entry)
98    }
99
100    async fn insert(
101        &self,
102        key: &InternalCacheKey,
103        entry: CacheEntry,
104        size_bytes: usize,
105        _codec: Option<CacheCodec>,
106    ) {
107        self.cache
108            .insert(*key, MokaCacheEntry { entry, size_bytes })
109            .await;
110    }
111
112    async fn get_or_insert<'a>(
113        &self,
114        key: &InternalCacheKey,
115        loader: Pin<Box<dyn Future<Output = Result<(CacheEntry, usize)>> + Send + 'a>>,
116        _codec: Option<CacheCodec>,
117    ) -> Result<(CacheEntry, bool)> {
118        // Track whether the loader actually ran (= cache miss).
119        let was_miss = Arc::new(AtomicBool::new(false));
120        let was_miss_clone = was_miss.clone();
121
122        let init = async move {
123            was_miss_clone.store(true, Ordering::Relaxed);
124            loader
125                .await
126                .map(|(entry, size_bytes)| MokaCacheEntry { entry, size_bytes })
127                .map_err(CloneableError)
128        };
129
130        let owned_key = *key;
131        match self.cache.try_get_with(owned_key, init).await {
132            Ok(record) => {
133                let was_cached = !was_miss.load(Ordering::Relaxed);
134                Ok((record.entry, was_cached))
135            }
136            Err(error) => Err(Arc::unwrap_or_clone(error).0),
137        }
138    }
139
140    async fn clear(&self) {
141        self.cache.invalidate_all();
142        self.cache.run_pending_tasks().await;
143    }
144
145    async fn num_entries(&self) -> usize {
146        self.cache.run_pending_tasks().await;
147        self.cache.entry_count() as usize
148    }
149
150    async fn size_bytes(&self) -> usize {
151        self.cache.run_pending_tasks().await;
152        self.weighted_size_bytes()
153    }
154
155    fn approx_num_entries(&self) -> usize {
156        self.cache.entry_count() as usize
157    }
158
159    fn approx_size_bytes(&self) -> usize {
160        // `weighted_size()` can be stale without `run_pending_tasks()`, which
161        // is async and can't be called from this synchronous context.
162        self.weighted_size_bytes()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn entry_weights_are_exact_at_byte_granularity() {
172        let key = InternalCacheKey::from_bytes([0; 16]);
173        assert_eq!(weight_unit(4096), 1);
174        assert_eq!(entry_weight(&key, 7, 1), 23);
175    }
176
177    #[tokio::test]
178    async fn size_methods_use_constant_time_weighted_accounting() {
179        let backend = MokaCacheBackend::with_capacity(4096);
180        let key = InternalCacheKey::from_bytes([0; 16]);
181        let entry: CacheEntry = Arc::new(());
182        let value_size = 7;
183        let expected = physical_size(&key, value_size);
184
185        backend.insert(&key, entry, value_size, None).await;
186
187        assert_eq!(backend.size_bytes().await, expected);
188        assert_eq!(backend.approx_size_bytes(), expected);
189    }
190
191    #[cfg(target_pointer_width = "64")]
192    #[test]
193    fn entry_weights_scale_for_capacities_above_four_gibibytes() {
194        let key = InternalCacheKey::from_bytes([0; 16]);
195        let capacity = 6 * 1024 * 1024 * 1024;
196        let weight_unit = weight_unit(capacity);
197        assert_eq!(weight_unit, 2);
198
199        let size_bytes = u32::MAX as usize + 1024;
200        let expected = physical_size(&key, size_bytes).div_ceil(weight_unit);
201        let weight = entry_weight(&key, size_bytes, weight_unit);
202        assert_eq!(weight as usize, expected);
203        assert_ne!(weight, u32::MAX);
204    }
205}