Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import time
import warnings
from collections.abc import Iterable
from typing import TYPE_CHECKING
from typing import Any
Expand Down Expand Up @@ -366,16 +367,39 @@ 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

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
Expand Down
55 changes: 52 additions & 3 deletions tests/backends/test_redis.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import sys
import time
import warnings
from collections.abc import AsyncGenerator
from typing import Any

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down
Loading