Skip to content

chore(caching): isolate semantic cache by tenant scope - #26992

Closed
stuxf wants to merge 2 commits into
BerriAI:mainfrom
stuxf:chore/cache-tenant-isolation
Closed

chore(caching): isolate semantic cache by tenant scope#26992
stuxf wants to merge 2 commits into
BerriAI:mainfrom
stuxf:chore/cache-tenant-isolation

Conversation

@stuxf

@stuxf stuxf commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Relevant issues

n/a — operational hardening.

Pre-Submission checklist

  • I have added tests in `tests/test_litellm/`.
  • My PR passes `make test-unit` on the affected paths.
  • My PR's scope is isolated to tenant scoping in the two semantic-cache backends.
  • I have requested a Greptile review by commenting `@greptileai` and received a Confidence Score of at least 4/5 before requesting a maintainer review.

Type

🐛 Bug Fix

Changes

Both `RedisSemanticCache` and `QdrantSemanticCache` ignored the proxy-injected tenant metadata when storing and retrieving cache entries. The standard cache backends (Redis exact-match, in-memory, S3) get isolation implicitly because team/user/org IDs are part of the request parameters that get hashed into the cache key. The semantic caches retrieve based on prompt embedding similarity, so two callers from different teams could retrieve each other's cached LLM responses by sending semantically similar prompts.

New helper

`litellm/caching/_tenant_scope.py:get_tenant_scope(kwargs) -> Optional[str]` extracts a stable scope identifier from `metadata.user_api_key_team_id`, `user_api_key_user_id`, `user_api_key_org_id` (joined with `|` in canonical order). Returns `None` when no scope is present (master key, direct SDK use) so callers can fall back to the legacy shared pool.

Qdrant — filter-based isolation

  • `set_cache` / `async_set_cache`: added `tenant_scope` to the point payload.
  • `get_cache` / `async_get_cache`: added `query_filter` constraining the search to one scope.
  • Sentinel `""` for no-tenant callers so they keep sharing among themselves but never see tenant entries.

Redis — index-per-tenant isolation

  • Each tenant scope gets its own RedisVL `SemanticCache` instance, lazy-created on first use. The Redis index name embeds a SHA-256 prefix of the scope so it's always a valid identifier.
  • The default `self.llmcache` continues to serve no-tenant callers — no schema migration, no data loss for upgrading deployments.
  • All four sync/async set/get paths route through `_get_cache_for_scope`.

Tests

15 mock-only unit tests in `tests/test_litellm/caching/test_semantic_cache_tenant_isolation.py`:

  • Helper coverage: scope from team-only, combined team+user+org, none, non-dict metadata, empty strings.
  • Qdrant: payload carries `tenant_scope` on store, search filters by exact scope on retrieve, sentinel `""` for no-tenant.
  • Redis: no-tenant uses default index, tenant requests lazy-create scoped index, two tenants get distinct indexes, sync+async set+get all route correctly.

🤖 Generated with Claude Code

Both ``RedisSemanticCache`` and ``QdrantSemanticCache`` ignored the
proxy-injected tenant metadata when storing or retrieving cache
entries. Two callers from different teams could read each other's
cached LLM responses by sending semantically similar prompts —
embedding-only retrieval bypassed the model/team isolation that the
standard cache backends get implicitly via the cache key.

New ``litellm.caching._tenant_scope.get_tenant_scope`` helper extracts
a stable scope identifier from ``user_api_key_team_id``,
``user_api_key_user_id``, ``user_api_key_org_id`` (joined with ``|``
in canonical order). Both semantic backends route storage and
retrieval through this scope.

- Qdrant: ``tenant_scope`` is added to the point payload on store and
  enforced via a ``query_filter`` on retrieve. Sentinel ``""`` for
  callers without proxy metadata so direct-SDK use shares its own
  legacy pool.
- Redis: each tenant scope gets its own ``SemanticCache`` instance
  (lazy-created, hashed index name) so the two backends share no
  schema, no keys, no vector neighborhood. Direct-SDK callers continue
  to use the existing default index — no schema migration, no data
  loss for upgrading deployments.

15 mock-only unit tests cover the helper + both backends across
sync/async set/get for tenant-scoped, no-tenant, and cross-tenant
cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing stuxf:chore/cache-tenant-isolation (b82ab77) with main (934ecdc)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds tenant-scope isolation to RedisSemanticCache and QdrantSemanticCache so that semantically similar prompts from different teams/users no longer produce cross-tenant cache hits. The _tenant_scope.py helper and the per-backend routing logic are well-structured, but two present defects need to be addressed before merging:

  • Qdrant cache invalidated on upgrade: the tenant_scope == "" filter on get_cache only matches points that were stored with that field; all pre-existing points (stored before this PR, which have no tenant_scope field) silently become cache misses for every no-tenant caller.
  • Redis _tenant_caches is unbounded: each unique tenant combination creates a permanent SemanticCache entry holding its own Redis connection pool, and index creation happens synchronously on the first request — a connection-exhaustion and latency risk in high-tenant deployments.

Confidence Score: 3/5

Not safe to merge — two P1 defects: Qdrant upgrade silently invalidates the full existing cache, and Redis _tenant_caches grows unboundedly.

Two distinct P1 findings on separate files bring the score below the P1 ceiling of 4. The Qdrant backward-compat break is a silent, immediate regression for all upgrading deployments; the Redis unbounded connection pool is a latent resource exhaustion. Neither is gated by a feature flag.

litellm/caching/qdrant_semantic_cache.py (sentinel filter breaks existing entries) and litellm/caching/redis_semantic_cache.py (_tenant_caches growth + on-path SemanticCache construction).

Important Files Changed

Filename Overview
litellm/caching/_tenant_scope.py New helper that extracts a stable tenant scope string from proxy-injected metadata; logic is clean, handles edge cases (non-dict metadata, empty strings, absent fields), no issues found.
litellm/caching/qdrant_semantic_cache.py Adds tenant_scope to payload on writes and a must-match filter on reads; P1 issue — the sentinel "" filter silently drops all pre-existing points (which have no tenant_scope field) on upgrade, invalidating the entire existing Qdrant cache for no-tenant callers.
litellm/caching/redis_semantic_cache.py Adds per-tenant lazy SemanticCache instances via _get_cache_for_scope; P1 issue — _tenant_caches is unbounded (one Redis connection pool per unique tenant combination, forever) and the first-use index creation happens synchronously on the critical request path.
tests/test_litellm/caching/test_semantic_cache_tenant_isolation.py 15 mock-only unit tests covering helper, Qdrant, and Redis isolation paths; all mock-based (no real network calls, consistent with repo rules), but no test covers the Qdrant backward-compatibility gap (existing points without tenant_scope).

Reviews (1): Last reviewed commit: "fix(caching): isolate semantic cache by ..." | Re-trigger Greptile

Comment on lines +254 to +256
"filter": _qdrant_tenant_filter(
get_tenant_scope(kwargs) or _NO_TENANT_SCOPE
),

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.

P1 Qdrant upgrade silently drops all pre-existing cache entries

All points stored before this PR have no tenant_scope field in their Qdrant payload. After upgrading, every get_cache call (even with no tenant) applies {"must": [{"key": "tenant_scope", "match": {"value": ""}}]}. Qdrant's match.value filter only returns points where the field exists and has that exact value — points missing the field entirely are excluded. The result is 100% cache miss for all pre-existing no-tenant callers, silently discarding every entry in the collection. The PR description claims "no data loss for upgrading deployments" but this only holds for the Redis backend; there is no analogous fallback path here. A migration step (e.g. bulk-update existing points with tenant_scope: "" via the Qdrant update-vectors API, or a conditional filter that also accepts the field being absent) is needed before the filter can safely be applied.

Rule Used: What: avoid backwards-incompatible changes without... (source)

Comment thread litellm/caching/redis_semantic_cache.py Outdated
Comment on lines +131 to +161
def _get_cache_for_scope(self, tenant_scope: Optional[str]) -> Any:
"""Return the ``SemanticCache`` instance for a tenant scope.

``None`` returns the default index (BC for callers without proxy
metadata). Any non-None scope gets its own RedisVL index, lazily
created on first use so two tenants share no keys, no schema,
and no vector neighborhood.
"""
if tenant_scope is None:
return self.llmcache
cached = self._tenant_caches.get(tenant_scope)
if cached is not None:
return cached
from hashlib import sha256

from redisvl.extensions.llmcache import SemanticCache

# Hash the scope so the Redis index name is always a valid
# identifier (team_ids and user_ids can contain characters that
# RedisVL rejects in index names).
scope_hash = sha256(tenant_scope.encode("utf-8")).hexdigest()[:16]
scoped_name = f"{self._index_name_base}:tenant:{scope_hash}"
scoped = SemanticCache(
name=scoped_name,
redis_url=self._redis_url,
vectorizer=self._cache_vectorizer,
distance_threshold=self.distance_threshold,
overwrite=False,
)
self._tenant_caches[tenant_scope] = scoped
return scoped

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.

P1 Unbounded _tenant_caches dict can exhaust Redis connections

_tenant_caches grows indefinitely — one entry per unique (team_id, user_id, org_id) combination ever seen in the process lifetime. Each SemanticCache instance holds its own RedisVL connection pool. In a multi-tenant proxy with many teams or per-user scoping, this can exhaust Redis max-connections and OOM the proxy process. Additionally, the first request from any new tenant creates the SemanticCache synchronously inside the hot request path (index creation involves a Redis round-trip with overwrite=False), which can introduce latency spikes. An LRU-bounded cache or a fixed-capacity dict would cap resource usage.

Rule Used: What: Avoid creating new database requests or Rout... (source)

Comment thread litellm/caching/redis_semantic_cache.py Outdated
Comment on lines +141 to +160
cached = self._tenant_caches.get(tenant_scope)
if cached is not None:
return cached
from hashlib import sha256

from redisvl.extensions.llmcache import SemanticCache

# Hash the scope so the Redis index name is always a valid
# identifier (team_ids and user_ids can contain characters that
# RedisVL rejects in index names).
scope_hash = sha256(tenant_scope.encode("utf-8")).hexdigest()[:16]
scoped_name = f"{self._index_name_base}:tenant:{scope_hash}"
scoped = SemanticCache(
name=scoped_name,
redis_url=self._redis_url,
vectorizer=self._cache_vectorizer,
distance_threshold=self.distance_threshold,
overwrite=False,
)
self._tenant_caches[tenant_scope] = scoped

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.

P2 Race condition on first request per tenant

Two concurrent requests from the same new tenant can both find self._tenant_caches.get(tenant_scope) returning None, both call SemanticCache(...), and both write to self._tenant_caches[tenant_scope]. The last write wins and neither raises an error, but two separate Redis connection pools are momentarily created per tenant instead of one. Under high concurrency this doubles (or more) the connection spike on the first request for every tenant. A simple lock keyed on tenant_scope, or a check-then-cache pattern, would prevent this.

@codecov

codecov Bot commented May 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/caching/redis_semantic_cache.py 95.23% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Three Greptile findings on the previous commit:

1. (P1, Qdrant) Pre-existing points have no ``tenant_scope`` field, so a
   strict ``match.value=""`` filter silently dropped every entry cached
   before this fix landed — a full cache wipe on upgrade for no-tenant
   callers. Switched the no-tenant filter to ``should`` with both an
   explicit ``""`` match AND ``is_empty`` so legacy points remain
   retrievable.

2. (P1, Redis) ``_tenant_caches`` was an unbounded dict — one ``SemanticCache``
   instance per unique ``(team, user, org)`` ever seen, each holding its
   own RedisVL connection pool. Replaced with an ``OrderedDict``-backed
   LRU capped at 256 entries; eviction is FIFO by last access, so hot
   tenants stay warm.

3. (P2, Redis) Two concurrent first-touches for the same tenant could
   both construct a ``SemanticCache`` and race the dict write. Added
   double-checked locking — the slow path runs the constructor outside
   the lock (so a hot tenant's cache hit isn't blocked by a cold
   tenant's index check), then re-checks inside the lock and discards
   the loser.

Adds three regression tests:
- Qdrant no-tenant filter includes ``is_empty`` clause for legacy points
- Redis LRU evicts oldest, keeps recently-used
- Redis concurrent first-touch produces one instance, not two

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stuxf

stuxf commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #26990, which targets litellm_internal_staging and links VERIA-54 to the active fix PR.

@stuxf stuxf closed this May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant