From e6b66e2f9a8a955ee3ffc0462be5f5f39b45b093 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 07:12:15 +0000 Subject: [PATCH 1/2] test(changelog): read the latest release off the file instead of naming it `test_the_repository_changelog_can_be_released` runs the promotion script on the real CHANGELOG.md, but asserted that the previous release was v0.3.4 and that the notes open with `### Security`. Both were true of the 0.3.5 notes and stopped being true the moment 0.3.5 was released, so the test has been failing on master since. It now takes the previous version from the first released heading and only requires the notes to open with a section heading. --- tests/test_changelog_release.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index ddc210a..1960c6a 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -169,12 +169,17 @@ def test_the_repository_changelog_can_be_released(): changelog = Path(__file__).parent.parent / "CHANGELOG.md" text = changelog.read_text(encoding="utf-8") + # Read the latest release off the file instead of naming it, so the test + # keeps passing after every release rather than breaking on the next one. + latest = re.search(r"^## \[(\d+\.\d+\.\d+)\]", text, re.MULTILINE) + assert latest is not None + rewritten, body = promote(text, "0.9.9", "2026-09-14") assert rewritten.startswith("# Changelog\n") assert text.count("removed in 0.3.5") == rewritten.count("removed in 0.3.5") - assert f"[0.9.9]: {BASE}/compare/v0.3.4...v0.9.9" in rewritten - assert body.startswith("### Security") + assert f"[0.9.9]: {BASE}/compare/v{latest[1]}...v0.9.9" in rewritten + assert body.startswith("### ") # Every released heading still has a link definition, and vice versa. headings = { line.removeprefix("## [").split("]")[0] From 1a0b345b8fabfe9dc8620b53f1085f898876e32b Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 07:12:23 +0000 Subject: [PATCH 2/2] feat(backends): add set_if_absent and delete_if_equals for locks and slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquiring a lock or a per-user slot could only be emulated with `increment` plus a compensating decrement, and the two calls race: concurrent claimers can leave a slot held by nobody, and a release landing between someone else's increment and decrement frees a slot that is still in use. Releasing with a plain `delete` has its own race — a holder whose entry expired deletes the entry of whoever claimed the key after it. `set_if_absent(key, value, ttl=None) -> bool` claims a key only when it is free (an expired key counts as absent): Redis `SET NX EX`, Memcached `ADD`, memory under its lock. `delete_if_equals(key, expected) -> bool` releases only while the key still holds the caller's entry, compared as a decoded `CacheEntry` so counters match too. Redis compares in Python and deletes through a Lua script that re-checks the raw bytes it compared. Memcached's classic `DELETE` takes no CAS token, so it uses `GETS` and a `CAS` write with exptime -1, which the server treats as immediately expired and which a later `ADD` can claim. Both are non-abstract with non-atomic fallbacks on `BaseCacheBackend`, so third-party backends keep working. The race tests for Redis and Memcached overwrite the key between compare and delete; swapping either implementation for a plain delete fails exactly those two tests. Closes #62 --- CHANGELOG.md | 10 +++ CLAUDE.md | 6 +- README.md | 27 ++++++- fastapi_cachex/backends/base.py | 52 ++++++++++++ fastapi_cachex/backends/memcached.py | 45 +++++++++++ fastapi_cachex/backends/memory.py | 31 +++++++ fastapi_cachex/backends/redis.py | 48 +++++++++++ tests/backends/test_base.py | 27 +++++++ tests/backends/test_memcached.py | 105 ++++++++++++++++++++++++ tests/backends/test_memory.py | 111 +++++++++++++++++++++++++ tests/backends/test_redis.py | 116 +++++++++++++++++++++++++++ 11 files changed, 573 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26acfc..3e78e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. ## [Unreleased] +### Added + +- `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 + it still holds your entry, so a holder whose entry expired cannot free a slot + someone else has claimed since. Redis uses `SET NX EX` and a Lua + compare-and-delete, Memcached `ADD` and `GETS` + `CAS`, memory its lock. + Third-party backends inherit non-atomic fallbacks. ([#62](https://github.com/allen0099/FastAPI-CacheX/issues/62)) + ## [0.3.5] - 2026-09-15 ### Security diff --git a/CLAUDE.md b/CLAUDE.md index 87c0443..fd5b046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,11 +84,13 @@ All backends implement `BaseCacheBackend` (abstract base in `backends/base.py`): Backend keys are namespaced automatically (default prefix: `fastapi_cachex:`). -Two non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see README "Atomic backend primitives"): +Four non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see README "Atomic backend primitives"): - `increment(key, delta=1, ttl=None) -> int`: fixed-window counter; `ttl` applies only when the counter is created. Redis runs a registered Lua script, Memcached uses `ADD` + `INCR`/`DECR`, memory works under its lock. A counter reads back through `get()` as a `CacheEntry` with `COUNTER_FINGERPRINT` (`types.py`). - `get_and_delete(key) -> CacheEntry | None`: one-shot retrieval (Redis `GETDEL`, Memcached get + `delete(noreply=False)` winner check). `StateManager.consume_state`, `delete_state`, `CacheManager.delete` and `invalidate()` use it. `delete()` keeps returning `None` for 0.3.x compatibility. +- `set_if_absent(key, value, ttl=None) -> bool`: claim-if-free for locks/slots. Redis `SET NX EX`, Memcached `ADD`, memory under its lock. +- `delete_if_equals(key, expected) -> bool`: release only while the key still holds `expected` (compared as decoded `CacheEntry`). Redis compares in Python then deletes via a Lua script that re-checks the raw bytes; Memcached uses `GETS` + `CAS` with exptime `-1` (immediate expiry), since classic `DELETE` has no CAS. -`delete_many(keys) -> int` is the third non-abstract base method: a per-key loop by default, one batched operation on Redis (`DEL`) and Memory (single lock). +`delete_many(keys) -> int` is the fifth non-abstract base method: a per-key loop by default, one batched operation on Redis (`DEL`) and Memory (single lock). `backends/codec.py` holds the JSON `CacheEntry` codec shared by Redis and Memcached; `decode_entry` maps a bare integer to a counter entry and every malformed value to `None`. diff --git a/README.md b/README.md index 679e95c..3ebc7b4 100644 --- a/README.md +++ b/README.md @@ -352,11 +352,14 @@ stored or replayed. ### Atomic backend primitives -Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for +Every backend exposes atomic operations on top of `get`/`set`/`delete`, for values that are read and written by many concurrent requests: ```python +import secrets + from fastapi_cachex import BackendProxy +from fastapi_cachex.types import CacheEntry backend = BackendProxy.get() @@ -367,6 +370,14 @@ if hits > 3: # One-shot value: of several concurrent callers exactly one gets the entry. grant = await backend.get_and_delete(f"grant:{token}") + +# Lock / slot: claim only if free, release only while it is still yours. +owner = CacheEntry(fingerprint="lock", content=secrets.token_bytes(16)) +if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): + try: + ... + finally: + await backend.delete_if_equals(f"stream:{user_id}", owner) ``` - `increment(key, delta=1, ttl=None) -> int` — Memory does the read-modify-write @@ -380,8 +391,18 @@ grant = await backend.get_and_delete(f"grant:{token}") uses `GETDEL` (server 6.2+) and Memcached returns the value only when its own `DELETE` won. `StateManager.consume_state`, `CacheManager.delete` and `invalidate()` are built on it. - -Both have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend +- `set_if_absent(key, value, ttl=None) -> bool` — stores `value` only when + `key` does not exist (an expired key counts as absent) and reports whether it + did. Memory checks under its lock, Redis uses `SET NX EX` and Memcached `ADD`. +- `delete_if_equals(key, expected) -> bool` — removes `key` only while it still + holds `expected`, so a holder whose entry expired cannot release a lock that + someone else has claimed since. Put a unique token in the entry you store and + release with that same entry. Memory compares under its lock, Redis deletes + through a Lua script that re-checks the value it compared, and Memcached uses + `GETS` + a `CAS` write that expires the entry immediately (the classic + protocol's `DELETE` takes no CAS token). + +All four have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend that only implements the abstract methods keeps working; override them to get real atomicity. diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 2799dd5..5ef3488 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -83,6 +83,58 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: await self.delete(key) return value + async def set_if_absent( + self, key: str, value: CacheEntry, ttl: int | None = None + ) -> bool: + """Store ``value`` only when ``key`` does not exist yet. + + The building block for locks and slots: of several concurrent callers + exactly one stores its value and gets ``True``, every other caller + gets ``False`` and the stored value is left untouched. An expired key + counts as absent. Pair it with ``delete_if_equals`` to release only + what you still hold. + + The base implementation is a best-effort, NON-atomic get-then-set + fallback for third-party backends; the built-in backends override it + with an atomic implementation. + + Args: + key: Cache key to claim + value: Entry to store, typically carrying a unique owner token + ttl: Time to live in seconds (``None`` = never expires) + + Returns: + Whether ``value`` was stored + """ + if await self.get(key) is not None: + return False + await self.set(key, value, ttl=ttl) + return True + + async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: + """Remove ``key`` only while it still holds ``expected``. + + Releasing a lock with a plain ``delete`` is unsafe: if the holder's + entry expired and someone else claimed the key in the meantime, the + delete removes the new holder's entry. Comparing against the value the + caller stored makes the release a no-op in that case. + + The base implementation is a best-effort, NON-atomic get-compare-delete + fallback for third-party backends; the built-in backends override it + with an atomic implementation. + + Args: + key: Cache key to release + expected: The entry the caller stored (compared with ``==``) + + Returns: + Whether the entry was removed + """ + if await self.get(key) != expected: + return False + await self.delete(key) + return True + async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int: """Atomically add ``delta`` to the integer counter stored at ``key``. diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 4cc4c92..8de4e18 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -167,6 +167,51 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key) return decode_entry(raw) + async def set_if_absent( + self, key: str, value: CacheEntry, ttl: int | None = None + ) -> bool: + """Atomically store ``value`` unless ``key`` exists (see base class). + + Memcached's ``ADD`` is exactly this operation. + """ + stored = await asyncio.to_thread( + self.client.add, + self._make_key(key), + encode_entry(value), + _expiry(ttl), + noreply=False, + ) + logger.debug( + "Memcached SET_IF_ABSENT %s; key=%s ttl=%s", + "STORED" if stored else "EXISTS", + key, + ttl, + ) + return bool(stored) + + async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: + """Atomically remove ``key`` while it holds ``expected`` (see base class). + + The classic protocol's DELETE takes no CAS token, so the release is a + CAS write with a negative exptime, which Memcached treats as "expired + immediately": it succeeds only if nothing wrote the key since ``GETS`` + read the value that was compared. + """ + prefixed_key = self._make_key(key) + raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key) + if raw is None or decode_entry(raw) != expected: + logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key) + return False + deleted = await asyncio.to_thread( + self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False + ) + logger.debug( + "Memcached DELETE_IF_EQUALS %s; key=%s", + "HIT" if deleted else "LOST RACE", + key, + ) + return bool(deleted) + def _add_delta(self, prefixed_key: str, delta: int) -> int | None: """Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist.""" if delta < 0: diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index cd3e63f..ee05a9b 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -157,6 +157,37 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: logger.debug("Memory cache GET_AND_DELETE HIT; key=%s", key) return item.value + async def set_if_absent( + self, key: str, value: CacheEntry, ttl: int | None = None + ) -> bool: + """Atomically store ``value`` unless ``key`` exists (see base class).""" + self._ensure_cleanup_started() + + async with self.lock: + now = time.time() + item = self.cache.get(key) + if item is not None and _is_live(item, now): + logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key) + return False + expiry = now + ttl if ttl is not None else None + self.cache[key] = CacheItem(value=value, expiry=expiry) + logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl) + return True + + async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: + """Atomically remove ``key`` while it holds ``expected`` (see base class).""" + async with self.lock: + item = self.cache.get(key) + if item is None or not _is_live(item, time.time()): + logger.debug("Memory cache DELETE_IF_EQUALS MISS; key=%s", key) + return False + if item.value != expected: + logger.debug("Memory cache DELETE_IF_EQUALS MISMATCH; key=%s", key) + return False + del self.cache[key] + logger.debug("Memory cache DELETE_IF_EQUALS HIT; key=%s", key) + return True + async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int: """Atomically add ``delta`` to the counter at ``key`` (see base class). diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 76baf7c..4a71f56 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -36,6 +36,15 @@ return value """ +# DEL that only fires while the key still holds the exact bytes the caller read, +# so a value replaced in the meantime survives. KEYS[1] = key, ARGV[1] = bytes. +_DELETE_IF_EQUALS_SCRIPT = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +""" + class AsyncRedisCacheBackend(BaseCacheBackend): """Async Redis cache backend implementation. @@ -108,6 +117,9 @@ def __init__( # Registered once so every call is an EVALSHA (redis-py reloads the # script transparently if the server has flushed it). self._increment_script = self.client.register_script(_INCREMENT_SCRIPT) + self._delete_if_equals_script = self.client.register_script( + _DELETE_IF_EQUALS_SCRIPT + ) @staticmethod def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend": @@ -189,6 +201,42 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: logger.debug("Redis GETDEL %s; key=%s", "HIT" if value else "MISS", key) return value + async def set_if_absent( + self, key: str, value: CacheEntry, ttl: int | None = None + ) -> bool: + """Atomically store ``value`` unless ``key`` exists (see base class). + + A single ``SET ... NX EX``. + """ + stored = await self.client.set( + self._make_key(key), encode_entry(value), ex=ttl, nx=True + ) + logger.debug( + "Redis SET_IF_ABSENT %s; key=%s ttl=%s", + "STORED" if stored else "EXISTS", + key, + ttl, + ) + return bool(stored) + + async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: + """Atomically remove ``key`` while it holds ``expected`` (see base class). + + The stored value is decoded and compared here, then a Lua script + deletes the key only if it still holds the bytes that were compared, + so a value written in between is never removed. + """ + prefixed_key = self._make_key(key) + raw = await self.client.get(prefixed_key) + if raw is None or decode_entry(raw) != expected: + logger.debug("Redis DELETE_IF_EQUALS MISMATCH; key=%s", key) + return False + deleted = await self._delete_if_equals_script(keys=[prefixed_key], args=[raw]) + logger.debug( + "Redis DELETE_IF_EQUALS %s; key=%s", "HIT" if deleted else "LOST RACE", key + ) + return bool(deleted) + async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int: """Atomically add ``delta`` to the counter at ``key`` (see base class). diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py index 06b7a1d..d40450c 100644 --- a/tests/backends/test_base.py +++ b/tests/backends/test_base.py @@ -86,3 +86,30 @@ async def test_delete_many_fallback_deletes_one_by_one(backend: DictBackend) -> assert await backend.delete_many(["a", "b", "missing"]) == 3 assert backend.store == {} + + +@pytest.mark.asyncio +async def test_set_if_absent_fallback_stores_only_the_first_value( + backend: DictBackend, +) -> None: + first = CacheEntry(fingerprint="lock", content=b"owner-a") + second = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await backend.set_if_absent("slot", first, ttl=30) is True + assert await backend.set_if_absent("slot", second, ttl=30) is False + assert backend.store["slot"] == (first, 30) + + +@pytest.mark.asyncio +async def test_delete_if_equals_fallback_removes_only_a_matching_entry( + backend: DictBackend, +) -> None: + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + theirs = CacheEntry(fingerprint="lock", content=b"owner-b") + await backend.set("slot", theirs) + + assert await backend.delete_if_equals("slot", mine) is False + assert "slot" in backend.store + assert await backend.delete_if_equals("slot", theirs) is True + assert "slot" not in backend.store + assert await backend.delete_if_equals("slot", theirs) is False diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index b8a68e5..0b69799 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -562,3 +562,108 @@ async def test_increment_reports_a_counter_that_vanished_mid_call() -> None: await backend.increment("k") assert backend.client.add.call_count == 1 + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_set_if_absent_stores_only_the_first_value( + memcached_backend: MemcachedBackend, +): + first = CacheEntry(fingerprint="lock", content=b"owner-a") + second = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await memcached_backend.set_if_absent("slot", first, 60) is True + assert await memcached_backend.set_if_absent("slot", second, 60) is False + assert await memcached_backend.get("slot") == first + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_set_if_absent_applies_the_ttl( + memcached_backend: MemcachedBackend, +): + entry = CacheEntry(fingerprint="lock", content=b"owner-a") + + assert await memcached_backend.set_if_absent("slot", entry, 1) is True + await asyncio.sleep(2.1) + assert await memcached_backend.set_if_absent("slot", entry, 60) is True + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_set_if_absent_has_exactly_one_winner( + memcached_backend: MemcachedBackend, +): + results = await asyncio.gather( + *( + memcached_backend.set_if_absent( + "slot", CacheEntry(fingerprint="lock", content=str(i).encode()), 60 + ) + for i in range(20) + ) + ) + + assert results.count(True) == 1 + winner = results.index(True) + assert await memcached_backend.get("slot") == CacheEntry( + fingerprint="lock", content=str(winner).encode() + ) + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_delete_if_equals_removes_only_a_matching_entry( + memcached_backend: MemcachedBackend, +): + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + theirs = CacheEntry(fingerprint="lock", content=b"owner-b") + await memcached_backend.set("slot", theirs, 60) + + assert await memcached_backend.delete_if_equals("slot", mine) is False + assert await memcached_backend.get("slot") == theirs + + assert await memcached_backend.delete_if_equals("slot", theirs) is True + assert await memcached_backend.get("slot") is None + assert await memcached_backend.delete_if_equals("slot", theirs) is False + # The CAS-expired key is really gone: it can be claimed again at once. + assert await memcached_backend.set_if_absent("slot", mine, 60) is True + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_delete_if_equals_matches_a_counter( + memcached_backend: MemcachedBackend, +): + await memcached_backend.increment("hits", 3) + + assert await memcached_backend.delete_if_equals("hits", counter_entry(2)) is False + assert await memcached_backend.delete_if_equals("hits", counter_entry(3)) is True + assert await memcached_backend.get("hits") is None + + +@requires_memcached +@pytest.mark.asyncio +async def test_memcached_delete_if_equals_keeps_a_value_written_after_the_compare( + memcached_backend: MemcachedBackend, +): + """Between GETS and the CAS, another holder claims the key; the CAS token + no longer matches, so the new holder's entry survives.""" + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + await memcached_backend.set("slot", mine, 60) + + client = memcached_backend.client + original_gets = client.gets + + def gets_then_overwrite(key): + result = original_gets(key) + client.set(key, b'{"overwritten": true}', 60) + return result + + client.gets = gets_then_overwrite + try: + assert await memcached_backend.delete_if_equals("slot", mine) is False + finally: + client.gets = original_gets + + raw = await asyncio.to_thread(client.get, memcached_backend._make_key("slot")) + assert raw == b'{"overwritten": true}' diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 5eb60a8..c9b48b7 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -688,3 +688,114 @@ async def test_cleanup_leaves_live_entries_alone(): assert sorted(backend.cache) == ["a", "b"] finally: backend.stop_cleanup() + + +@pytest.mark.asyncio +async def test_memory_set_if_absent_stores_only_the_first_value( + memory_backend: MemoryBackend, +): + first = CacheEntry(fingerprint="lock", content=b"owner-a") + second = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await memory_backend.set_if_absent("slot", first, 60) is True + assert await memory_backend.set_if_absent("slot", second, 60) is False + assert await memory_backend.get("slot") == first + assert memory_backend.cache["slot"].expiry is not None + + +@pytest.mark.asyncio +async def test_memory_set_if_absent_without_ttl_never_expires( + memory_backend: MemoryBackend, +): + entry = CacheEntry(fingerprint="lock", content=b"owner-a") + + assert await memory_backend.set_if_absent("slot", entry) is True + assert memory_backend.cache["slot"].expiry is None + + +@pytest.mark.asyncio +async def test_memory_set_if_absent_treats_an_expired_entry_as_absent( + memory_backend: MemoryBackend, +): + stale = CacheEntry(fingerprint="lock", content=b"owner-a") + fresh = CacheEntry(fingerprint="lock", content=b"owner-b") + await memory_backend.set("slot", stale, 60) + memory_backend.cache["slot"].expiry = time.time() - 1 + + assert await memory_backend.set_if_absent("slot", fresh, 60) is True + assert await memory_backend.get("slot") == fresh + + +@pytest.mark.asyncio +async def test_memory_set_if_absent_has_exactly_one_winner( + memory_backend: MemoryBackend, +): + results = await asyncio.gather( + *( + memory_backend.set_if_absent( + "slot", CacheEntry(fingerprint="lock", content=str(i).encode()), 60 + ) + for i in range(20) + ) + ) + + assert results.count(True) == 1 + winner = results.index(True) + assert await memory_backend.get("slot") == CacheEntry( + fingerprint="lock", content=str(winner).encode() + ) + + +@pytest.mark.asyncio +async def test_memory_delete_if_equals_removes_only_a_matching_entry( + memory_backend: MemoryBackend, +): + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + theirs = CacheEntry(fingerprint="lock", content=b"owner-b") + await memory_backend.set("slot", theirs, 60) + + assert await memory_backend.delete_if_equals("slot", mine) is False + assert await memory_backend.get("slot") == theirs + + assert await memory_backend.delete_if_equals("slot", theirs) is True + assert "slot" not in memory_backend.cache + assert await memory_backend.delete_if_equals("slot", theirs) is False + + +@pytest.mark.asyncio +async def test_memory_delete_if_equals_ignores_an_expired_entry( + memory_backend: MemoryBackend, +): + entry = CacheEntry(fingerprint="lock", content=b"owner-a") + await memory_backend.set("slot", entry, 60) + memory_backend.cache["slot"].expiry = time.time() - 1 + + assert await memory_backend.delete_if_equals("slot", entry) is False + + +@pytest.mark.asyncio +async def test_memory_delete_if_equals_matches_a_counter( + memory_backend: MemoryBackend, +): + await memory_backend.increment("hits", 3) + + assert await memory_backend.delete_if_equals("hits", counter_entry(2)) is False + assert await memory_backend.delete_if_equals("hits", counter_entry(3)) is True + assert await memory_backend.get("hits") is None + + +@pytest.mark.asyncio +async def test_memory_lock_release_after_expiry_keeps_the_new_holder( + memory_backend: MemoryBackend, +): + """The lock cycle issue #62 asks for: a late release must not free a slot + that has since been claimed by someone else.""" + owner_a = CacheEntry(fingerprint="lock", content=b"owner-a") + owner_b = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await memory_backend.set_if_absent("slot", owner_a, 60) is True + memory_backend.cache["slot"].expiry = time.time() - 1 + assert await memory_backend.set_if_absent("slot", owner_b, 60) is True + + assert await memory_backend.delete_if_equals("slot", owner_a) is False + assert await memory_backend.get("slot") == owner_b diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 97ac5da..7adc820 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -728,3 +728,119 @@ async def test_redis_get_cache_data_skips_undecodable_values( assert "good" in data assert "junk" not in data + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_set_if_absent_stores_only_the_first_value( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + first = CacheEntry(fingerprint="lock", content=b"owner-a") + second = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await async_redis_backend.set_if_absent("slot", first, 60) is True + assert await async_redis_backend.set_if_absent("slot", second, 60) is False + assert await async_redis_backend.get("slot") == first + ttl = await async_redis_backend.client.ttl(async_redis_backend._make_key("slot")) + assert 0 < ttl <= 60 + + assert await async_redis_backend.set_if_absent("forever", first) is True + assert ( + await async_redis_backend.client.ttl(async_redis_backend._make_key("forever")) + == -1 + ) + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_set_if_absent_has_exactly_one_winner( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + results = await asyncio.gather( + *( + async_redis_backend.set_if_absent( + "slot", CacheEntry(fingerprint="lock", content=str(i).encode()), 60 + ) + for i in range(20) + ) + ) + + assert results.count(True) == 1 + winner = results.index(True) + assert await async_redis_backend.get("slot") == CacheEntry( + fingerprint="lock", content=str(winner).encode() + ) + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_delete_if_equals_removes_only_a_matching_entry( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + theirs = CacheEntry(fingerprint="lock", content=b"owner-b") + await async_redis_backend.set("slot", theirs, 60) + + assert await async_redis_backend.delete_if_equals("slot", mine) is False + assert await async_redis_backend.get("slot") == theirs + + assert await async_redis_backend.delete_if_equals("slot", theirs) is True + assert await async_redis_backend.get("slot") is None + assert await async_redis_backend.delete_if_equals("slot", theirs) is False + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_delete_if_equals_matches_a_counter( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + """Counters are stored as bare integers, not as encoded entries.""" + await async_redis_backend.increment("hits", 3) + + assert await async_redis_backend.delete_if_equals("hits", counter_entry(2)) is False + assert await async_redis_backend.delete_if_equals("hits", counter_entry(3)) is True + assert await async_redis_backend.get("hits") is None + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_delete_if_equals_keeps_a_value_written_after_the_compare( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + """Between the GET that compares and the script that deletes, another + holder claims the key; the script must see the new bytes and back off.""" + mine = CacheEntry(fingerprint="lock", content=b"owner-a") + theirs = CacheEntry(fingerprint="lock", content=b"owner-b") + await async_redis_backend.set("slot", mine, 60) + + client = async_redis_backend.client + original_get = client.get + + async def get_then_overwrite(name): + raw = await original_get(name) + await async_redis_backend.set("slot", theirs, 60) + return raw + + client.get = get_then_overwrite # type: ignore[method-assign] + try: + assert await async_redis_backend.delete_if_equals("slot", mine) is False + finally: + client.get = original_get # type: ignore[method-assign] + + assert await async_redis_backend.get("slot") == theirs + + +@requires_redis +@pytest.mark.asyncio +async def test_redis_lock_release_after_expiry_keeps_the_new_holder( + async_redis_backend: AsyncRedisCacheBackend, +) -> None: + owner_a = CacheEntry(fingerprint="lock", content=b"owner-a") + owner_b = CacheEntry(fingerprint="lock", content=b"owner-b") + + assert await async_redis_backend.set_if_absent("slot", owner_a, 60) is True + await async_redis_backend.client.delete(async_redis_backend._make_key("slot")) + assert await async_redis_backend.set_if_absent("slot", owner_b, 60) is True + + assert await async_redis_backend.delete_if_equals("slot", owner_a) is False + assert await async_redis_backend.get("slot") == owner_b