From 2e6bc6b992930050e5fa2629dec366f2fb21d1e2 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 08:49:54 +0000 Subject: [PATCH] feat(cache-manager): add add() for atomic store-if-absent of application values CacheManager.add(key, value, ttl=None) -> bool stores a JSON value only when the key is free and reports whether it did. It runs on the backend's set_if_absent, so of several concurrent callers exactly one wins, which makes once-per-key work (webhook or email deduplication) race-free. set() and add() now share one _encode() helper for the JSON entry. Closes #65 --- CHANGELOG.md | 8 ++++ CLAUDE.md | 2 +- README.md | 2 +- docs/APP_CACHE.md | 11 ++++- fastapi_cachex/manager.py | 46 ++++++++++++++++++--- tests/test_cache_manager.py | 80 +++++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53149c8..117c515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5200bc4..f135bc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 26a65e4..9f30f2a 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index ec98a96..47a0736 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -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:*" @@ -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. @@ -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). diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 7bba2ea..f92d763 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -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. @@ -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. diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 630f582..a514e1d 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -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 ---------------------------------------------------------------