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