diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d320da..dcb9bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,21 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. the full entries on the documentation site. The changelog itself keeps the details. +### Fixed + +- **Redis `clear_pattern()` no longer strips a pattern that starts with the key + prefix.** The pattern now always matches the logical key, as on the memory + backend. With `key_prefix="cache:"` and the default `CacheManager`, whose + keys also start with `cache:`, `clear_pattern("user:*")` used to clear + nothing. ([#109](https://github.com/allen0099/FastAPI-CacheX/issues/109)) + +### Deprecated + +- **Passing the Redis key prefix in a `clear_pattern()` pattern.** When such a + pattern clears nothing, the prefix-stripped form is still tried and emits a + `DeprecationWarning` if it clears anything. The retry will be removed in + 0.4.0. ([#125](https://github.com/allen0099/FastAPI-CacheX/issues/125)) + ## [0.3.7] - 2026-09-25 ### Added diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index e7a12ed..028c4fe 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -53,6 +53,10 @@ BackendProxy.set(backend) - Only the pattern you pass to `clear_pattern()` is a glob. The key prefix and the path given to `clear_path()` are matched literally, so `*`, `?`, `[` or `]` in them cannot reach keys outside the prefix or miss the path +- `clear_pattern()` matches the logical key, the key without the backend prefix, and + always adds the prefix itself. Before 0.3.8 a pattern that started with the prefix + was matched with the prefix stripped. That form still works when it is the only one + that matches anything, with a `DeprecationWarning`, until 0.4.0 **Configuring from a model**: `RedisConfig` is a pydantic model with the same settings and validation, which is handy when they come from environment diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 5f79c16..23ead90 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -2,6 +2,7 @@ import logging import time +import warnings from collections.abc import Iterable from typing import TYPE_CHECKING from typing import Any @@ -366,7 +367,13 @@ async def clear_pattern(self, pattern: str) -> int: """Clear cached responses matching a pattern. Only ``pattern`` is a live glob; the backend's key prefix is matched - literally, whether or not ``pattern`` repeats it. + literally and always added, so ``pattern`` matches the logical key like + on every other backend. + + Before 0.3.8 a pattern that started with the key prefix was matched + with the prefix stripped instead. When a pattern like that clears + nothing, the old form is still tried, and a ``DeprecationWarning`` is + emitted if it clears anything. That retry will be removed in 0.4.0. Args: pattern: A glob pattern to match cache keys against @@ -374,8 +381,25 @@ async def clear_pattern(self, pattern: str) -> int: Returns: Number of cache entries cleared """ - full_pattern = self._prefix_pattern + pattern.removeprefix(self.key_prefix) + full_pattern = self._prefix_pattern + pattern cleared_count = await self._delete_keys(await self._scan_keys(full_pattern)) + if ( + cleared_count == 0 + and self.key_prefix + and pattern.startswith(self.key_prefix) + ): + full_pattern = self._prefix_pattern + pattern.removeprefix(self.key_prefix) + cleared_count = await self._delete_keys(await self._scan_keys(full_pattern)) + if cleared_count: + warnings.warn( + f"clear_pattern({pattern!r}) matched only with the backend's " + f"key prefix {self.key_prefix!r} stripped. Patterns match the " + "logical key, without the backend prefix; pass " + f"{pattern.removeprefix(self.key_prefix)!r} instead. The " + "stripped retry will be removed in version 0.4.0.", + DeprecationWarning, + stacklevel=2, + ) warn_if_path_shaped(pattern, cleared_count) logger.debug( "Redis CLEAR_PATTERN; pattern=%s removed=%s", full_pattern, cleared_count diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index db67bcd..8a3b703 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -1,6 +1,7 @@ import asyncio import sys import time +import warnings from collections.abc import AsyncGenerator from typing import Any @@ -11,6 +12,7 @@ from fastapi_cachex.backends.redis import _BATCH_SIZE from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.lock import CacheLock +from fastapi_cachex.manager import CacheManager from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry from tests.live_servers import REDIS_HOST @@ -357,18 +359,64 @@ async def test_redis_clear_pattern_no_matches( async def test_redis_clear_pattern_with_prefixed_pattern( async_redis_backend: AsyncRedisCacheBackend, ): - """Cover branch where provided pattern already includes key prefix.""" + """A pattern that repeats the key prefix still clears, with a deprecation (#109).""" value = CacheEntry(fingerprint="test-etag", content=b"test-content") await async_redis_backend.set("/api/users/1", value) await async_redis_backend.set("/api/users/2", value) prefixed = f"{async_redis_backend.key_prefix}/api/users/*" - cleared = await async_redis_backend.clear_pattern(prefixed) + with pytest.warns(DeprecationWarning, match=r"'/api/users/\*' instead"): + cleared = await async_redis_backend.clear_pattern(prefixed) assert cleared == 2 assert await async_redis_backend.get("/api/users/1") is None assert await async_redis_backend.get("/api/users/2") is None +@requires_redis +@pytest.mark.asyncio +async def test_redis_clear_pattern_prefixes_a_pattern_that_starts_with_the_prefix( + async_redis_backend: AsyncRedisCacheBackend, +): + """A logical key may start with the backend prefix; it is not stripped (#109).""" + value = CacheEntry(fingerprint="test-etag", content=b"test-content") + prefix = async_redis_backend.key_prefix + await async_redis_backend.set(f"{prefix}user:1", value) + await async_redis_backend.set("user:1", value) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + cleared = await async_redis_backend.clear_pattern(f"{prefix}user:*") + + assert cleared == 1 + assert await async_redis_backend.get(f"{prefix}user:1") is None + assert await async_redis_backend.get("user:1") == value + + +@pytest.mark.asyncio +async def test_redis_cache_manager_clear_pattern_with_matching_prefixes() -> None: + """The #109 reproduction: backend and CacheManager both use ``cache:``.""" + reason = redis_skip_reason() + if reason is not None: + pytest.skip(reason) + + backend = AsyncRedisCacheBackend( + host=REDIS_HOST, port=REDIS_PORT, key_prefix="cache:" + ) + manager = CacheManager(backend=backend) + try: + await manager.set("user:1", {"name": "a"}) + await manager.set("post:1", {"name": "b"}) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert await manager.clear_pattern("user:*") == 1 + + assert await manager.get("user:1") is None + assert await manager.get("post:1") == {"name": "b"} + finally: + await backend.clear() + + @requires_redis @pytest.mark.asyncio async def test_redis_clear_path_exact_without_params( @@ -1009,7 +1057,8 @@ def make(prefix: str) -> AsyncRedisCacheBackend: assert await globbed.get_all_keys() == ["mine"] assert await globbed.clear_pattern("*") == 1 await globbed.set("mine", entry) - assert await globbed.clear_pattern("cachex-test?*:*") == 1 + with pytest.warns(DeprecationWarning, match="key prefix"): + assert await globbed.clear_pattern("cachex-test?*:*") == 1 await globbed.set("mine", entry) await globbed.clear()