From 168d154130657152f1cab7aae976e1eb4aab834a Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sat, 26 Sep 2026 10:58:59 +0000 Subject: [PATCH] fix(redis): always prefix clear_pattern patterns that start with the key prefix clear_pattern stripped the backend key prefix from a pattern that started with it, so a logical key that itself starts with the prefix could not be matched. With key_prefix="cache:" and the default CacheManager, whose keys also start with "cache:", CacheManager.clear_pattern("user:*") cleared nothing. The pattern now always matches the logical key, as on the memory backend. When it clears nothing and starts with the prefix, the old stripped form is still tried and emits a DeprecationWarning if it clears anything; #125 removes that retry in 0.4.0. Closes #109 --- CHANGELOG.md | 15 +++++++++ docs/BACKENDS.md | 4 +++ fastapi_cachex/backends/redis.py | 28 ++++++++++++++-- tests/backends/test_redis.py | 55 ++++++++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 5 deletions(-) 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()