Skip to content

fix: evict version-scoped cache entries when cleanup deletes their files - #7718

Open
yeung108 wants to merge 3 commits into
lance-format:mainfrom
yeung108:fix/cache-invalidation-write-commit-cleanup
Open

yeung108 wants to merge 3 commits into
lance-format:mainfrom
yeung108:fix/cache-invalidation-write-commit-cleanup

Conversation

@yeung108

@yeung108 yeung108 commented Jul 9, 2026

Copy link
Copy Markdown

Problem

#1983 was originally about merge_insert's DataFusion join having no configured RAM limit. In investigating it I found that the join's RAM usage is only part of the story — there's a separate, more fundamental issue that also affects plain add(), not just merge_insert().

A jemalloc-instrumented reproduction (table held at a constant row count, cleanup_older_than=0 on every optimize(), forced allocator arena.purge()) showed:

  • stats.allocated (jemalloc live heap, not just RSS) grows on every commit, even with disk usage and fragment/version counts provably bounded.
  • Forced allocator purge recovers 0.0 MB, every time — ruling out allocator retention.
  • Plain table.add() alone leaks (+77.8 MB live heap over 1,500 calls), not just merge_insert() (+117.8 MB) — ruling out the join as the sole cause.

Root cause

Every commit inserts new entries into the session's metadata/index caches (rust/lance/src/session/caches.rs, rust/lance/src/session/index_caches.rs), keyed by dataset version:

ManifestKey { version }        // "manifest/{version}[/{e_tag}]"
TransactionKey { version }     // "txn/{version}"
RowAddrMaskKey { version, .. } // "row_addr_mask/{version}[/{hash}]"
RowIdIndexKey { version }      // "row_id_index/{version}"
IndexMetadataKey { version }   // bare version number, in the index cache

Nothing in the write, commit, or cleanup paths ever evicts these. A repo-wide search confirms the only call sites for the existing invalidate_prefix are the trait definition, its Moka implementation, and two test-only mock backends — never in commit.rs, cleanup.rs, or optimize.rs. So a long-running writer sharing one Session across many commits sees these caches grow roughly linearly with commit count — indistinguishable from a leak from the outside — bounded only by DEFAULT_METADATA_CACHE_SIZE/DEFAULT_INDEX_CACHE_SIZE (1 GiB each), a ceiling that's often above a container's actual memory limit.

Cleanup (cleanup_old_versions, and optimize()'s auto-cleanup hook — exactly the maintenance cadence the docs recommend for long-running upsert workloads) already knows precisely which versions become unreachable when it deletes their files. It just never told the cache.

Fix

CleanupTask::run() (rust/lance/src/dataset/cleanup.rs) now invalidates the metadata/index cache entries for every manifest version it physically deletes, gated on self.action.deletes_files() so a side-effect-free explain() stays side-effect-free.

The existing invalidate_prefix couldn't be reused for this: it matches an entry's namespace prefix field (a dataset URI), never its per-entry key field where version numbers actually live (see InternalCacheKey::starts_with). So I added invalidate_key_prefix (backend trait method + Moka implementation + LanceCache wrapper) that matches on key with a /-boundary check — evicting version 5 doesn't also evict version 50, and it covers every optional suffix a key type may carry (e.g. a manifest's e-tag) without needing to know it in advance.

Scope

This PR covers version-scoped cache entries, which the jemalloc evidence points to as the dominant driver. Per-fragment entries (DeletionFileKey, RowIdSequenceKey) aren't covered yet — documented as a follow-up in the code. The original ask in #1983 (a configurable RAM limit for merge_insert's DataFusion join) is a separate, complementary fix I'll follow up with in another PR — it bounds the join's own transient memory, not this cache-retention gap, and doesn't address the fact that plain add() leaks too.

Testing

  • New unit test test_cache_invalidate_key_prefix (rust/lance-core/src/cache/mod.rs) verifies the new primitive's boundary-safety (evicting "manifest/5" doesn't touch "manifest/50") and that it covers suffixed variants ("manifest/5/etag-abc").
  • New integration test cleanup_evicts_stale_version_caches (rust/lance/src/dataset/cleanup.rs) writes 11 versions under one shared Session, runs cleanup to retain only the latest, and asserts both that the cache shrinks and that no cached key still references a version whose files were just deleted.
  • Verified the integration test actually catches the regression: temporarily commented out the new invalidation call and confirmed the test fails with a clear diff (num_entries unchanged before/after cleanup); restored the fix and confirmed it passes again.
  • All existing tests in dataset::cleanup::, session::, and lance-core's cache:: modules pass unmodified (34 + 11 + 22 tests respectively).
  • cargo check, cargo fmt --check, and cargo clippy -- -D warnings are clean on lance and lance-core, run under the toolchain pinned in rust-toolchain.toml (1.94.0) to match CI exactly.

Summary by CodeRabbit

  • New Features
    • Added cache invalidation by key prefix, evicting exact matches and key_prefix/-prefixed variants while avoiding unrelated shorter numeric-overlap prefixes.
  • Bug Fixes
    • Dataset cleanup now evicts stale session metadata and index cache entries only after successful file deletion (not during explain), preventing leftover cached data.
  • Tests
    • Added coverage for key-prefix eviction semantics and stale-version cleanup cache reduction.
  • Refactor
    • Extended cache backends with a multi-prefix invalidation capability (with safe no-op defaults where not supported).

merge_insert()/add() commits insert new entries into the session's
metadata/index caches keyed by dataset version (ManifestKey, TransactionKey,
RowAddrMaskKey, RowIdIndexKey, IndexMetadataKey), but nothing in the write,
commit, or cleanup paths ever evicted them. A long-running writer sharing one
Session across many commits saw these caches grow roughly linearly with
commit count -- looking exactly like a leak from the outside, even though
every entry was legitimately "live" (referenced) cache data, bounded only by
DEFAULT_METADATA_CACHE_SIZE/DEFAULT_INDEX_CACHE_SIZE (1 GiB each), a ceiling
often above a container's actual memory limit.

CleanupTask::run() now invalidates the metadata/index cache entries for every
manifest version it physically deletes, once deletion actually succeeds (not
during a side-effect-free `explain`). This engages on every path that calls
cleanup, including optimize()'s auto-cleanup hook and explicit
cleanup_old_versions(), which is exactly the maintenance cadence Lance
recommends for long-running upsert workloads.

The existing invalidate_prefix only matches an entry's namespace `prefix`
field (a dataset URI), never its per-entry `key` field where version numbers
live, so it can't evict a single version's entries. Added
invalidate_key_prefix (backend trait + Moka impl + LanceCache wrapper) that
matches on `key` with a `/`-boundary check, so evicting version 5 doesn't
also evict version 50, and covers every optional suffix a key type may carry
(e.g. a manifest's e_tag) without needing to know it in advance.

Scoped to version-level cache entries for this PR; per-fragment entries
(DeletionFileKey, RowIdSequenceKey) are a smaller, documented follow-up.

See lance-format#1983 for the investigation
(jemalloc-instrumented reproduction showing this is a genuine live-heap
accumulation, not allocator retention or on-disk version bloat).
@github-actions github-actions Bot added the bug Something isn't working label Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0dfb82ee-8ab0-48c5-80ec-dd7e9c052502

📥 Commits

Reviewing files that changed from the base of the PR and between 2d2075d and 8fc07fb.

📒 Files selected for processing (2)
  • rust/lance-core/src/cache/backend.rs
  • rust/lance/src/dataset/cleanup.rs
 _________________________________________________
< PhD, MSc, BSc, and a black belt in code review. >
 -------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
📝 Walkthrough

Walkthrough

Cleanup now supports key-prefix cache invalidation through the cache backend and Moka implementation. Dataset cleanup tracks deleted manifest versions and evicts related metadata and index entries after successful execution, with regression tests covering matching and stale-version removal.

Changes

Cache eviction and cleanup

Layer / File(s) Summary
Key-prefix invalidation API
rust/lance-core/src/cache/backend.rs, rust/lance-core/src/cache/mod.rs, rust/lance-core/src/cache/moka.rs
Adds cache trait hooks, a public forwarding API, namespace-scoped Moka filtering, and tests for exact, slash-delimited, numeric-boundary, and batched key-prefix matching.
Cleanup stale-version eviction
rust/lance/src/dataset/cleanup.rs
Tracks unreachable versions, deletes stale files, and evicts their metadata and index cache entries in execute mode; adds regression coverage using a shared session.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CleanupTask
  participant LanceCache
  participant MokaCacheBackend
  CleanupTask->>CleanupTask: identify unreachable manifest versions
  CleanupTask->>CleanupTask: delete stale files
  CleanupTask->>LanceCache: invalidate stale version key prefixes
  LanceCache->>MokaCacheBackend: evict matching namespace entries
  MokaCacheBackend-->>CleanupTask: complete eviction
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: cleanup now evicts version-scoped cache entries after deleting their files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance-core/src/cache/mod.rs`:
- Around line 240-255: Add a runnable Rustdoc # Examples block to
LanceCache::invalidate_key_prefix, following the nearby keys() documentation
style. Include the necessary imports and a minimal CacheKey implementation, then
demonstrate inserting an entry with insert_with_key, invalidating it via
invalidate_key_prefix, and asserting it is absent with get_with_key.

In `@rust/lance-core/src/cache/moka.rs`:
- Around line 132-141: Hoist the key-prefix formatting in invalidate_key_prefix
by creating a single "{key_prefix}/" String before invalidate_entries_if, then
have the per-entry closure reuse it for starts_with checks instead of calling
format! for each entry. Verify this behavior against the installed moka version,
where the predicate may run per cached entry and be invoked repeatedly during
retrieval processing.

In `@rust/lance/src/dataset/cleanup.rs`:
- Around line 511-546: Optimize invalidate_stale_version_caches by batching
invalidation work instead of issuing five sequential invalidate_key_prefix calls
per stale version. Build combined predicates or use a batched invalidation API,
matching stale versions across manifest, transaction, row address mask, row ID
index, and index cache keys while preserving the existing cache scopes and
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d2b32b86-b11a-492b-b89e-61dc8ef8f037

📥 Commits

Reviewing files that changed from the base of the PR and between c5c3bf1 and d96fcb8.

📒 Files selected for processing (4)
  • rust/lance-core/src/cache/backend.rs
  • rust/lance-core/src/cache/mod.rs
  • rust/lance-core/src/cache/moka.rs
  • rust/lance/src/dataset/cleanup.rs

Comment thread rust/lance-core/src/cache/mod.rs
Comment thread rust/lance-core/src/cache/moka.rs Outdated
Comment thread rust/lance/src/dataset/cleanup.rs
- Add a runnable # Examples block to LanceCache::invalidate_key_prefix,
  matching this file's convention for public API docs.
- Add invalidate_key_prefixes (plural): invalidates entries matching any of
  several key prefixes in one pass over the cache, instead of one pass per
  prefix. invalidate_key_prefix now defaults to a single-element call to it,
  which also hoists the "{key_prefix}/" allocation out of the per-entry
  closure (it was being rebuilt on every entry scanned).
- CleanupTask::invalidate_stale_version_caches now calls
  invalidate_key_prefixes twice total (once per cache) instead of 5 times
  per stale version, so a cleanup run clearing many versions at once stays
  cheap.

Addresses CodeRabbit review comments on lance-format#7718.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance-core/src/cache/backend.rs`:
- Around line 137-159: Clarify the documentation for invalidate_key_prefix and
invalidate_key_prefixes to state that implementing invalidate_key_prefixes is
sufficient because invalidate_key_prefix forwards to it, while implementing only
invalidate_key_prefix leaves the batched method as a no-op. Replace the
misleading “implement one of the two” guidance with explicit advice to override
invalidate_key_prefixes for backend support.

In `@rust/lance/src/dataset/cleanup.rs`:
- Around line 522-554: Add regression coverage for the index-cache invalidation
path in cleanup_evicts_stale_version_caches (or a sibling test): create and
populate an index before appending and cleaning stale versions, then assert via
session.index_cache_stats() or index_cache_keys() that entries for the stale
version are removed. Ensure the assertions validate the actual index-cache key
format and that cleanup reduces/removes stale index entries, exercising
invalidate_stale_version_caches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7f4fbbda-2232-493e-a8ef-be9cd469a525

📥 Commits

Reviewing files that changed from the base of the PR and between d96fcb8 and 2d2075d.

📒 Files selected for processing (4)
  • rust/lance-core/src/cache/backend.rs
  • rust/lance-core/src/cache/mod.rs
  • rust/lance-core/src/cache/moka.rs
  • rust/lance/src/dataset/cleanup.rs

Comment thread rust/lance-core/src/cache/backend.rs
Comment thread rust/lance/src/dataset/cleanup.rs
- Clarify invalidate_key_prefix's doc comment: default-forwarding to
  invalidate_key_prefixes only works in one direction. Overriding
  invalidate_key_prefixes gives both methods; overriding only
  invalidate_key_prefix leaves invalidate_key_prefixes's no-op default in
  place for callers that invoke it directly (as LanceCache::
  invalidate_key_prefixes and cleanup.rs do). The old "implement one of the
  two" wording implied a symmetry that doesn't exist.

- cleanup_evicts_stale_version_caches previously never created an index, so
  the index_cache.invalidate_key_prefixes call ran against an empty cache
  with no way to catch a key-format mismatch. Now builds a real vector
  index and calls optimize_indices() partway through the write sequence,
  so the index cache actually accumulates version-keyed IndexMetadataKey
  entries the same way production code populates them, and asserts both
  caches shrink with no stale entries left in either.

Verified the strengthened test catches the regression it's meant to catch:
temporarily disabled the index_cache invalidation call, confirmed the test
fails (before/after num_entries unchanged), restored the fix.

Addresses two more CodeRabbit review comments on lance-format#7718's second review pass.

@wjones127 wjones127 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think addressing this is a good idea, but I think it needs to be adapted to handle the new scheme for keys. Perhaps we can have a list of CacheKeys that we know we can drop and invalidate those one-by-one?

Comment on lines +147 to +150
async fn invalidate_key_prefix(&self, prefix: &str, key_prefix: &str) {
self.invalidate_key_prefixes(prefix, std::slice::from_ref(&key_prefix.to_owned()))
.await;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): we are planning on removing these APIs, since the new style of keys (the 128-bit opaque keys) no longer are strings with prefixes. Is there a way where we could avoid relying on prefixes but still invalidate known keys?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants