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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 1 addition & 5 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 25 additions & 10 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -331,26 +336,36 @@ 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]] = {}

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
42 changes: 41 additions & 1 deletion tests/backends/test_redis.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import sys
import time
from collections.abc import AsyncGenerator
from typing import Any

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

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