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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
now raises `ValueError`.
([#180](https://github.com/allen0099/FastAPI-CacheX/issues/180))

- **Redis `get_all_keys()` no longer lists a key twice.** SCAN may return a
key more than once when the keyspace shrinks during the iteration, and the
duplicates were passed through to `get_all_keys()`, `CacheManager` and the
monitoring routes. Scanned keys are now deduplicated.
([#173](https://github.com/allen0099/FastAPI-CacheX/issues/173))

## [0.3.7] - 2026-09-25

### Added
Expand Down
10 changes: 6 additions & 4 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,19 @@ def _prefix_pattern(self) -> str:
async def _scan_keys(self, pattern: str) -> list[str]:
"""Collect every key matching ``pattern`` (a full, prefixed glob).

Uses SCAN instead of KEYS so the server is never blocked.
Uses SCAN instead of KEYS so the server is never blocked. SCAN may
return a key more than once (when the keyspace shrinks mid-iteration),
so the result is deduplicated, keeping the order keys were first seen.
"""
cursor = 0
keys: list[str] = []
keys: dict[str, None] = {}
while True:
cursor, page = await self.client.scan(
cursor, match=pattern, count=_BATCH_SIZE
)
keys.extend(page)
keys.update(dict.fromkeys(page))
if cursor == 0:
return keys
return list(keys)

async def _delete_keys(self, keys: list[str]) -> int:
"""Delete prefixed keys in batches; returns how many existed."""
Expand Down
34 changes: 34 additions & 0 deletions tests/backends/test_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,40 @@ async def test_redis_scan_walks_every_page(
assert await async_redis_backend.get_all_keys() == []


@requires_redis
@pytest.mark.asyncio
async def test_redis_scan_results_are_deduplicated(
async_redis_backend: AsyncRedisCacheBackend,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""SCAN may return a key twice when the keyspace shrinks mid-scan (#173).

That only happens under a concurrent resize, so the client here repeats
the first key it returned on every later page.
"""
total = _BATCH_SIZE * 3
for index in range(total):
await async_redis_backend.set(
f"dup|||localhost|||/item/{index}|||",
CacheEntry(fingerprint="e", content=b"v"),
)

real_scan = async_redis_backend.client.scan
returned: list[str] = []

async def scan_with_repeats(*args: Any, **kwargs: Any) -> tuple[int, list[str]]:
cursor, page = await real_scan(*args, **kwargs)
page = [*page, *returned[:1]]
returned.extend(page)
return cursor, page

monkeypatch.setattr(async_redis_backend.client, "scan", scan_with_repeats)

keys = await async_redis_backend.get_all_keys()
assert len(returned) > total # the repeat was actually injected
assert len(keys) == len(set(keys)) == total


@requires_redis
@pytest.mark.asyncio
async def test_redis_get_cache_data_reports_absolute_expiry(
Expand Down
Loading