Skip to content

feat: pluggable index cache via CacheBackend trait#6222

Open
wjones127 wants to merge 12 commits intolance-format:mainfrom
wjones127:feat/pluggable-index-cache
Open

feat: pluggable index cache via CacheBackend trait#6222
wjones127 wants to merge 12 commits intolance-format:mainfrom
wjones127:feat/pluggable-index-cache

Conversation

@wjones127
Copy link
Contributor

@wjones127 wjones127 commented Mar 18, 2026

Previously the Session's index cache was hardcoded to Moka. This adds a CacheBackend trait so users can provide their own cache implementation. There is a default MokaCacheBackend that works the same as the existing cache.

flowchart LR
    LanceCache --> backend["dyn CacheBackend"]
    backend --> MokaCacheBackend
    backend --> CustomCacheBackend
Loading

The cache key construction is handled at the LanceCache layer, so CacheBackend implementations just receive opaque bytes for keys. They can optionally use parse_cache_key to get a unique u64 type id. This might be used by caches to figure out how they can downcast and serialize / deserialize the entry.

flowchart LR
    CacheKey --> key["&[u8]"]
    key --"parse_cache_key"--> typeid
Loading

The Session's index cache was hardcoded to use Moka. This adds a
CacheBackend trait so users can provide their own cache implementation
(e.g. Redis-backed, disk-backed, shared across processes).

Two-layer design:
- CacheBackend: object-safe async trait with opaque byte keys. This is
  what plugin authors implement (get, insert, invalidate_prefix, clear,
  num_entries, size_bytes).
- LanceCache: typed wrapper handling key construction (prefix + type
  tag), type-safe get/insert, DeepSizeOf size computation, hit/miss
  stats, and concurrent load deduplication.

MokaCacheBackend is the default, preserving existing behavior. Custom
backends are wired through Session::with_index_cache_backend() or
DatasetBuilder::with_index_cache_backend().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added the enhancement New feature or request label Mar 18, 2026
@github-actions
Copy link
Contributor

PR Review: feat: pluggable index cache via CacheBackend trait

P0: TOCTOU race in get_or_insert — dedup is broken

The check-then-register pattern in get_or_insert has a race between the two separately-locked critical sections:

// Block 1: check (drops lock at end of block)
{
    let map = self.in_flight.lock().await;
    if let Some(rx) = map.get(&cache_key) { ... }
}
// <-- gap: another task can also see an empty map here

// Block 2: register as leader (re-acquires lock)
let (tx, rx) = tokio::sync::watch::channel(None);
{
    let mut map = self.in_flight.lock().await;
    map.insert(cache_key.clone(), rx);
}

Between dropping the lock in block 1 and re-acquiring it in block 2, N tasks can all see an empty in-flight map and all decide they are the "leader." The last one to insert overwrites the earlier receivers, so:

  1. The loader runs N times (dedup completely defeated).
  2. Tasks that cloned an earlier receiver get RecvError when that sender drops without the value propagating through the map.

Fix: Merge the check and register into a single critical section:

let mut map = self.in_flight.lock().await;
if let Some(rx) = map.get(&cache_key) {
    let mut rx = rx.clone();
    drop(map);
    // ... wait for leader ...
} else {
    let (tx, rx) = tokio::sync::watch::channel(None);
    map.insert(cache_key.clone(), rx);
    drop(map);
    // ... run loader ...
}

The test_get_or_insert_dedup test passes by luck because tokio::task::yield_now() with a broadcast barrier doesn't reliably interleave into this gap. A test with a longer sleep or explicit synchronization at the gap point would expose this.

P1: type_tag relies on unspecified pointer identity

fn type_tag<T: 'static>() -> [u8; 8] {
    (std::any::type_name::<T>().as_ptr() as u64).to_le_bytes()
}

The Rust spec does not guarantee that type_name::<T>().as_ptr() returns the same address across calls, nor that distinct types produce distinct addresses (string deduplication/interning is compiler-implementation-defined). The old code used TypeId as a key component, which has correct identity semantics.

If two different types happen to collide (same pointer or same string literal address), the cache will silently return a wrong type and the downcast::<T>().unwrap() will panic at runtime.

Consider using TypeId bytes instead. TypeId implements Hash and Eq, so you could hash it to get a stable discriminator, or use transmute to extract its bytes (it's currently 128 bits).

P1: invalidate_prefix is fire-and-forget

pub fn invalidate_prefix(&self, prefix: &str) {
    let prefix_bytes = self.make_prefix(prefix);
    let cache = self.cache.clone();
    tokio::spawn(async move {
        cache.invalidate_prefix(&prefix_bytes).await;
    });
}

This means invalidation is not guaranteed to complete before subsequent cache reads. A caller that invalidates then immediately reads could get stale data. The old code was synchronous. If this must remain sync (non-async signature), at minimum document this caveat prominently, or consider changing the signature to async fn.

Minor notes

  • DeepSizeOf for LanceCache now returns 0, which is a silent behavioral regression for any code that relies on deep_size_of() for memory accounting. Consider at least delegating to approx_size_bytes().
  • WeakLanceCache::get_or_insert lost all dedup behavior — concurrent loads for the same key will all run the loader independently. The old code used moka's optionally_get_with which handled this.

wjones127 and others added 2 commits March 18, 2026 20:04
Add type_name()/type_id() to CacheKey and UnsizedCacheKey traits so
backends can identify the type of cached entries. Add parse_cache_key()
utility for backends to extract (user_key, type_id) from opaque key
bytes.

CacheKey-based methods now pipe the key's type_id through to the
backend. Non-CacheKey methods use type_id_of::<T>() as a sentinel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Remove #[cfg(test)] convenience methods; tests now use CacheKey
   via a TestKey helper, eliminating the parallel method hierarchy.

2. Fix dedup race condition: re-check the cache while holding the
   in-flight lock so no two tasks can both become leader for the
   same key.

3. Use Arc::try_unwrap on the leader error path to preserve the
   original error type when possible.

4. Make invalidate_prefix async instead of fire-and-forget spawn.

5. Replace type_name().as_ptr() with a hash of std::any::TypeId for
   stable type discrimination. Defined once in type_id_of() and used
   by CacheKey::type_id() default.

6. Add dedup to WeakLanceCache::get_or_insert, sharing the in-flight
   map from the parent LanceCache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address feedback:

1. Move get_or_insert() onto CacheBackend. The method takes a pinned
   future (not a closure), so LanceCache can type-erase the user's
   non-'static loader before passing it to the backend. Default impl
   does simple get-then-insert; MokaCacheBackend uses moka's built-in
   optionally_get_with for dedup. This eliminates duplicated dedup
   logic and the manual watch-channel machinery.

2. Restore type_name().as_ptr() for type_id derivation on CacheKey.
   Remove standalone type_id_of() function. The derivation lives in
   one place: CacheKey::type_id()/UnsizedCacheKey::type_id().

3. Remove approx_size_bytes from CacheBackend trait and Session debug
   output. Only approx_num_entries remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wjones127 wjones127 force-pushed the feat/pluggable-index-cache branch from 56a3273 to 00867ad Compare March 19, 2026 16:56
wjones127 and others added 3 commits March 19, 2026 10:05
Remove all methods that bypass CacheKey from WeakLanceCache (get,
insert, get_or_insert, get_unsized, insert_unsized). Remove
insert_unsized/get_unsized from LanceCache. Remove type_tag helper.
All cache access now goes through CacheKey/UnsizedCacheKey.

Make parse_cache_key return (empty, 0) instead of panicking on short
keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore approx_size_bytes on CacheBackend so DeepSizeOf on LanceCache
reports actual cache memory usage (used by Session::size_bytes). Fixes
test_metadata_cache_size Python test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wjones127 wjones127 force-pushed the feat/pluggable-index-cache branch from 369afe2 to 1ba4ac3 Compare March 19, 2026 18:29
@wjones127 wjones127 marked this pull request as ready for review March 19, 2026 19:05
wjones127 and others added 5 commits March 19, 2026 17:07
The type_name().as_ptr() approach for type discrimination was unstable
across crate boundaries due to monomorphization. Replace with an
explicit fn type_id() -> &'static str that each CacheKey impl provides
as a short human-readable literal (e.g. 'Vec<IndexMetadata>', 'Manifest').

Key format changes from user_key\0<8 LE bytes> to user_key\0<type_id str>.
parse_cache_key() now returns (&[u8], &str).
Add IvfIndexState struct and serialization to lance-index, enabling
IVFIndex to export its reconstructable state (IVF model, quantizer
metadata) without non-serializable handles. Add reconstruct_vector_index()
which rebuilds an IVFIndex from cached state by re-opening FileReaders
(cheap with warm metadata cache) instead of re-fetching global buffers
from object storage.

Also adds IvfQuantizationStorage::from_cached() to skip global buffer
reads during reconstruction, and Session::file_metadata_cache() to
expose the metadata cache for the reconstruction context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reconstructed VectorIndex instances need the original cache key prefix
to share partition entries with the two-tier cache backend. Also adds
LanceCache::with_backend_and_prefix() and WeakLanceCache::prefix().

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant