diff --git a/docs/concepts/caching.md b/docs/concepts/caching.md index 2328cfb..097a3bd 100644 --- a/docs/concepts/caching.md +++ b/docs/concepts/caching.md @@ -123,6 +123,34 @@ agent = Agent( | **RedisVL** | You already run Redis, want local embeddings, need full control over cache index schema. | | **LangCache** | You want a managed service with no infrastructure, server-side embeddings, and built-in analytics. | +## Entry IDs and Targeted Invalidation + +Both providers expose a stable identifier for each cache entry, so an application-level coordinator can retire exactly one stale entry without a semantic re-search and without clearing unrelated entries: + +- `provider.check(prompt)` returns a `CacheEntry` whose `entry_id` field identifies the matched entry. +- `provider.store(prompt, response)` returns the identifier of the newly written entry. +- `provider.delete_by_id(entry_id)` deletes exactly that entry. Unlike `provider.clear()`, which removes every entry in the cache, `delete_by_id` targets a single entry. + +```python +entry_id = await provider.store("What is our refund policy?", "30 days.") + +hit = await provider.check("What's the refund policy?") +if hit is not None: + print(hit.entry_id) # same entry + +# The source document changed; retire only this entry. +await provider.delete_by_id(entry_id) +``` + +ID availability per backend: + +| Provider | `entry_id` value | +|----------|------------------| +| **LangCache** | The managed LangCache entry ID. | +| **RedisVL** | The RedisVL cache entry ID. | + +For a given provider instance, identifiers returned by `check()` and `store()` are interchangeable inputs to `delete_by_id()`. `LLMResponseCache` does not use targeted invalidation itself; the simple read-through callback path is unchanged. + ## Configuration Options | Option | Provider | Default | Description | diff --git a/src/adk_redis/cache/_provider.py b/src/adk_redis/cache/_provider.py index 1dc741c..acca216 100644 --- a/src/adk_redis/cache/_provider.py +++ b/src/adk_redis/cache/_provider.py @@ -32,20 +32,53 @@ @dataclass class CacheEntry: - """Represents a cached entry.""" + """Represents a cached entry. + + Attributes: + prompt: The prompt text that was matched. + response: The cached response text. + distance: The vector distance of the match, if available. + metadata: Optional metadata stored alongside the entry. + entry_id: Stable backend identifier for the matched entry, if the + backend provides one. For LangCacheProvider this is the LangCache + entry ID; for RedisVLCacheProvider this is the RedisVL entry ID. + It can be passed to delete_by_id() to retire exactly + this entry. None when the backend does not expose an identifier. + """ prompt: str response: str distance: Optional[float] = None metadata: Optional[dict[str, Any]] = None + entry_id: Optional[str] = None class BaseCacheProvider(ABC): - """Abstract base class for cache providers.""" + """Abstract base class for cache providers. + + Entry identity contract: + Providers surface a stable per-entry identifier when the backend + exposes one. check() populates CacheEntry.entry_id on hits and + store() returns the identifier of the newly written entry. Both + LangCacheProvider and RedisVLCacheProvider provide backend entry IDs; + a backend that cannot provide an identifier must + leave CacheEntry.entry_id as None and return None from store(). + Identifiers from check() and store() are interchangeable inputs to + delete_by_id() for the same provider instance. + """ @abstractmethod async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: - """Check if a semantically similar prompt exists in the cache.""" + """Check if a semantically similar prompt exists in the cache. + + Args: + prompt: The prompt to look up. + **kwargs: Provider-specific options. + + Returns: + A CacheEntry on a hit (with entry_id populated when the backend + provides one), None on a miss. + """ pass @abstractmethod @@ -55,13 +88,43 @@ async def store( response: str, metadata: Optional[dict[str, Any]] = None, **kwargs: Any, - ) -> None: - """Store a prompt-response pair in the cache.""" + ) -> Optional[str]: + """Store a prompt-response pair in the cache. + + Args: + prompt: The prompt text. + response: The response text. + metadata: Optional metadata to store alongside the entry. + **kwargs: Provider-specific options. + + Returns: + The identifier of the newly written entry when the backend + provides one, otherwise None. The identifier can be passed to + delete_by_id() to retire exactly this entry. + """ + pass + + @abstractmethod + async def delete_by_id(self, entry_id: str, **kwargs: Any) -> None: + """Delete exactly one entry from the cache by its identifier. + + Unlike clear(), which removes every entry in the cache, this targets + a single entry, identified by the entry_id obtained from check() or + store(). Deleting an identifier that no longer exists is a no-op. + + Args: + entry_id: The identifier of the entry to delete, as returned by + check() (CacheEntry.entry_id) or store() on this provider. + **kwargs: Provider-specific options. + """ pass @abstractmethod async def clear(self, **kwargs: Any) -> None: - """Clear all entries from the cache.""" + """Clear all entries from the cache. + + For removing a single entry, use delete_by_id() instead. + """ pass @abstractmethod @@ -147,7 +210,16 @@ def __init__(self, config: RedisVLCacheProviderConfig, vectorizer: Any): ) async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: - """Check for a semantically similar prompt in the cache.""" + """Check for a semantically similar prompt in the cache. + + Args: + prompt: The prompt to look up. + **kwargs: Additional keyword arguments (unused). + + Returns: + A CacheEntry on a hit, None on a miss. entry_id is the RedisVL entry + ID of the matched entry and can be passed to delete_by_id(). + """ result = await asyncio.to_thread(self._cache.check, prompt=prompt) if result: logger.debug("Cache hit for prompt: %s", prompt[:50]) @@ -155,6 +227,7 @@ async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: prompt=prompt, response=result[0]["response"], distance=result[0].get("vector_distance"), + entry_id=result[0].get("entry_id"), ) logger.debug("Cache miss for prompt: %s", prompt[:50]) return None @@ -165,13 +238,45 @@ async def store( response: str, metadata: Optional[dict[str, Any]] = None, **kwargs: Any, - ) -> None: - """Store a prompt-response pair in the cache.""" - await asyncio.to_thread(self._cache.store, prompt=prompt, response=response) + ) -> Optional[str]: + """Store a prompt-response pair in the cache. + + Args: + prompt: The prompt text. + response: The response text. + metadata: Optional metadata (unused by this provider). + **kwargs: Additional keyword arguments (unused). + + Returns: + The RedisVL entry ID of the newly written entry. It can be passed to + delete_by_id() to retire exactly this entry. + """ + key: str = await asyncio.to_thread( + self._cache.store, prompt=prompt, response=response + ) + key_prefix = f"{self._config.name}:" + entry_id = key.removeprefix(key_prefix) logger.debug("Stored response for prompt: %s", prompt[:50]) + return entry_id + + async def delete_by_id(self, entry_id: str, **kwargs: Any) -> None: + """Delete exactly one cache entry by its RedisVL entry ID. + + Unlike clear(), which removes every entry, this drops only the entry + identified by the RedisVL entry ID obtained from check() or store(). + + Args: + entry_id: The RedisVL entry ID of the entry to delete. + **kwargs: Additional keyword arguments (unused). + """ + await asyncio.to_thread(self._cache.drop, ids=[entry_id]) + logger.debug("Dropped cache entry: %s", entry_id) async def clear(self, **kwargs: Any) -> None: - """Clear all entries from the cache.""" + """Clear all entries from the cache. + + For removing a single entry, use delete_by_id() instead. + """ await asyncio.to_thread(self._cache.clear) logger.info("Cache cleared") @@ -187,7 +292,7 @@ class LangCacheProvider(BaseCacheProvider): LangCache is a managed semantic caching service that handles embedding generation, storage, and retrieval. Unlike RedisVLCacheProvider, it does - not require a local vectorizer — embeddings are handled server-side. + not require a local vectorizer; embeddings are handled server-side. Requires redisvl>=0.11.1 with LangCache support. """ @@ -230,7 +335,9 @@ async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: **kwargs: Additional keyword arguments (e.g., distance_threshold). Returns: - A CacheEntry if a cache hit is found, None otherwise. + A CacheEntry if a cache hit is found, None otherwise. entry_id is + the LangCache entry ID of the matched entry and can be passed to + delete_by_id(). """ distance_threshold = kwargs.get( "distance_threshold", self._config.distance_threshold @@ -246,6 +353,7 @@ async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: response=result[0]["response"], distance=result[0].get("vector_distance"), metadata=result[0].get("metadata"), + entry_id=result[0].get("entry_id"), ) logger.debug("LangCache miss for prompt: %s", prompt[:50]) return None @@ -256,7 +364,7 @@ async def store( response: str, metadata: Optional[dict[str, Any]] = None, **kwargs: Any, - ) -> None: + ) -> Optional[str]: """Store a prompt-response pair in LangCache. Args: @@ -264,6 +372,10 @@ async def store( response: The response text. metadata: Optional metadata to store alongside the entry. **kwargs: Additional keyword arguments (e.g., ttl). + + Returns: + The LangCache entry ID of the newly written entry. It can be + passed to delete_by_id() to retire exactly this entry. """ astore_kwargs: dict[str, Any] = { "prompt": prompt, @@ -274,11 +386,29 @@ async def store( ttl = kwargs.get("ttl") if ttl is not None: astore_kwargs["ttl"] = ttl - await self._cache.astore(**astore_kwargs) + entry_id = await self._cache.astore(**astore_kwargs) logger.debug("LangCache stored response for prompt: %s", prompt[:50]) + return entry_id or None + + async def delete_by_id(self, entry_id: str, **kwargs: Any) -> None: + """Delete exactly one LangCache entry by its entry ID. + + Unlike clear(), which removes every entry, this deletes only the + entry identified by the LangCache entry ID obtained from check() + or store(). + + Args: + entry_id: The LangCache entry ID of the entry to delete. + **kwargs: Additional keyword arguments (unused). + """ + await self._cache.adelete_by_id(entry_id) + logger.debug("LangCache deleted entry: %s", entry_id) async def clear(self, **kwargs: Any) -> None: - """Clear all entries from the LangCache.""" + """Clear all entries from the LangCache. + + For removing a single entry, use delete_by_id() instead. + """ await self._cache.aclear() logger.info("LangCache cleared") diff --git a/tests/cache/test_provider.py b/tests/cache/test_provider.py index 4d20f3d..06412df 100644 --- a/tests/cache/test_provider.py +++ b/tests/cache/test_provider.py @@ -16,6 +16,8 @@ from __future__ import annotations +from typing import Any, Optional +from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import warnings @@ -24,6 +26,10 @@ pytest.importorskip("redisvl") +from adk_redis.cache._provider import BaseCacheProvider +from adk_redis.cache._provider import CacheEntry +from adk_redis.cache._provider import LangCacheProvider +from adk_redis.cache._provider import LangCacheProviderConfig from adk_redis.cache._provider import RedisVLCacheProvider from adk_redis.cache._provider import RedisVLCacheProviderConfig @@ -77,3 +83,185 @@ def test_construction_passes_expected_kwargs(self, mock_vectorizer): assert kwargs["distance_threshold"] == 0.2 assert kwargs["vectorizer"] is mock_vectorizer assert kwargs["overwrite"] is True + + +class TestBaseCacheProviderContract: + """The abstract provider contract includes targeted invalidation.""" + + def test_delete_by_id_is_abstract(self): + """A subclass missing delete_by_id cannot be instantiated.""" + + class IncompleteProvider(BaseCacheProvider): + + async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: + return None + + async def store( + self, + prompt: str, + response: str, + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Optional[str]: + return None + + async def clear(self, **kwargs: Any) -> None: + pass + + async def close(self) -> None: + pass + + with pytest.raises(TypeError, match="delete_by_id"): + IncompleteProvider() + + +def _make_langcache_provider() -> tuple[LangCacheProvider, MagicMock]: + """Build a LangCacheProvider around a mocked LangCacheSemanticCache.""" + config = LangCacheProviderConfig(cache_id="cache-1", api_key="secret") + with patch("redisvl.extensions.cache.llm.LangCacheSemanticCache") as mock_cls: + mock_cache = MagicMock() + mock_cache.acheck = AsyncMock(return_value=[]) + mock_cache.astore = AsyncMock(return_value="") + mock_cache.adelete_by_id = AsyncMock(return_value=None) + mock_cls.return_value = mock_cache + provider = LangCacheProvider(config) + return provider, mock_cache + + +@pytest.mark.asyncio +class TestLangCacheProviderEntryIds: + """LangCacheProvider surfaces entry IDs and targeted invalidation.""" + + async def test_check_surfaces_entry_id(self): + """check() preserves the LangCache hit's entry_id on CacheEntry.""" + provider, mock_cache = _make_langcache_provider() + mock_cache.acheck.return_value = [ + { + "entry_id": "entry-abc", + "prompt": "What is Redis?", + "response": "An in-memory data store.", + "vector_distance": 0.05, + "metadata": {"source": "docs"}, + } + ] + + entry = await provider.check("What is Redis?") + + assert entry is not None + assert entry.entry_id == "entry-abc" + assert entry.response == "An in-memory data store." + assert entry.metadata == {"source": "docs"} + + async def test_store_returns_entry_id(self): + """store() returns the entry ID reported by the backend.""" + provider, mock_cache = _make_langcache_provider() + mock_cache.astore.return_value = "entry-new" + + entry_id = await provider.store("prompt", "response") + + assert entry_id == "entry-new" + + async def test_delete_by_id_calls_backend_with_exact_id(self): + """delete_by_id() forwards the exact entry ID to the backend.""" + provider, mock_cache = _make_langcache_provider() + + await provider.delete_by_id("entry-abc") + + mock_cache.adelete_by_id.assert_awaited_once_with("entry-abc") + + async def test_delete_one_of_two_similar_entries_leaves_other_intact(self): + """Deleting one returned ID retires only that entry (issue #22).""" + provider, mock_cache = _make_langcache_provider() + + entries: dict[str, dict[str, Any]] = {} + deleted_ids: list[str] = [] + + async def fake_astore(prompt, response, metadata=None, **kwargs): + entry_id = f"entry-{len(entries) + 1}" + entries[entry_id] = { + "entry_id": entry_id, + "prompt": prompt, + "response": response, + "vector_distance": 0.0, + "metadata": metadata, + } + return entry_id + + async def fake_acheck(prompt, **kwargs): + return [hit for hit in entries.values() if hit["prompt"] == prompt] + + async def fake_adelete_by_id(entry_id): + deleted_ids.append(entry_id) + entries.pop(entry_id, None) + + mock_cache.astore.side_effect = fake_astore + mock_cache.acheck.side_effect = fake_acheck + mock_cache.adelete_by_id.side_effect = fake_adelete_by_id + + stale_id = await provider.store( + "What is our refund policy?", "30 days, policy doc v12." + ) + fresh_id = await provider.store( + "What's the refund policy?", "60 days, policy doc v13." + ) + assert stale_id != fresh_id + + await provider.delete_by_id(stale_id) + + assert deleted_ids == [stale_id] + assert await provider.check("What is our refund policy?") is None + survivor = await provider.check("What's the refund policy?") + assert survivor is not None + assert survivor.entry_id == fresh_id + assert survivor.response == "60 days, policy doc v13." + + +def _make_redisvl_provider( + mock_vectorizer: MagicMock, +) -> tuple[RedisVLCacheProvider, MagicMock]: + """Build a RedisVLCacheProvider around a mocked SemanticCache.""" + config = RedisVLCacheProviderConfig(name="test_cache") + with patch("redisvl.extensions.cache.llm.SemanticCache") as mock_cls: + mock_cache = MagicMock() + mock_cls.return_value = mock_cache + provider = RedisVLCacheProvider(config, vectorizer=mock_vectorizer) + return provider, mock_cache + + +@pytest.mark.asyncio +class TestRedisVLCacheProviderEntryIds: + """RedisVLCacheProvider surfaces RedisVL entry IDs.""" + + async def test_check_surfaces_entry_id(self, mock_vectorizer): + """check() surfaces the hit's RedisVL entry ID.""" + provider, mock_cache = _make_redisvl_provider(mock_vectorizer) + mock_cache.check.return_value = [ + { + "response": "An in-memory data store.", + "vector_distance": 0.08, + "entry_id": "abc123", + } + ] + + entry = await provider.check("What is Redis?") + + assert entry is not None + assert entry.entry_id == "abc123" + assert entry.response == "An in-memory data store." + + async def test_store_returns_entry_id(self, mock_vectorizer): + """store() normalizes the backend Redis key to its entry ID.""" + provider, mock_cache = _make_redisvl_provider(mock_vectorizer) + mock_cache.store.return_value = "test_cache:abc123" + + entry_id = await provider.store("prompt", "response") + + assert entry_id == "abc123" + + async def test_delete_by_id_drops_exact_entry_id(self, mock_vectorizer): + """delete_by_id() drops exactly the given RedisVL entry ID.""" + provider, mock_cache = _make_redisvl_provider(mock_vectorizer) + + await provider.delete_by_id("abc123") + + mock_cache.drop.assert_called_once_with(ids=["abc123"])