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::deepsize::Context;
13use crate::error::CloneableError;
14
15use super::backend::{CacheBackend, CacheEntry};
16use super::{CacheCodec, InternalCacheKey};
17
18/// Internal record stored in the moka cache.
19#[derive(Clone, Debug)]
20struct MokaCacheEntry {
21    entry: CacheEntry,
22    size_bytes: usize,
23}
24
25/// Per-entry key cost for eviction.
26pub(super) fn key_footprint(_key: &InternalCacheKey) -> usize {
27    std::mem::size_of::<InternalCacheKey>()
28}
29
30fn physical_size(key: &InternalCacheKey, size_bytes: usize) -> usize {
31    key_footprint(key).saturating_add(size_bytes)
32}
33
34/// Number of physical bytes represented by one Moka weight unit.
35///
36/// Moka limits each entry's weight to `u32`, so capacities above 4 GiB need
37/// coarser units to account for a single large entry without undercharging it.
38fn weight_unit(capacity: usize) -> usize {
39    capacity.div_ceil(u32::MAX as usize).max(1)
40}
41
42fn entry_weight(key: &InternalCacheKey, size_bytes: usize, weight_unit: usize) -> u32 {
43    physical_size(key, size_bytes)
44        .div_ceil(weight_unit)
45        .try_into()
46        .unwrap_or(u32::MAX)
47}
48
49/// Default [`CacheBackend`] backed by a [moka](https://crates.io/crates/moka) cache.
50///
51/// Nonzero capacities provide weighted eviction and concurrent-load deduplication.
52/// A zero capacity invokes each loader independently without caching.
53pub struct MokaCacheBackend {
54    cache: moka::future::Cache<InternalCacheKey, MokaCacheEntry>,
55    capacity: usize,
56    weight_unit: usize,
57}
58
59impl std::fmt::Debug for MokaCacheBackend {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("MokaCacheBackend")
62            .field("entry_count", &self.cache.entry_count())
63            .finish()
64    }
65}
66
67impl MokaCacheBackend {
68    pub fn with_capacity(capacity: usize) -> Self {
69        let weight_unit = weight_unit(capacity);
70        let capacity_weight = capacity.div_ceil(weight_unit) as u64;
71        let cache = moka::future::Cache::builder()
72            .max_capacity(capacity_weight)
73            .weigher(move |key: &InternalCacheKey, entry: &MokaCacheEntry| {
74                entry_weight(key, entry.size_bytes, weight_unit)
75            })
76            .build();
77        Self {
78            cache,
79            capacity,
80            weight_unit,
81        }
82    }
83
84    pub fn no_cache() -> Self {
85        Self {
86            cache: moka::future::Cache::new(0),
87            capacity: 0,
88            weight_unit: 1,
89        }
90    }
91
92    /// Configured weighted capacity in bytes.
93    pub fn capacity(&self) -> usize {
94        self.capacity
95    }
96
97    fn weighted_size_bytes(&self) -> usize {
98        self.cache
99            .weighted_size()
100            .saturating_mul(self.weight_unit as u64)
101            .try_into()
102            .unwrap_or(usize::MAX)
103    }
104}
105
106#[async_trait]
107impl CacheBackend for MokaCacheBackend {
108    async fn get_resident(&self, key: &InternalCacheKey) -> Option<CacheEntry> {
109        self.cache.get(key).await.map(|r| r.entry)
110    }
111
112    /// Moka does not count `contains_key` as a read: the entry's popularity
113    /// estimate and recency order stay unchanged.
114    async fn peek_resident(&self, key: &InternalCacheKey) -> bool {
115        self.cache.contains_key(key)
116    }
117
118    async fn get(&self, key: &InternalCacheKey, _codec: Option<CacheCodec>) -> Option<CacheEntry> {
119        self.cache.get(key).await.map(|r| r.entry)
120    }
121
122    async fn insert(
123        &self,
124        key: &InternalCacheKey,
125        entry: CacheEntry,
126        size_bytes: usize,
127        _codec: Option<CacheCodec>,
128    ) {
129        self.cache
130            .insert(*key, MokaCacheEntry { entry, size_bytes })
131            .await;
132    }
133
134    async fn get_or_insert<'a>(
135        &self,
136        key: &InternalCacheKey,
137        loader: Pin<Box<dyn Future<Output = Result<(CacheEntry, usize)>> + Send + 'a>>,
138        _codec: Option<CacheCodec>,
139    ) -> Result<(CacheEntry, bool)> {
140        // Avoid Moka's single-flight waiters when nothing can be cached.
141        if self.capacity == 0 {
142            return loader.await.map(|(entry, _)| (entry, false));
143        }
144
145        // Track whether the loader actually ran (= cache miss).
146        let was_miss = Arc::new(AtomicBool::new(false));
147        let was_miss_clone = was_miss.clone();
148
149        let init = async move {
150            was_miss_clone.store(true, Ordering::Relaxed);
151            loader
152                .await
153                .map(|(entry, size_bytes)| MokaCacheEntry { entry, size_bytes })
154                .map_err(CloneableError::new)
155        };
156
157        let owned_key = *key;
158        match self.cache.try_get_with(owned_key, init).await {
159            Ok(record) => {
160                let was_cached = !was_miss.load(Ordering::Relaxed);
161                Ok((record.entry, was_cached))
162            }
163            Err(error) => Err(Arc::unwrap_or_clone(error).into_inner()),
164        }
165    }
166
167    async fn clear(&self) {
168        self.cache.invalidate_all();
169        self.cache.run_pending_tasks().await;
170    }
171
172    async fn num_entries(&self) -> usize {
173        self.cache.run_pending_tasks().await;
174        self.cache.entry_count() as usize
175    }
176
177    async fn size_bytes(&self) -> usize {
178        self.cache.run_pending_tasks().await;
179        self.weighted_size_bytes()
180    }
181
182    fn capacity_bytes(&self) -> Option<usize> {
183        Some(self.capacity)
184    }
185
186    fn approx_num_entries(&self) -> usize {
187        self.cache.entry_count() as usize
188    }
189
190    fn approx_size_bytes(&self) -> usize {
191        // `weighted_size()` can be stale without `run_pending_tasks()`, which
192        // is async and can't be called from this synchronous context.
193        self.weighted_size_bytes()
194    }
195
196    fn deep_size_of_entries(
197        &self,
198        context: &mut Context,
199        size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option<usize>,
200    ) -> Option<usize> {
201        Some(
202            self.cache
203                .iter()
204                .map(|(key, record)| {
205                    key_footprint(key.as_ref())
206                        + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes)
207                })
208                .sum(),
209        )
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use std::pin::pin;
216
217    use futures::{FutureExt, poll};
218    use tokio::sync::oneshot;
219
220    use super::*;
221
222    #[rstest::rstest]
223    #[case::zero_capacity_success(MokaCacheBackend::with_capacity(0), false)]
224    #[case::zero_capacity_error(MokaCacheBackend::with_capacity(0), true)]
225    #[case::no_cache_success(MokaCacheBackend::no_cache(), false)]
226    #[case::no_cache_error(MokaCacheBackend::no_cache(), true)]
227    #[tokio::test]
228    async fn zero_capacity_loaders_run_independently(
229        #[case] backend: MokaCacheBackend,
230        #[case] loader_fails: bool,
231    ) {
232        let key = InternalCacheKey::from_bytes([0; 16]);
233        let first_entry: CacheEntry = Arc::new(1_u64);
234        let second_entry: CacheEntry = Arc::new(2_u64);
235        let (release_tx, release_rx) = oneshot::channel();
236        let entry = first_entry.clone();
237        let mut first = pin!(backend.get_or_insert(
238            &key,
239            Box::pin(async move {
240                release_rx.await.unwrap();
241                Ok((entry, 8))
242            }),
243            None,
244        ));
245        assert!(poll!(first.as_mut()).is_pending());
246
247        let entry = second_entry.clone();
248        let second = backend
249            .get_or_insert(
250                &key,
251                Box::pin(async move {
252                    if loader_fails {
253                        Err(crate::Error::invalid_input("independent loader failed"))
254                    } else {
255                        Ok((entry, 8))
256                    }
257                }),
258                None,
259            )
260            .now_or_never()
261            .expect("a disabled cache must not wait for another loader on the same key");
262        if loader_fails {
263            let error = second.unwrap_err();
264            assert!(matches!(&error, crate::Error::InvalidInput { .. }));
265            assert!(error.to_string().contains("independent loader failed"));
266        } else {
267            let (entry, was_cached) = second.unwrap();
268            assert!(Arc::ptr_eq(&entry, &second_entry));
269            assert!(!was_cached);
270        }
271
272        release_tx.send(()).unwrap();
273        let (entry, was_cached) = first.await.unwrap();
274        assert!(Arc::ptr_eq(&entry, &first_entry));
275        assert!(!was_cached);
276        assert!(backend.get(&key, None).await.is_none());
277        assert_eq!(backend.num_entries().await, 0);
278        assert_eq!(backend.size_bytes().await, 0);
279    }
280
281    #[test]
282    fn entry_weights_are_exact_at_byte_granularity() {
283        let key = InternalCacheKey::from_bytes([0; 16]);
284        assert_eq!(weight_unit(4096), 1);
285        assert_eq!(entry_weight(&key, 7, 1), 23);
286    }
287
288    #[test]
289    fn capacity_bytes_reports_configured_capacity() {
290        assert_eq!(
291            MokaCacheBackend::with_capacity(4096).capacity_bytes(),
292            Some(4096)
293        );
294        assert_eq!(MokaCacheBackend::no_cache().capacity_bytes(), Some(0));
295    }
296
297    #[tokio::test]
298    async fn size_methods_use_constant_time_weighted_accounting() {
299        let backend = MokaCacheBackend::with_capacity(4096);
300        let key = InternalCacheKey::from_bytes([0; 16]);
301        let entry: CacheEntry = Arc::new(());
302        let value_size = 7;
303        let expected = physical_size(&key, value_size);
304
305        backend.insert(&key, entry, value_size, None).await;
306
307        assert_eq!(backend.size_bytes().await, expected);
308        assert_eq!(backend.approx_size_bytes(), expected);
309    }
310
311    #[cfg(target_pointer_width = "64")]
312    #[test]
313    fn entry_weights_scale_for_capacities_above_four_gibibytes() {
314        let key = InternalCacheKey::from_bytes([0; 16]);
315        let capacity = 6 * 1024 * 1024 * 1024;
316        let weight_unit = weight_unit(capacity);
317        assert_eq!(weight_unit, 2);
318
319        let size_bytes = u32::MAX as usize + 1024;
320        let expected = physical_size(&key, size_bytes).div_ceil(weight_unit);
321        let weight = entry_weight(&key, size_bytes, weight_unit);
322        assert_eq!(weight as usize, expected);
323        assert_ne!(weight, u32::MAX);
324    }
325}
326
327/// Registry identifier for the built-in Moka backend.
328pub const MOKA_BACKEND_KIND: &str = "moka";
329
330/// [`BackendBuildFn`](super::registry::BackendBuildFn) for [`MokaCacheBackend`].
331///
332/// Recognized options:
333///   * `capacity` — total weighted capacity in bytes (`usize`).
334///     This must be present and non-empty.
335///
336/// Unknown options are rejected so typos surface immediately instead of
337/// silently falling through to the default capacity.
338pub(super) fn build_moka_backend(
339    config: &super::registry::BackendConfig,
340) -> Result<MokaCacheBackend> {
341    let mut capacity: Option<usize> = None;
342    for (key, value) in &config.options {
343        match key.as_str() {
344            "capacity" => {
345                if value.is_empty() {
346                    return Err(crate::Error::invalid_input(
347                        "moka cache backend: capacity must not be empty",
348                    ));
349                } else {
350                    capacity = Some(value.parse::<usize>().map_err(|err| {
351                        crate::Error::invalid_input(format!(
352                            "moka cache backend: cannot parse capacity {:?}: {}",
353                            value, err
354                        ))
355                    })?);
356                }
357            }
358            other => {
359                return Err(crate::Error::invalid_input(format!(
360                    "moka cache backend: unknown option {:?}",
361                    other
362                )));
363            }
364        }
365    }
366    let capacity = capacity.ok_or_else(|| {
367        crate::Error::invalid_input(
368            "moka cache backend: capacity is required; use moka://?capacity=<bytes>",
369        )
370    })?;
371    Ok(MokaCacheBackend::with_capacity(capacity))
372}
373
374pub(super) fn build_moka(config: &super::registry::BackendConfig) -> Result<Arc<dyn CacheBackend>> {
375    Ok(Arc::new(build_moka_backend(config)?))
376}
377
378#[cfg(test)]
379mod moka_registry_tests {
380    use super::super::backend_uri::{build_from_uri, parse_backend_uri};
381    use super::super::registry::{BackendConfig, build_from_config, registry_test_lock};
382    use super::*;
383
384    #[test]
385    fn test_moka_builds_from_config() {
386        let _lock = registry_test_lock();
387        let cfg = BackendConfig::new("moka")
388            .unwrap()
389            .with_option("capacity", "1048576");
390        let backend = build_moka_backend(&cfg).unwrap();
391        assert_eq!(backend.capacity(), 1048576);
392        let _backend = build_from_config(&cfg).unwrap();
393    }
394
395    #[test]
396    fn test_moka_builds_from_uri() {
397        let _lock = registry_test_lock();
398        let cfg = parse_backend_uri("moka://?capacity=1048576").unwrap();
399        let backend = build_moka_backend(&cfg).unwrap();
400        assert_eq!(backend.capacity(), 1048576);
401        let _backend = build_from_uri("moka://?capacity=1048576").unwrap();
402    }
403
404    #[test]
405    fn test_moka_rejects_unknown_option() {
406        let _lock = registry_test_lock();
407        let cfg = BackendConfig::new("moka")
408            .unwrap()
409            .with_option("mystery", "1");
410        let err = build_from_config(&cfg).unwrap_err();
411        assert!(err.to_string().contains("unknown option"));
412    }
413
414    #[test]
415    fn test_moka_rejects_bad_capacity() {
416        let _lock = registry_test_lock();
417        let cfg = BackendConfig::new("moka")
418            .unwrap()
419            .with_option("capacity", "not-a-number");
420        let err = build_from_config(&cfg).unwrap_err();
421        assert!(err.to_string().contains("cannot parse capacity"));
422    }
423
424    #[test]
425    fn test_moka_rejects_missing_capacity() {
426        let _lock = registry_test_lock();
427        let cfg = BackendConfig::new("moka").unwrap();
428        let err = build_from_config(&cfg).unwrap_err();
429        assert!(err.to_string().contains("capacity is required"));
430    }
431
432    #[test]
433    fn test_moka_rejects_empty_capacity() {
434        let _lock = registry_test_lock();
435        let cfg = BackendConfig::new("moka")
436            .unwrap()
437            .with_option("capacity", "");
438        let err = build_from_config(&cfg).unwrap_err();
439        assert!(err.to_string().contains("capacity must not be empty"));
440    }
441}