From e68908c6ef98603e020ec47337ae3983d730215f Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Fri, 31 Jul 2026 16:29:43 -0400 Subject: [PATCH 1/3] feat(cache): expose cache entry ids and targeted invalidation Add an optional entry_id field to CacheEntry and a delete_by_id abstract method to BaseCacheProvider so callers can retire exactly one cache entry without a semantic re-search or a full clear(). - LangCacheProvider surfaces the LangCache entry ID on check() hits, returns it from store(), and deletes via adelete_by_id(). - RedisVLCacheProvider surfaces the full Redis key of the matched entry on check() hits, returns it from store(), and deletes via SemanticCache.drop(keys=...). - store() now returns Optional[str] across the provider contract. - Document ID availability per backend and how delete_by_id differs from clear(), in the provider docstrings and the caching concepts page. - Add mock-based tests covering ID surfacing, store return values, targeted deletion, and the ABC contract. Closes #22 --- docs/concepts/caching.md | 28 +++++ src/adk_redis/cache/_provider.py | 160 +++++++++++++++++++++++--- tests/cache/test_provider.py | 187 +++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 16 deletions(-) diff --git a/docs/concepts/caching.md b/docs/concepts/caching.md index 2328cfb..a64c479 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 full Redis key of the cache entry. | + +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..ace9145 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 full Redis key of + the entry. 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 (LangCache entry IDs) and RedisVLCacheProvider + (full Redis keys) provide identifiers; a backend that cannot 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 full Redis + key 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("key"), ) logger.debug("Cache miss for prompt: %s", prompt[:50]) return None @@ -165,13 +238,43 @@ 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 full Redis key 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 + ) logger.debug("Stored response for prompt: %s", prompt[:50]) + return key + + async def delete_by_id(self, entry_id: str, **kwargs: Any) -> None: + """Delete exactly one cache entry by its Redis key. + + Unlike clear(), which removes every entry, this drops only the entry + identified by the full Redis key obtained from check() or store(). + + Args: + entry_id: The full Redis key of the entry to delete. + **kwargs: Additional keyword arguments (unused). + """ + await asyncio.to_thread(self._cache.drop, keys=[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 +290,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 +333,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 +351,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 +362,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 +370,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 +384,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..eef6545 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,184 @@ 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 + + +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 + + +class TestRedisVLCacheProviderEntryIds: + """RedisVLCacheProvider uses full Redis keys as entry IDs.""" + + async def test_check_surfaces_redis_key_as_entry_id(self, mock_vectorizer): + """check() surfaces the hit's Redis key on CacheEntry.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", + "key": "test_cache:abc123", + } + ] + + entry = await provider.check("What is Redis?") + + assert entry is not None + assert entry.entry_id == "test_cache:abc123" + assert entry.response == "An in-memory data store." + + async def test_store_returns_redis_key(self, mock_vectorizer): + """store() returns the Redis key reported by the backend.""" + 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 == "test_cache:abc123" + + async def test_delete_by_id_drops_exact_key(self, mock_vectorizer): + """delete_by_id() drops exactly the given Redis key.""" + provider, mock_cache = _make_redisvl_provider(mock_vectorizer) + + await provider.delete_by_id("test_cache:abc123") + + mock_cache.drop.assert_called_once_with(keys=["test_cache:abc123"]) From 83a0e370d7f2af3d69bc76cfd62c449756b11305 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Fri, 31 Jul 2026 17:00:37 -0400 Subject: [PATCH 2/3] fix(cache): normalize RedisVL entry identifiers --- docs/concepts/caching.md | 2 +- src/adk_redis/cache/_provider.py | 30 ++++++++++++++++-------------- tests/cache/test_provider.py | 23 +++++++++++------------ 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/docs/concepts/caching.md b/docs/concepts/caching.md index a64c479..097a3bd 100644 --- a/docs/concepts/caching.md +++ b/docs/concepts/caching.md @@ -147,7 +147,7 @@ ID availability per backend: | Provider | `entry_id` value | |----------|------------------| | **LangCache** | The managed LangCache entry ID. | -| **RedisVL** | The full Redis key of the cache entry. | +| **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. diff --git a/src/adk_redis/cache/_provider.py b/src/adk_redis/cache/_provider.py index ace9145..acca216 100644 --- a/src/adk_redis/cache/_provider.py +++ b/src/adk_redis/cache/_provider.py @@ -41,8 +41,8 @@ class CacheEntry: 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 full Redis key of - the entry. It can be passed to delete_by_id() to retire exactly + 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. """ @@ -60,8 +60,8 @@ class BaseCacheProvider(ABC): 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 (LangCache entry IDs) and RedisVLCacheProvider - (full Redis keys) provide identifiers; a backend that cannot must + 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. @@ -217,8 +217,8 @@ async def check(self, prompt: str, **kwargs: Any) -> Optional[CacheEntry]: **kwargs: Additional keyword arguments (unused). Returns: - A CacheEntry on a hit, None on a miss. entry_id is the full Redis - key of the matched entry and can be passed to delete_by_id(). + 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: @@ -227,7 +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("key"), + entry_id=result[0].get("entry_id"), ) logger.debug("Cache miss for prompt: %s", prompt[:50]) return None @@ -248,26 +248,28 @@ async def store( **kwargs: Additional keyword arguments (unused). Returns: - The full Redis key of the newly written entry. It can be passed - to delete_by_id() to retire exactly this entry. + 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 key + return entry_id async def delete_by_id(self, entry_id: str, **kwargs: Any) -> None: - """Delete exactly one cache entry by its Redis key. + """Delete exactly one cache entry by its RedisVL entry ID. Unlike clear(), which removes every entry, this drops only the entry - identified by the full Redis key obtained from check() or store(). + identified by the RedisVL entry ID obtained from check() or store(). Args: - entry_id: The full Redis key of the entry to delete. + entry_id: The RedisVL entry ID of the entry to delete. **kwargs: Additional keyword arguments (unused). """ - await asyncio.to_thread(self._cache.drop, keys=[entry_id]) + 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: diff --git a/tests/cache/test_provider.py b/tests/cache/test_provider.py index eef6545..596bdc5 100644 --- a/tests/cache/test_provider.py +++ b/tests/cache/test_provider.py @@ -228,39 +228,38 @@ def _make_redisvl_provider( class TestRedisVLCacheProviderEntryIds: - """RedisVLCacheProvider uses full Redis keys as entry IDs.""" + """RedisVLCacheProvider surfaces RedisVL entry IDs.""" - async def test_check_surfaces_redis_key_as_entry_id(self, mock_vectorizer): - """check() surfaces the hit's Redis key on CacheEntry.entry_id.""" + 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", - "key": "test_cache:abc123", } ] entry = await provider.check("What is Redis?") assert entry is not None - assert entry.entry_id == "test_cache:abc123" + assert entry.entry_id == "abc123" assert entry.response == "An in-memory data store." - async def test_store_returns_redis_key(self, mock_vectorizer): - """store() returns the Redis key reported by the backend.""" + 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 == "test_cache:abc123" + assert entry_id == "abc123" - async def test_delete_by_id_drops_exact_key(self, mock_vectorizer): - """delete_by_id() drops exactly the given Redis key.""" + 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("test_cache:abc123") + await provider.delete_by_id("abc123") - mock_cache.drop.assert_called_once_with(keys=["test_cache:abc123"]) + mock_cache.drop.assert_called_once_with(ids=["abc123"]) From 7cf4b1bdd0f1e2e9f35f0ffff2c7a6b005969728 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Fri, 31 Jul 2026 17:48:23 -0400 Subject: [PATCH 3/3] test(cache): mark async provider cases --- tests/cache/test_provider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/cache/test_provider.py b/tests/cache/test_provider.py index 596bdc5..06412df 100644 --- a/tests/cache/test_provider.py +++ b/tests/cache/test_provider.py @@ -128,6 +128,7 @@ def _make_langcache_provider() -> tuple[LangCacheProvider, MagicMock]: return provider, mock_cache +@pytest.mark.asyncio class TestLangCacheProviderEntryIds: """LangCacheProvider surfaces entry IDs and targeted invalidation.""" @@ -227,6 +228,7 @@ def _make_redisvl_provider( return provider, mock_cache +@pytest.mark.asyncio class TestRedisVLCacheProviderEntryIds: """RedisVLCacheProvider surfaces RedisVL entry IDs."""