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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.

## [Unreleased]

### Added

- `CacheManager.add(key, value, ttl=None) -> bool` stores an application value
only when the key is free and reports whether it did. It uses the same key
prefix, JSON encoding and `default_ttl` as `set()`, and runs on the
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))

## [0.3.6] - 2026-09-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The library has four independent subsystems:
- Cache values are stored as `CacheEntry(fingerprint, content, media_type)` dataclass (defined in `types.py`).

**2. Application-Level Caching (`fastapi_cachex/manager.py`, `manager_proxy.py`)**
- `CacheManager` is a thin, JSON-serializing wrapper around whatever backend `BackendProxy` has configured, for caching arbitrary developer values (not HTTP responses) via `get`/`set`/`delete`/`has`/`clear_prefix`/`clear`.
- `CacheManager` is a thin, JSON-serializing wrapper around whatever backend `BackendProxy` has configured, for caching arbitrary developer values (not HTTP responses) via `get`/`set`/`add`/`delete`/`has`/`get_or_set`/`clear_prefix`/`clear`. `add()` is store-if-absent on top of `backend.set_if_absent`.
- Keys live under their own `cache:`-prefixed namespace by default (configurable via `key_prefix`), separate from HTTP route keys and `oauth_state:`.
- `get()` never raises — returns `default` (`None` unless overridden) on a miss or decode failure. `set()` lets `TypeError` propagate for non-JSON-serializable values.
- `CacheManagerProxy` mirrors `BackendProxy`/`SessionManagerProxy`. The `AppCache` FastAPI dependency (`get_app_cache`, in `dependencies.py`) lazily creates and registers a default `CacheManager` on first use.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
- **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
`ETag` / `If-None-Match` (304) and per-route invalidation.
- **Application cache** — `CacheManager` for caching arbitrary JSON values in
your own code, with compute-on-miss `get_or_set()`.
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
one-shot values and locks.
- **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
Expand Down
11 changes: 10 additions & 1 deletion docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ await manager.clear_prefix() # clear everything under "myapp:"
# undecodable. It may be sync or async.
profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300)

# Store only if the key is free: of concurrent callers exactly one gets True.
if await manager.add(f"webhook:{event_id}", True, ttl=86400):
await deliver_webhook(event_id)

# Glob over this manager's namespace, using the backend's native pattern
# support (Redis SCAN) rather than enumerating every key.
await manager.clear_pattern("user:*") # matches "myapp:user:*"
Expand All @@ -40,6 +44,11 @@ await manager.clear_pattern("user:*") # matches "myapp:user:*"
- `set()` lets `TypeError` propagate for values that are not JSON-serializable.
- `get_or_set()` provides no stampede protection: concurrent misses for the same
key each run `factory`.
- `add()` stores a value only when the key is free and returns whether it did.
The check and the write are one atomic backend operation (`set_if_absent`),
so it suits "do this once per key" work such as webhook or email
deduplication. An expired key counts as free; a key holding an undecodable
value does not, even though `get()` treats it as a miss.
- Keys live under their own `cache:`-prefixed namespace by default, separate from
the HTTP route cache and OAuth state, so `clear()`/`clear_prefix()` never touch
unrelated cache entries.
Expand All @@ -51,7 +60,7 @@ await manager.clear_pattern("user:*") # matches "myapp:user:*"
> and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't
> support key enumeration (see [Backends](BACKENDS.md#memcached)), these
> methods — and `clear_pattern()` — are no-ops on a Memcached backend;
> `get()`/`set()`/`delete()`/`has()` work normally. Use Redis or the in-memory
> `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory
> backend if you need bulk clearing.

The full method list is in the [API reference](api/cache-manager.md).
46 changes: 40 additions & 6 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ def __init__(
def _cache_key(self, key: str) -> str:
return f"{self.key_prefix}{key}"

@staticmethod
def _encode(value: Any) -> CacheEntry:
json_content = json.dumps(value)
fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
return CacheEntry(fingerprint=fingerprint, content=json_content.encode("utf-8"))

async def get(self, key: str, default: Any = None) -> Any:
"""Retrieve and JSON-decode a cached value.

Expand Down Expand Up @@ -81,16 +87,44 @@ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
TypeError: If ``value`` is not JSON-serializable.
"""
effective_ttl = ttl if ttl is not None else self.default_ttl

json_content = json.dumps(value)
fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
entry = CacheEntry(
fingerprint=fingerprint, content=json_content.encode("utf-8")
)
entry = self._encode(value)

await self.backend.set(self._cache_key(key), entry, ttl=effective_ttl)
logger.debug("Cache SET; key=%s ttl=%s", key, effective_ttl)

async def add(self, key: str, value: Any, ttl: int | None = None) -> bool:
"""Store a value only if the key is not already in the cache.

The check and the write are one atomic backend operation
(``set_if_absent``), so of several concurrent callers adding the same
key exactly one gets ``True``. Use it to do something once per key,
such as sending a webhook or recording a first occurrence.

An expired key counts as absent. A key holding a value that cannot be
decoded still exists, so ``add()`` returns ``False`` for it, whereas
``get()`` and ``get_or_set()`` treat it as a miss.

Args:
key: Logical cache key (without the manager's prefix).
value: A JSON-serializable Python value.
ttl: Time-to-live in seconds. If None, uses ``self.default_ttl``
(which itself defaults to no expiry).

Returns:
True if the value was stored, False if the key already existed.

Raises:
TypeError: If ``value`` is not JSON-serializable.
"""
effective_ttl = ttl if ttl is not None else self.default_ttl
entry = self._encode(value)

added = await self.backend.set_if_absent(
self._cache_key(key), entry, ttl=effective_ttl
)
logger.debug("Cache ADD; key=%s ttl=%s added=%s", key, effective_ttl, added)
return added

async def delete(self, key: str) -> bool:
"""Remove a value from the cache.

Expand Down
80 changes: 80 additions & 0 deletions tests/test_cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,86 @@ async def test_get_or_set_treats_corrupted_content_as_miss(
assert await manager.get("bad") == "repaired"


# --- add ------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_add_stores_when_key_is_free(cache_manager: CacheManager) -> None:
"""add() stores the value and reports it when nothing holds the key."""
assert await cache_manager.add("event:1", {"sent": True}) is True
assert await cache_manager.get("event:1") == {"sent": True}


@pytest.mark.asyncio
async def test_add_keeps_the_existing_value(cache_manager: CacheManager) -> None:
"""add() never overwrites: the first value stays and the call reports False."""
await cache_manager.set("event:1", "first")

assert await cache_manager.add("event:1", "second") is False
assert await cache_manager.get("event:1") == "first"


@pytest.mark.asyncio
async def test_add_concurrent_callers_have_exactly_one_winner(
cache_manager: CacheManager,
) -> None:
"""The check and the write are atomic, so only one concurrent add() succeeds."""
results = await asyncio.gather(
*(cache_manager.add("event:1", n) for n in range(20))
)

assert results.count(True) == 1
# The stored value is the winner's, not a later caller's.
assert await cache_manager.get("event:1") == results.index(True)


@pytest.mark.asyncio
async def test_add_ttl_expires_the_claim(cache_manager: CacheManager) -> None:
"""An explicit ttl applies, and once it lapses the key can be added again."""
assert await cache_manager.add("event:1", "first", ttl=1) is True
assert await cache_manager.add("event:1", "second", ttl=1) is False

await asyncio.sleep(1.2)

assert await cache_manager.add("event:1", "third") is True
assert await cache_manager.get("event:1") == "third"


@pytest.mark.asyncio
async def test_add_uses_default_ttl(memory_backend: MemoryBackend) -> None:
"""add() without an explicit ttl falls back to the manager's default_ttl."""
manager = CacheManager(backend=memory_backend, default_ttl=1)
assert await manager.add("event:1", "value") is True

await asyncio.sleep(1.2)

assert await manager.get("event:1") is None


@pytest.mark.asyncio
async def test_add_treats_undecodable_content_as_present(
memory_backend: MemoryBackend,
) -> None:
"""Unlike get(), add() sees a corrupted entry as an existing key."""
manager = CacheManager(backend=memory_backend)
entry = CacheEntry(fingerprint="x", content=b"not valid json")
await memory_backend.set(f"{manager.key_prefix}bad", entry, ttl=60)

assert await manager.add("bad", "value") is False
assert await manager.get("bad", default="fallback") == "fallback"


@pytest.mark.asyncio
async def test_add_non_json_serializable_raises_type_error(
cache_manager: CacheManager,
) -> None:
"""add() raises TypeError like set(), and leaves the key free."""
with pytest.raises(TypeError):
await cache_manager.add("event:1", {1, 2, 3})

assert await cache_manager.has("event:1") is False


# --- delete / has ---------------------------------------------------------------


Expand Down
Loading