Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
40 changes: 40 additions & 0 deletions tests/test_cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down