diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e78e6b..ab4b10b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. ### Added +- `CacheManager.add(key, value, ttl=None) -> bool` stores a JSON-serializable + application value only when the key is free, using the same prefix, + encoding and `default_ttl` as `set()`, and delegating to + `backend.set_if_absent` so the claim is atomic on Memory, Redis and + Memcached. ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65)) - `BaseCacheBackend.set_if_absent(key, value, ttl=None) -> bool` and `delete_if_equals(key, expected) -> bool`, the atomic pair for locks and per-user slots: claim a key only when it is free, and release it only while diff --git a/README.md b/README.md index 13e9531..8b88582 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ 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 still free (atomic on Memory, Redis, Memcached). +claimed = await manager.add("webhook:evt-123", True, ttl=86400) + # 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:*" @@ -244,7 +247,7 @@ entries. **Note**: `clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()` and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't support key enumeration (see [Memcached limitations](#memcached)), these two methods are no-ops on a -Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use +Memcached backend — `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory backend if you need bulk clearing. ## Backend Configuration diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 7bba2ea..4f648b0 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -91,6 +91,41 @@ async def set(self, key: str, value: Any, ttl: int | None = None) -> None: 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 ``value`` only when ``key`` does not already exist. + + Same key prefix, JSON encoding and ``default_ttl`` handling as + ``set()``. Delegates to ``backend.set_if_absent`` so the claim is + atomic on Memory, Redis and Memcached. + + 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 ``key`` already existed. + + Raises: + 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") + ) + + stored = await self.backend.set_if_absent( + self._cache_key(key), entry, ttl=effective_ttl + ) + logger.debug( + "Cache ADD; key=%s ttl=%s stored=%s", key, effective_ttl, stored + ) + return stored + 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..3b39473 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -409,6 +409,46 @@ async def test_set_non_json_serializable_raises_type_error( await cache_manager.set("key", {1, 2, 3}) +# --- add (store-if-absent) --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_stores_when_key_absent(cache_manager: CacheManager) -> None: + """add() stores a value and returns True when the key is free.""" + assert await cache_manager.add("once", {"event": 1}) is True + assert await cache_manager.get("once") == {"event": 1} + + +@pytest.mark.asyncio +async def test_add_does_not_overwrite_existing_key( + cache_manager: CacheManager, +) -> None: + """add() returns False and leaves the original value when the key exists.""" + await cache_manager.set("once", "first") + assert await cache_manager.add("once", "second") is False + assert await cache_manager.get("once") == "first" + + +@pytest.mark.asyncio +async def test_add_honors_default_ttl(memory_backend: MemoryBackend) -> None: + """add() without an explicit ttl uses the manager's default_ttl.""" + BackendProxy.set(memory_backend) + manager = CacheManager(default_ttl=1) + assert await manager.add("key", "value") is True + assert await manager.get("key") == "value" + await asyncio.sleep(1.2) + assert await manager.get("key") is None + + +@pytest.mark.asyncio +async def test_add_non_json_serializable_raises_type_error( + cache_manager: CacheManager, +) -> None: + """add() propagates TypeError for a non-JSON-serializable value.""" + with pytest.raises(TypeError): + await cache_manager.add("key", {1, 2, 3}) + + @pytest.mark.asyncio async def test_get_with_corrupted_backend_content_returns_default( memory_backend: MemoryBackend,