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(&self, key: &InternalCacheKey, _codec: Option<CacheCodec>) -> Option<CacheEntry> {
109        self.cache.get(key).await.map(|r| r.entry)
110    }
111
112    async fn insert(
113        &self,
114        key: &InternalCacheKey,
115        entry: CacheEntry,
116        size_bytes: usize,
117        _codec: Option<CacheCodec>,
118    ) {
119        self.cache
120            .insert(*key, MokaCacheEntry { entry, size_bytes })
121            .await;
122    }
123
124    async fn get_or_insert<'a>(
125        &self,
126        key: &InternalCacheKey,
127        loader: Pin<Box<dyn Future<Output = Result<(CacheEntry, usize)>> + Send + 'a>>,
128        _codec: Option<CacheCodec>,
129    ) -> Result<(CacheEntry, bool)> {
130        // Avoid Moka's single-flight waiters when nothing can be cached.
131        if self.capacity == 0 {
132            return loader.await.map(|(entry, _)| (entry, false));
133        }
134
135        // Track whether the loader actually ran (= cache miss).
136        let was_miss = Arc::new(AtomicBool::new(false));
137        let was_miss_clone = was_miss.clone();
138
139        let init = async move {
140            was_miss_clone.store(true, Ordering::Relaxed);
141            loader
142                .await
143                .map(|(entry, size_bytes)| MokaCacheEntry { entry, size_bytes })
144                .map_err(CloneableError)
145        };
146
147        let owned_key = *key;
148        match self.cache.try_get_with(owned_key, init).await {
149            Ok(record) => {
150                let was_cached = !was_miss.load(Ordering::Relaxed);
151                Ok((record.entry, was_cached))
152            }
153            Err(error) => Err(Arc::unwrap_or_clone(error).0),
154        }
155    }
156
157    async fn clear(&self) {
158        self.cache.invalidate_all();
159        self.cache.run_pending_tasks().await;
160    }
161
162    async fn num_entries(&self) -> usize {
163        self.cache.run_pending_tasks().await;
164        self.cache.entry_count() as usize
165    }
166
167    async fn size_bytes(&self) -> usize {
168        self.cache.run_pending_tasks().await;
169        self.weighted_size_bytes()
170    }
171
172    fn capacity_bytes(&self) -> Option<usize> {
173        Some(self.capacity)
174    }
175
176    fn approx_num_entries(&self) -> usize {
177        self.cache.entry_count() as usize
178    }
179
180    fn approx_size_bytes(&self) -> usize {
181        // `weighted_size()` can be stale without `run_pending_tasks()`, which
182        // is async and can't be called from this synchronous context.
183        self.weighted_size_bytes()
184    }
185
186    fn deep_size_of_entries(
187        &self,
188        context: &mut Context,
189        size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option<usize>,
190    ) -> Option<usize> {
191        Some(
192            self.cache
193                .iter()
194                .map(|(key, record)| {
195                    key_footprint(key.as_ref())
196                        + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes)
197                })
198                .sum(),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use std::pin::pin;
206
207    use futures::{FutureExt, poll};
208    use tokio::sync::oneshot;
209
210    use super::*;
211
212    #[rstest::rstest]
213    #[case::zero_capacity_success(MokaCacheBackend::with_capacity(0), false)]
214    #[case::zero_capacity_error(MokaCacheBackend::with_capacity(0), true)]
215    #[case::no_cache_success(MokaCacheBackend::no_cache(), false)]
216    #[case::no_cache_error(MokaCacheBackend::no_cache(), true)]
217    #[tokio::test]
218    async fn zero_capacity_loaders_run_independently(
219        #[case] backend: MokaCacheBackend,
220        #[case] loader_fails: bool,
221    ) {
222        let key = InternalCacheKey::from_bytes([0; 16]);
223        let first_entry: CacheEntry = Arc::new(1_u64);
224        let second_entry: CacheEntry = Arc::new(2_u64);
225        let (release_tx, release_rx) = oneshot::channel();
226        let entry = first_entry.clone();
227        let mut first = pin!(backend.get_or_insert(
228            &key,
229            Box::pin(async move {
230                release_rx.await.unwrap();
231                Ok((entry, 8))
232            }),
233            None,
234        ));
235        assert!(poll!(first.as_mut()).is_pending());
236
237        let entry = second_entry.clone();
238        let second = backend
239            .get_or_insert(
240                &key,
241                Box::pin(async move {
242                    if loader_fails {
243                        Err(crate::Error::invalid_input("independent loader failed"))
244                    } else {
245                        Ok((entry, 8))
246                    }
247                }),
248                None,
249            )
250            .now_or_never()
251            .expect("a disabled cache must not wait for another loader on the same key");
252        if loader_fails {
253            let error = second.unwrap_err();
254            assert!(matches!(&error, crate::Error::InvalidInput { .. }));
255            assert!(error.to_string().contains("independent loader failed"));
256        } else {
257            let (entry, was_cached) = second.unwrap();
258            assert!(Arc::ptr_eq(&entry, &second_entry));
259            assert!(!was_cached);
260        }
261
262        release_tx.send(()).unwrap();
263        let (entry, was_cached) = first.await.unwrap();
264        assert!(Arc::ptr_eq(&entry, &first_entry));
265        assert!(!was_cached);
266        assert!(backend.get(&key, None).await.is_none());
267        assert_eq!(backend.num_entries().await, 0);
268        assert_eq!(backend.size_bytes().await, 0);
269    }
270
271    #[test]
272    fn entry_weights_are_exact_at_byte_granularity() {
273        let key = InternalCacheKey::from_bytes([0; 16]);
274        assert_eq!(weight_unit(4096), 1);
275        assert_eq!(entry_weight(&key, 7, 1), 23);
276    }
277
278    #[test]
279    fn capacity_bytes_reports_configured_capacity() {
280        assert_eq!(
281            MokaCacheBackend::with_capacity(4096).capacity_bytes(),
282            Some(4096)
283        );
284        assert_eq!(MokaCacheBackend::no_cache().capacity_bytes(), Some(0));
285    }
286
287    #[tokio::test]
288    async fn size_methods_use_constant_time_weighted_accounting() {
289        let backend = MokaCacheBackend::with_capacity(4096);
290        let key = InternalCacheKey::from_bytes([0; 16]);
291        let entry: CacheEntry = Arc::new(());
292        let value_size = 7;
293        let expected = physical_size(&key, value_size);
294
295        backend.insert(&key, entry, value_size, None).await;
296
297        assert_eq!(backend.size_bytes().await, expected);
298        assert_eq!(backend.approx_size_bytes(), expected);
299    }
300
301    #[cfg(target_pointer_width = "64")]
302    #[test]
303    fn entry_weights_scale_for_capacities_above_four_gibibytes() {
304        let key = InternalCacheKey::from_bytes([0; 16]);
305        let capacity = 6 * 1024 * 1024 * 1024;
306        let weight_unit = weight_unit(capacity);
307        assert_eq!(weight_unit, 2);
308
309        let size_bytes = u32::MAX as usize + 1024;
310        let expected = physical_size(&key, size_bytes).div_ceil(weight_unit);
311        let weight = entry_weight(&key, size_bytes, weight_unit);
312        assert_eq!(weight as usize, expected);
313        assert_ne!(weight, u32::MAX);
314    }
315}
316
317/// Registry identifier for the built-in Moka backend.
318pub const MOKA_BACKEND_KIND: &str = "moka";
319
320/// [`BackendBuildFn`](super::registry::BackendBuildFn) for [`MokaCacheBackend`].
321///
322/// Recognized options:
323///   * `capacity` — total weighted capacity in bytes (`usize`).
324///     This must be present and non-empty.
325///
326/// Unknown options are rejected so typos surface immediately instead of
327/// silently falling through to the default capacity.
328pub(super) fn build_moka_backend(
329    config: &super::registry::BackendConfig,
330) -> Result<MokaCacheBackend> {
331    let mut capacity: Option<usize> = None;
332    for (key, value) in &config.options {
333        match key.as_str() {
334            "capacity" => {
335                if value.is_empty() {
336                    return Err(crate::Error::invalid_input(
337                        "moka cache backend: capacity must not be empty",
338                    ));
339                } else {
340                    capacity = Some(value.parse::<usize>().map_err(|err| {
341                        crate::Error::invalid_input(format!(
342                            "moka cache backend: cannot parse capacity {:?}: {}",
343                            value, err
344                        ))
345                    })?);
346                }
347            }
348            other => {
349                return Err(crate::Error::invalid_input(format!(
350                    "moka cache backend: unknown option {:?}",
351                    other
352                )));
353            }
354        }
355    }
356    let capacity = capacity.ok_or_else(|| {
357        crate::Error::invalid_input(
358            "moka cache backend: capacity is required; use moka://?capacity=<bytes>",
359        )
360    })?;
361    Ok(MokaCacheBackend::with_capacity(capacity))
362}
363
364pub(super) fn build_moka(config: &super::registry::BackendConfig) -> Result<Arc<dyn CacheBackend>> {
365    Ok(Arc::new(build_moka_backend(config)?))
366}
367
368#[cfg(test)]
369mod moka_registry_tests {
370    use super::super::backend_uri::{build_from_uri, parse_backend_uri};
371    use super::super::registry::{BackendConfig, build_from_config, registry_test_lock};
372    use super::*;
373
374    #[test]
375    fn test_moka_builds_from_config() {
376        let _lock = registry_test_lock();
377        let cfg = BackendConfig::new("moka")
378            .unwrap()
379            .with_option("capacity", "1048576");
380        let backend = build_moka_backend(&cfg).unwrap();
381        assert_eq!(backend.capacity(), 1048576);
382        let _backend = build_from_config(&cfg).unwrap();
383    }
384
385    #[test]
386    fn test_moka_builds_from_uri() {
387        let _lock = registry_test_lock();
388        let cfg = parse_backend_uri("moka://?capacity=1048576").unwrap();
389        let backend = build_moka_backend(&cfg).unwrap();
390        assert_eq!(backend.capacity(), 1048576);
391        let _backend = build_from_uri("moka://?capacity=1048576").unwrap();
392    }
393
394    #[test]
395    fn test_moka_rejects_unknown_option() {
396        let _lock = registry_test_lock();
397        let cfg = BackendConfig::new("moka")
398            .unwrap()
399            .with_option("mystery", "1");
400        let err = build_from_config(&cfg).unwrap_err();
401        assert!(err.to_string().contains("unknown option"));
402    }
403
404    #[test]
405    fn test_moka_rejects_bad_capacity() {
406        let _lock = registry_test_lock();
407        let cfg = BackendConfig::new("moka")
408            .unwrap()
409            .with_option("capacity", "not-a-number");
410        let err = build_from_config(&cfg).unwrap_err();
411        assert!(err.to_string().contains("cannot parse capacity"));
412    }
413
414    #[test]
415    fn test_moka_rejects_missing_capacity() {
416        let _lock = registry_test_lock();
417        let cfg = BackendConfig::new("moka").unwrap();
418        let err = build_from_config(&cfg).unwrap_err();
419        assert!(err.to_string().contains("capacity is required"));
420    }
421
422    #[test]
423    fn test_moka_rejects_empty_capacity() {
424        let _lock = registry_test_lock();
425        let cfg = BackendConfig::new("moka")
426            .unwrap()
427            .with_option("capacity", "");
428        let err = build_from_config(&cfg).unwrap_err();
429        assert!(err.to_string().contains("capacity must not be empty"));
430    }
431}