Skip to content

feat(backends): add set_if_absent and delete_if_equals for locks and slots - #63

Merged
allen0099 merged 2 commits into
masterfrom
feature/set-if-absent
Sep 25, 2026
Merged

allen0099 merged 2 commits into
masterfrom
feature/set-if-absent

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #62

What

Two atomic primitives on BaseCacheBackend, overridden by every built-in backend:

set_if_absent(key, value, ttl=None) -> bool delete_if_equals(key, expected) -> bool
Memory check + store under the lock (expired = absent) compare + delete under the lock
Redis SET NX EX decode + compare in Python, then a Lua script that deletes only if the key still holds the bytes compared
Memcached ADD GETS + CAS write with exptime -1 (immediately expired)
Base fallback get-then-set (non-atomic, documented) get-compare-delete (non-atomic, documented)
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)

Why delete_if_equals too

#62 proposes releasing with delete. That frees the wrong holder when the first holder's entry has expired and someone else has claimed the key since — likely with long-lived SSE streams. Releasing only while the key still holds your token closes that.

Notes

  • Memcached's classic DELETE takes no CAS token, so the release is a CAS write with a negative exptime. Verified against a real server: a stale CAS token fails, and a subsequent ADD claims the key.
  • Comparison is on the decoded CacheEntry, so counters (stored as bare integers on Redis/Memcached) compare correctly.
  • BackendProxy needs no change: callers already reach the backend via BackendProxy.get().
  • Separate commit: test_the_repository_changelog_can_be_released hardcoded v0.3.4 and ### Security, so it has been failing on master since 0.3.5 was released. It now reads the latest release from the file.

Testing

  • Per backend: first-writer-wins, 20-way concurrency has exactly one winner, mismatch does not delete, counter comparison, and the Add an atomic set-if-absent (SET NX EX) to BaseCacheBackend for lock / slot acquisition #62 scenario (release after expiry keeps the new holder).
  • Redis/Memcached race tests overwrite the key between compare and delete. Swapping either implementation for a plain delete fails exactly those two tests.
  • Locally against live Redis/Memcached: 665 passed, 1 skipped (live-server gate), coverage 100%; ruff, mypy --strict and pre-commit clean.

…ng 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.
…slots

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
@allen0099
allen0099 merged commit a3748aa into master Sep 25, 2026
8 checks passed
@allen0099
allen0099 deleted the feature/set-if-absent branch September 25, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an atomic set-if-absent (SET NX EX) to BaseCacheBackend for lock / slot acquisition

1 participant