Skip to main content

lance_core/cache/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Pluggable cache-backend registry.
5//!
6//! A [`BackendConfig`] identifies which backend to build (`kind`) and carries
7//! backend-specific string options. Backends are constructed through a
8//! [`BackendBuildFn`] registered under a unique `kind`. Third-party crates
9//! integrate by calling [`register_backend`] once at application startup;
10//! [`build_from_config`] then locates the constructor and hands it the
11//! config.
12//!
13//! The registry uses `HashMap<String, String>` for options so it can be
14//! represented naturally across FFI (Python `dict[str, str]`, Java
15//! `Map<String, String>`, etc.).
16
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
19
20use super::backend::CacheBackend;
21use super::moka::{MOKA_BACKEND_KIND, build_moka};
22use crate::{Error, Result};
23
24/// Backend-independent configuration passed to a [`BackendBuildFn`].
25///
26/// `kind` selects which registered backend to construct; `options` carries
27/// backend-specific key/value settings (e.g. `capacity`, `path`).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct BackendConfig {
30    /// Registered backend identifier, e.g. `"moka"`.
31    pub kind: String,
32    /// Backend-specific string options.
33    pub options: HashMap<String, String>,
34}
35
36impl BackendConfig {
37    /// Build a config with no options.
38    pub fn new(kind: impl AsRef<str>) -> Result<Self> {
39        Ok(Self {
40            kind: normalize_backend_kind(kind.as_ref())?,
41            options: HashMap::new(),
42        })
43    }
44
45    /// Insert a single option and return `self`, enabling chaining.
46    pub fn with_option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
47        self.options.insert(key.into(), value.into());
48        self
49    }
50}
51
52/// Normalize and validate a cache backend kind.
53///
54/// Backend kinds share the same syntax as URI schemes. They are matched
55/// case-insensitively and stored as lowercase ASCII so registry lookups,
56/// config dictionaries, and URI parsing all address the same key.
57pub fn normalize_backend_kind(kind: &str) -> Result<String> {
58    let mut chars = kind.chars();
59    match chars.next() {
60        Some(c) if c.is_ascii_alphabetic() => {}
61        _ => {
62            return Err(Error::invalid_input(format!(
63                "cache backend kind {:?}: must start with an ASCII letter",
64                kind
65            )));
66        }
67    }
68    for c in chars {
69        let ok = c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.');
70        if !ok {
71            return Err(Error::invalid_input(format!(
72                "cache backend kind {:?}: invalid character {:?}",
73                kind, c
74            )));
75        }
76    }
77    Ok(kind.to_ascii_lowercase())
78}
79
80/// Constructor signature for a cache backend.
81///
82/// Constructors are synchronous. Backends that need async initialization
83/// should surface a `try_new_blocking` shim (or equivalent) and call it here.
84pub type BackendBuildFn = fn(&BackendConfig) -> Result<Arc<dyn CacheBackend>>;
85
86fn registry() -> &'static Mutex<HashMap<String, BackendBuildFn>> {
87    static REGISTRY: OnceLock<Mutex<HashMap<String, BackendBuildFn>>> = OnceLock::new();
88    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
89}
90
91fn registry_lock() -> Result<MutexGuard<'static, HashMap<String, BackendBuildFn>>> {
92    registry()
93        .lock()
94        .map_err(|_| Error::internal("cache backend registry mutex is poisoned"))
95}
96
97#[cfg(test)]
98fn registry_lock_for_test() -> MutexGuard<'static, HashMap<String, BackendBuildFn>> {
99    registry()
100        .lock()
101        .unwrap_or_else(|poisoned| poisoned.into_inner())
102}
103
104/// Register a constructor for a cache backend under `kind`.
105///
106/// Returns `Err` if a non-built-in `kind` is already registered. Built-in
107/// backends may be replaced so callers can mask a built-in implementation
108/// (for example, a patched `"moka"` backend) without changing URI/config
109/// strings elsewhere.
110///
111/// Typical usage from a backend crate:
112///
113/// ```
114/// # use std::sync::Arc;
115/// # use lance_core::Result;
116/// # use lance_core::cache::{BackendConfig, CacheBackend, MokaCacheBackend, register_backend};
117/// fn build_my_backend(_config: &BackendConfig) -> Result<Arc<dyn CacheBackend>> {
118///     Ok(Arc::new(MokaCacheBackend::with_capacity(1024)))
119/// }
120///
121/// # fn main() -> Result<()> {
122/// register_backend("my-backend", build_my_backend)?;
123/// # Ok(())
124/// # }
125/// ```
126pub fn register_backend(kind: &str, build: BackendBuildFn) -> Result<()> {
127    let kind = normalize_backend_kind(kind)?;
128    insert_backend(&kind, build, builtin_backend(&kind).is_some())
129}
130
131fn insert_backend(kind: &str, build: BackendBuildFn, allow_replace: bool) -> Result<()> {
132    let mut map = registry_lock()?;
133    if map.contains_key(kind) && !allow_replace {
134        return Err(Error::invalid_input(format!(
135            "cache backend {:?} is already registered",
136            kind
137        )));
138    }
139    map.insert(kind.to_string(), build);
140    Ok(())
141}
142
143fn builtin_backend(kind: &str) -> Option<BackendBuildFn> {
144    match kind {
145        MOKA_BACKEND_KIND => Some(build_moka),
146        _ => None,
147    }
148}
149
150/// Look up the constructor for `config.kind` and build a backend.
151///
152/// Returns `Err` if no backend has been registered under that identifier.
153pub fn build_from_config(config: &BackendConfig) -> Result<Arc<dyn CacheBackend>> {
154    ensure_builtin_backends()?;
155    let kind = normalize_backend_kind(&config.kind)?;
156    let config = BackendConfig {
157        kind: kind.clone(),
158        options: config.options.clone(),
159    };
160    let build = {
161        let map = registry_lock()?;
162        map.get(&kind).copied()
163    };
164    match build {
165        Some(build) => build(&config),
166        None => Err(Error::invalid_input(format!(
167            "unknown cache backend kind: {:?}",
168            kind
169        ))),
170    }
171}
172
173/// Idempotently register the backends that ship with `lance-core`.
174///
175/// Called by [`build_from_config`] (and, transitively, by
176/// [`build_from_uri`](super::backend_uri::build_from_uri)) so a bare Lance
177/// installation can build a Moka backend without the caller having to
178/// register it. Third-party backends still have to opt in with their own
179/// `register()` call.
180///
181/// The check is against the current registry contents rather than a
182/// process-once flag so that `#[cfg(test)]` helpers which snapshot and
183/// restore the registry still see the built-in backend after they take
184/// ownership.
185fn ensure_builtin_backends() -> Result<()> {
186    let mut map = registry_lock()?;
187    if !map.contains_key(MOKA_BACKEND_KIND)
188        && let Some(build) = builtin_backend(MOKA_BACKEND_KIND)
189    {
190        map.insert(MOKA_BACKEND_KIND.to_string(), build);
191    }
192    Ok(())
193}
194
195/// Test-only helper: replace the registry with an empty map so tests can
196/// exercise duplicate-registration logic without polluting the global one.
197#[cfg(test)]
198pub(super) fn take_registry_for_test() -> HashMap<String, BackendBuildFn> {
199    let mut map = registry_lock_for_test();
200    std::mem::take(&mut *map)
201}
202
203/// Test-only helper: restore a previously captured registry state.
204#[cfg(test)]
205pub(super) fn restore_registry_for_test(saved: HashMap<String, BackendBuildFn>) {
206    let mut map = registry_lock_for_test();
207    *map = saved;
208}
209
210#[cfg(test)]
211pub(super) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> {
212    static M: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
213    M.get_or_init(|| std::sync::Mutex::new(()))
214        .lock()
215        .unwrap_or_else(|poisoned| poisoned.into_inner())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use async_trait::async_trait;
222    use std::pin::Pin;
223
224    use crate::cache::InternalCacheKey;
225    use crate::cache::backend::CacheEntry;
226    use crate::cache::codec::CacheCodec;
227    use futures::Future;
228
229    // A trivial no-op backend so tests do not depend on Moka or any other
230    // real backend. Every method returns "empty" / does nothing.
231    #[derive(Debug, Default)]
232    struct NullBackend;
233
234    #[async_trait]
235    impl CacheBackend for NullBackend {
236        async fn get(
237            &self,
238            _key: &InternalCacheKey,
239            _codec: Option<CacheCodec>,
240        ) -> Option<CacheEntry> {
241            None
242        }
243
244        async fn insert(
245            &self,
246            _key: &InternalCacheKey,
247            _entry: CacheEntry,
248            _size_bytes: usize,
249            _codec: Option<CacheCodec>,
250        ) {
251        }
252
253        async fn get_or_insert<'a>(
254            &self,
255            _key: &InternalCacheKey,
256            loader: Pin<Box<dyn Future<Output = crate::Result<(CacheEntry, usize)>> + Send + 'a>>,
257            _codec: Option<CacheCodec>,
258        ) -> crate::Result<(CacheEntry, bool)> {
259            let (entry, _size) = loader.await?;
260            Ok((entry, false))
261        }
262
263        async fn clear(&self) {}
264        async fn num_entries(&self) -> usize {
265            0
266        }
267        async fn size_bytes(&self) -> usize {
268            0
269        }
270    }
271
272    fn build_null(_cfg: &BackendConfig) -> Result<Arc<dyn CacheBackend>> {
273        Ok(Arc::new(NullBackend))
274    }
275
276    struct RegistryGuard {
277        // Hold the serialization lock for the full test.
278        _lock: std::sync::MutexGuard<'static, ()>,
279        saved: HashMap<String, BackendBuildFn>,
280    }
281    impl RegistryGuard {
282        fn new() -> Self {
283            Self {
284                _lock: registry_test_lock(),
285                saved: take_registry_for_test(),
286            }
287        }
288    }
289    impl Drop for RegistryGuard {
290        fn drop(&mut self) {
291            restore_registry_for_test(std::mem::take(&mut self.saved));
292        }
293    }
294
295    #[test]
296    fn test_register_and_build() {
297        let _guard = RegistryGuard::new();
298        register_backend("null", build_null).unwrap();
299        let backend = build_from_config(&BackendConfig::new("null").unwrap()).unwrap();
300        // Backend is opaque; we just check that the constructor ran and
301        // gave us an Arc<dyn CacheBackend>.
302        assert_eq!(Arc::strong_count(&backend), 1);
303    }
304
305    #[test]
306    fn test_duplicate_registration_errors() {
307        let _guard = RegistryGuard::new();
308        register_backend("dup", build_null).unwrap();
309        let err = register_backend("dup", build_null).unwrap_err();
310        assert!(err.to_string().contains("already registered"));
311    }
312
313    #[test]
314    fn test_builtin_kind_can_be_overridden() {
315        let _guard = RegistryGuard::new();
316        register_backend("moka", build_null).unwrap();
317        let backend = build_from_config(&BackendConfig::new("moka").unwrap()).unwrap();
318        assert_eq!(Arc::strong_count(&backend), 1);
319    }
320
321    #[test]
322    fn test_unknown_kind_errors() {
323        let _guard = RegistryGuard::new();
324        let err = build_from_config(&BackendConfig::new("missing").unwrap()).unwrap_err();
325        assert!(err.to_string().contains("unknown cache backend kind"));
326    }
327
328    #[test]
329    fn test_backend_kind_is_normalized() {
330        let _guard = RegistryGuard::new();
331        register_backend("Echo.Backend", build_null).unwrap();
332        let backend = build_from_config(&BackendConfig::new("echo.backend").unwrap()).unwrap();
333        assert_eq!(Arc::strong_count(&backend), 1);
334    }
335
336    #[test]
337    fn test_config_lookup_normalizes_direct_config() {
338        let _guard = RegistryGuard::new();
339        fn build_echo(cfg: &BackendConfig) -> Result<Arc<dyn CacheBackend>> {
340            assert_eq!(cfg.kind, "echo.backend");
341            Ok(Arc::new(NullBackend))
342        }
343        register_backend("echo.backend", build_echo).unwrap();
344        let cfg = BackendConfig {
345            kind: "ECHO.Backend".to_string(),
346            options: HashMap::new(),
347        };
348        build_from_config(&cfg).unwrap();
349    }
350
351    #[test]
352    fn test_invalid_backend_kind_errors() {
353        let err = register_backend("not a scheme", build_null).unwrap_err();
354        assert!(err.to_string().contains("invalid character"));
355        let err = BackendConfig::new("1moka").unwrap_err();
356        assert!(err.to_string().contains("must start with an ASCII letter"));
357    }
358
359    #[test]
360    fn test_options_are_passed_through() {
361        let _guard = RegistryGuard::new();
362        fn build_echo(cfg: &BackendConfig) -> Result<Arc<dyn CacheBackend>> {
363            assert_eq!(cfg.options.get("capacity").map(String::as_str), Some("42"));
364            Ok(Arc::new(NullBackend))
365        }
366        register_backend("echo", build_echo).unwrap();
367        let cfg = BackendConfig::new("echo")
368            .unwrap()
369            .with_option("capacity", "42");
370        build_from_config(&cfg).unwrap();
371    }
372}