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(