Proposal: Pluggable cache backends across Rust core and Python / Java bindings #7575
Replies: 6 comments 3 replies
-
Beta Was this translation helpful? Give feedback.
-
|
This proposal does not seem to address how to add a cache backend that is not yet built into Lance. For example, Lance does not currently support Foyer. How would one load a Foyer backend? |
Beta Was this translation helpful? Give feedback.
-
Yes, currently, in the first phase, I only considered the register and switch cache backend, which is built into However, if you'd prefer to have a third-party cache backend to register, I can refine it. |
Beta Was this translation helpful? Give feedback.
-
Loading a cache backend that is NOT compiled into LanceRust has no stable ABI, and
The ABI (version 1)Lance publishes a stable, pub const LANCE_CACHE_ABI_V1: u32 = 1;
#[repr(C)]
pub struct LanceKv { pub key: *const c_char, pub val: *const c_char }
#[repr(C)]
pub struct LanceKeyC { // borrowed for the call's duration
pub prefix: *const c_char,
pub key: *const c_char,
pub type_name: *const c_char,
}
#[repr(C)]
pub struct LanceCacheBackendVtableV1 {
pub abi_version: u32, // must equal LANCE_CACHE_ABI_V1
pub kind: *const c_char, // e.g. "foyer"
// Construct from string options. NULL on failure (err_out filled).
pub create: extern "C" fn(*const LanceKv, usize, *mut c_char, usize) -> *mut c_void,
// Blocking, byte-oriented ops. 0 = ok; nonzero = stable error code.
// On a get hit, plugin allocates *value_out; Lance returns it via free_value.
pub get: extern "C" fn(*mut c_void, *const LanceKeyC, *mut *mut u8, *mut usize) -> i32,
pub insert: extern "C" fn(*mut c_void, *const LanceKeyC, *const u8, usize, usize) -> i32,
pub invalidate_prefix: extern "C" fn(*mut c_void, *const c_char) -> i32,
pub clear: extern "C" fn(*mut c_void) -> i32,
pub num_entries: extern "C" fn(*mut c_void) -> usize,
pub size_bytes: extern "C" fn(*mut c_void) -> usize,
pub free_value: extern "C" fn(*mut u8, usize), // ownership handoff
pub destroy: extern "C" fn(*mut c_void),
}1. How a plugin author implements itThe author writes an ordinary Rust backend and links a small helper crate ( // crate-type = ["cdylib"]
struct FoyerBackend { /* author's own foyer state */ }
impl lance_cache_plugin::PluginBackend for FoyerBackend {
fn create(options: &BTreeMap<String, String>) -> Result<Self, String> { /* ... */ }
fn get(&self, key: &PluginKey) -> Option<Vec<u8>> { /* ... */ }
fn insert(&self, key: &PluginKey, value: &[u8], size_bytes: usize) { /* ... */ }
fn invalidate_prefix(&self, prefix: &str) { /* ... */ }
fn clear(&self) { /* ... */ }
fn num_entries(&self) -> usize { /* ... */ }
fn size_bytes(&self) -> usize { /* ... */ }
pub approx_num_entries: extern "C" fn(*mut c_void) -> usize,
pub approx_size_bytes: extern "C" fn(*mut c_void) -> usize,
}
// Generates lance_cache_backend_plugin_v1() and all extern "C" shims.
lance_cache_plugin::export_cache_backend!(kind = "foyer", FoyerBackend);cargo build --release # produces libfoyer_cache.so2. How Lance registers itLance exposes a loader. It pub fn load_cache_backend_library(path: impl AsRef<Path>) -> Result<()> {
// 1. libloading::Library::new(path)
// 2. sym: fn() -> *const LanceCacheBackendVtableV1
// 3. assert vtable.abi_version == LANCE_CACHE_ABI_V1 (else stable error)
// 4. kind = CStr(vtable.kind)
// 5. register_backend(kind, move |cfg| {
// let handle = (vtable.create)(cfg.options ...)?; // -> *mut c_void
// Ok(Arc::new(DynPluginBackend { vtable, handle, lib }))
// })
}The adapter turns blocking C calls into the async trait and does typed↔bytes via the codec: #[async_trait]
impl CacheBackend for DynPluginBackend {
async fn get(&self, key: &InternalCacheKey, codec: Option<CacheCodec>) -> Option<CacheEntry> {
let bytes = spawn_blocking(move || call_get(vtable, handle, key_c)).await.ok()??;
codec?.deserialize(&bytes).ok() // bytes -> Arc<dyn Any>
}
// insert: codec.serialize -> spawn_blocking(insert)
// get_or_insert: get(); on miss run the native Rust loader; insert().
// Concurrent-load dedup stays here, not in the plugin.
}Because 3. How a user uses itIdentical to any built-in backend once loaded — the caller never sees the difference: // Rust
lance::cache::load_cache_backend_library("/opt/lance/libfoyer_cache.so")?;
Session::builder().index_cache_backend("foyer:///var/lance/cache?capacity=10G").build()?;# Python — no recompilation of pylance required
session = lance.Session(
backend_plugins=["/opt/lance/libfoyer_cache.so"],
index_cache_backend="foyer:///var/lance/cache?capacity=10G",
)Constraints this relies on
|
Beta Was this translation helpful? Give feedback.
-
|
Looks like a good approach to me. This is somewhat similar to how the scalar index plugin registry is setup. Instead of just using a factory function we went ahead and made a |
Beta Was this translation helpful? Give feedback.
-
|
FYI, I'm working with another contributor on improving the cache implementations. Two changes that will likely come soon: we will change the keys to a u128 instead of a string, and we will drop
Some work that's not complete yet is I'd like to deprecate and remove the use of |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Background
Lance already has a typed cache framework and a CacheBackend abstraction:
•
LanceCache:rust/lance-core/src/cache/mod.rs•
CacheBackendtrait:rust/lance-core/src/cache/backend.rs• Cache persistence envelope:
rust/lance-core/src/cache/codec.rs• Current production backend:
MokaCacheBackend, defined inrust/lance-core/src/cache/moka.rs• Rust currently has a custom backend injection point for the index cache:
rust/lance/src/session.rs• Python bindings currently expose cache size parameters, e.g.
python/src/session.rsandpython/src/dataset.rsThis means the lower-level abstraction is already extensible, but there is no unified mechanism yet for Session, language bindings, or third-party backends to select and register cache backends.
Motivation
The goal is to allow Lance users to choose a cache backend based on their deployment environment instead of always relying on a single default backend. Some concrete use cases:
Goals
This proposal aims to add:
• A unified cache backend registration and construction mechanism in Rust core.
• Backend selection for both index cache and metadata cache.
• Stable backend configuration entry points in Python / Java bindings.
• Backward compatibility for existing cache size parameters.
• A design that can later support externally compiled dynamic
.soplugins without changing the user-facing configuration model.Non-goals
The following are out of scope for the initial implementation:
• Changing the core semantics of the existing
CacheBackendtrait.• Adding a new concrete backend implementation as part of this proposal.
• Implementing a dynamic
.soplugin loader or C ABI in the first step.• Changing the current default backend behavior. If no backend is explicitly configured, Lance should behave as it does today.
Dynamic plugins are not part of the first implementation, but the design below should leave a clear path for them.
Proposed design
1. Add backend config and registry
Add a backend-independent config type in
lance-core:Where:
•
kindidentifies the backend type, e.g."moka", "...".•
optionscontains backend-specific configuration.•
optionsusesHashMap<String, String>instead of a closed enum or Rust-only types, so it can be represented naturally across Python, Java, and future FFI boundaries.The registry and construction API could look like:
register_backendshould returnResult<()>and reject duplicatekindregistrations. This prevents different crates or future dynamic plugins from silently overriding each other.MokaCacheBackendcan also be registered as"moka"through the same mechanism, so the default backend and external backends share the same construction path.2. URI as a binding-friendly configuration form
In addition to structured config, provide a URI form:
Examples:
A URI would be parsed into
BackendConfig, then passed tobuild_from_config.The relationship is:
This keeps Rust APIs structured while giving Python / Java / config files a compact string representation. It also avoids requiring each backend to implement its own incompatible URI parser.
3. Third-party crate integration
For a third-party backend that is compiled together with Lance, the integration path could be:
CacheBackend.BackendBuildFn.register_backend("my_backend", build_my_backend).This does not require adding a new enum variant or hardcoded backend list in Lance core.
4. Symmetric backend selection for index cache and metadata cache
Rust currently has an injection point for the index cache, but metadata cache is not symmetric. This proposal suggests adding a unified construction path, for example:
Alternatively, the current style can be preserved with separate methods:
The exact API shape can follow the existing
Sessiondesign. The important part is that both index cache and metadata cache should be configurable through the same backend construction mechanism.5. Python bindings
Python can accept a URI string:
It can also accept a structured dict:
The Rust binding layer can dispatch based on the Python value type and call either
build_from_uriorbuild_from_config.This avoids adding a Python class for every backend and avoids exposing
Arc<dyn CacheBackend>directly to Python.6. Java bindings
Java can use the same concepts:
• URI:
String• Structured config:
kind + Map<String, String>For example:
Or:
Python and Java do not need to expose identical builder APIs, but the semantics should be consistent: each backend configuration should eventually map to a
BackendConfig.7. Compatibility with existing cache size parameters
Existing parameters should remain available, for example:
•
index_cache_size_bytes•
metadata_cache_size_bytesSuggested rules:
• If
*_cache_backendis not set, keep the current behavior.• If
*_cache_backendis set, capacity and other backend-specific parameters should be supplied through the backend config.• If both
*_cache_backendand the corresponding*_cache_size_bytesare set, return a clear error instead of relying on implicit precedence.For example:
This should be rejected because the capacity is specified twice.
Compatibility with dynamic
.sopluginsThis proposal does not require implementing dynamic plugin loading in the first phase, but the API should be able to support externally compiled
.sobackends later.A future dynamic plugin flow could look like:
load_cache_backend_library(path)..soand registers the plugin backend into the same registry.In other words, dynamic plugins would add a loader and adapter under the registry:
To keep this path possible without changing the public configuration model later, the current design should preserve these constraints:
BackendConfig.optionsusesHashMap<String, String>, which can later be represented as string pairs, JSON, or another stable ABI-compatible form.register_backendshould return an error instead of replacing an existing backend silently.Arc<dyn Any>directly. Dynamic plugins, remote backends, and persistent backends usually need to operate on bytes, so values should go through the existingCacheCodec/ envelope mechanism.This does not require ABI support today. It only means the factory / registry / config design should not conflict with future ABI-based plugins.
Beta Was this translation helpful? Give feedback.
All reactions