From 90f41163ad12b8af550f8a194a5600058fe73362 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 08:54:33 +0000 Subject: [PATCH] fix(redis): report each key's expiry to the monitoring routes AsyncRedisCacheBackend.get_cache_data() returned (entry, None) for every key, so /cached-hits and /cached-records showed Redis entries as never expiring. It now queues PTTL next to each GET in the same pipeline and converts it to the absolute time.time() expiry the memory backend reports. PTTL -1 maps to None, and a key that vanished between SCAN and the fetch (PTTL -2) is skipped. Closes #74 --- CHANGELOG.md | 9 +++++++ docs/CACHE_FLOW.md | 11 +++------ docs/HTTP_CACHING.md | 6 +---- fastapi_cachex/backends/redis.py | 35 ++++++++++++++++++-------- tests/backends/test_redis.py | 42 +++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 117c515..c5cd217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. backend's atomic `set_if_absent`, so of several concurrent callers exactly one wins — for "send this webhook once" style deduplication. ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65)) +### Fixed + +- The monitoring routes from `add_routes()` now show when Redis entries + expire. `AsyncRedisCacheBackend.get_cache_data()` reported every entry as + never expiring (`ttl_remaining: null`); it now fetches each key's `PTTL` in + the same pipeline as its value and returns the absolute expiry the memory + backend reports. A key that disappears between the scan and the fetch is left + out. ([#74](https://github.com/allen0099/FastAPI-CacheX/issues/74)) + ## [0.3.6] - 2026-09-25 ### Added diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 9870bdd..158e043 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -329,13 +329,10 @@ value: the JSON document above ``` > [!NOTE] -> **The TTL fields of the monitoring endpoints are unavailable on the Redis -> backend.** `AsyncRedisCacheBackend.get_cache_data()` returns `(entry, None)` -> for every key without querying each key's actual TTL, so the -> `/cached-hits` and `/cached-records` routes mounted by `add_routes()` show every -> Redis entry as never expiring (`ttl_remaining: null`). Redis still enforces -> expiry itself; the monitoring just cannot see the remaining seconds. On -> Memcached these endpoints return no entries at all. +> The `/cached-hits` and `/cached-records` routes mounted by `add_routes()` read +> expiry from `get_cache_data()`. The memory backend tracks it directly and +> Redis reports each key's `PTTL`; on Memcached, which cannot enumerate keys, +> these endpoints return no entries at all. ## Cache clearing strategies diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 79354a6..0e17caa 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -265,8 +265,4 @@ add_routes( > `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app. > [!NOTE] -> The `ttl_remaining` field is not available on the Redis backend. -> `AsyncRedisCacheBackend.get_cache_data()` does not issue a per-key `TTL` -> lookup, so Redis-backed entries are reported as never expiring. Expiry itself -> still happens — only the monitoring view is blind to it. On Memcached, which -> cannot enumerate keys, both routes return nothing. +> On Memcached, which cannot enumerate keys, both routes return nothing. diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 4a71f56..adae57e 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -1,6 +1,7 @@ """Redis cache backend implementation.""" import logging +import time from collections.abc import Iterable from typing import TYPE_CHECKING from typing import Any @@ -22,6 +23,10 @@ logger = logging.getLogger(__name__) +# PTTL replies that are not a remaining lifetime. +_PTTL_NO_EXPIRY = -1 +_PTTL_MISSING = -2 + # SCAN page size and DEL batch size; keeps individual commands small. _BATCH_SIZE = 100 @@ -331,9 +336,10 @@ async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]: """Get all cache data with expiry information. Returns: - Dictionary mapping cache keys to (CacheEntry, expiry) tuples. - Note: Redis stores TTL but not absolute expiry time, so this - returns None for expiry (no expiry tracking in Redis backend). + Dictionary mapping cache keys to (CacheEntry, expiry) tuples, where + expiry is an absolute ``time.time()`` timestamp like the memory + backend reports, or None for a key without a TTL. It is derived + from each key's ``PTTL``, so it is accurate to the round-trip. """ all_keys = await self.get_all_keys() cache_data: dict[str, tuple[CacheEntry, float | None]] = {} @@ -341,16 +347,25 @@ async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]: if not all_keys: return cache_data - # Fetch all values in a single pipeline round-trip instead of N+1 GETs + # Fetch every value and its remaining lifetime in a single pipeline + # round-trip instead of 2N commands. pipe = self.client.pipeline() for key in all_keys: - pipe.get(self._make_key(key)) - raw_values: list[str | None] = await pipe.execute() - - for key, raw in zip(all_keys, raw_values, strict=False): + redis_key = self._make_key(key) + pipe.get(redis_key) + pipe.pttl(redis_key) + replies: list[Any] = await pipe.execute() + now = time.time() + + for key, raw, pttl in zip(all_keys, replies[::2], replies[1::2], strict=True): + # -2: the key expired or was deleted between SCAN and this fetch. + if pttl == _PTTL_MISSING: + continue value = decode_entry(raw) - if value is not None: - cache_data[key] = (value, None) + if value is None: + continue + expiry = None if pttl == _PTTL_NO_EXPIRY else now + pttl / 1000 + cache_data[key] = (value, expiry) logger.debug("Redis GET_CACHE_DATA; keys=%s", len(cache_data)) return cache_data diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 7adc820..fe5c26d 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -1,5 +1,6 @@ import asyncio import sys +import time from collections.abc import AsyncGenerator from typing import Any @@ -562,7 +563,7 @@ async def test_redis_get_cache_data_with_entries( assert stored_value1 == value1 assert stored_value2 == value2 - # Redis backend returns None for expiry (no expiry tracking) + # Stored without a ttl, so neither key expires. assert expiry1 is None assert expiry2 is None @@ -713,6 +714,45 @@ 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_get_cache_data_reports_absolute_expiry( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + """A key with a ttl reports when it expires, as a time.time() timestamp.""" + entry = CacheEntry(fingerprint="e", content=b"v") + before = time.time() + await async_redis_backend.set("short", entry, ttl=60) + await async_redis_backend.set("forever", entry) + + data = await async_redis_backend.get_cache_data() + after = time.time() + + _, expiry = data["short"] + assert expiry is not None + assert before + 59 <= expiry <= after + 60 + assert data["forever"][1] is None + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_get_cache_data_skips_keys_gone_after_scan( + async_redis_backend: AsyncRedisCacheBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A key that vanishes between SCAN and the fetch (PTTL -2) is left out.""" + await async_redis_backend.set("kept", CacheEntry(fingerprint="e", content=b"v")) + + async def keys_with_a_ghost() -> list[str]: + return ["kept", "gone"] + + monkeypatch.setattr(async_redis_backend, "get_all_keys", keys_with_a_ghost) + + data = await async_redis_backend.get_cache_data() + + assert list(data) == ["kept"] + + @requires_redis @pytest.mark.asyncio async def test_redis_get_cache_data_skips_undecodable_values(