Use CAS deletion in get_and_delete to prevent race conditions - #218
Conversation
allen0099
left a comment
There was a problem hiding this comment.
Thanks @ShivanshShukla, this is a solid change. I checked it locally, merged into current master against live Redis and Memcached. The full suite passes. Your tests also fail when I revert get_and_delete to the old get + delete, and when I drop the retry on False.
A few things before merging:
1. Raise when the retries run out, instead of returning None
When all 16 attempts lose to a concurrent writer, the key still holds a value and was not deleted. Returning None tells the caller "there was nothing there", and every caller acts on that:
CacheManager.delete()returnsFalse("key did not exist"), but the key is still there.invalidate()treats the entry as already gone, so the stale response keeps being served.StateManager.consume_state()/delete_state()report the state as missing, but it stays consumable.
We just removed this kind of made-up answer from the Memcached backend in #216 (#197): when it cannot do what was asked, it raises. Please raise CacheXError here too, with a message that names the key and the number of attempts, and keep the bound so the call cannot spin forever. test_memcached_get_and_delete_exhausts_retries_when_writes_continue would then assert the raise (pytest.raises(CacheXError)) instead of None. The logger.warning can go, since the exception carries the same information.
2. Rebase onto master
The PR conflicts with #216, which added _DEAD_TIMEOUT next to your _CAS_MAX_RETRIES and an import at the same place in tests/backends/test_memcached.py. Keep both sides. Afterwards, ruff check will ask you to re-sort the imports in the test file.
3. Link the right issue
- In the PR description, replace "Part of #119" with
Closes #175, so the issue closes on merge. - The CHANGELOG entry links #119. Please point it at #175.
- Please move the entry to the end of the
### Fixedlist rather than the top; new entries are appended there. Its wording also needs to mention the raise from point 1.
4. Nit: use monkeypatch in the live test
test_memcached_get_and_delete_live_concurrent_write_regression assigns memcached_backend.client.gets directly. The fixture is per-test, so nothing leaks, but monkeypatch.setattr(memcached_backend.client, "gets", ...) matches the other tests in the file.
Thanks again!
…nditions get_and_delete previously issued a get followed by delete(noreply=False). If a concurrent writer updated the key between the two calls, the caller deleted the new value while returning the old one. Use gets + cas(..., exptime=-1) with bounded retries on CAS token mismatch, mirroring delete_if_equals and matching Redis GETDEL behavior. If the key was deleted or expired concurrently, return None; if retries run out, raise CacheXError. Closes allen0099#175
3234816 to
2c4f588
Compare
|
Here is the updated summary: SummaryFixes the race condition in Closes #175. Changes
Verification
|
allen0099
left a comment
There was a problem hiding this comment.
Thanks, all points addressed.
Summary
Fixes the race condition in
MemcachedBackend.get_and_delete()where a concurrent writer could store a new value betweengetanddelete, causingget_and_delete()to delete the new value from the cache while returning the stale old value.Part of #119.
Changes
MemcachedBackend.get_and_delete()from a naiveget+delete(noreply=False)sequence togetsfollowed bycas(..., -1, noreply=False)(mirroringdelete_if_equals).True: CAS won and atomically expired the item immediately; returnsdecode_entry(raw).None: Key was deleted or expired concurrently before CAS; returnsNone.False: A concurrent writer replaced the value sincegets. Retriesgets+casin a bounded loop (_CAS_MAX_RETRIES = 16) to retrieve and delete the latest value, ensuring behavior parity with RedisGETDEL.get_and_delete()without touchingincrement()or multi-step worker consolidation (Memcached: run each multi-step operation in one worker call #176).getsandcas.Nonewhen the key is deleted beforecas.Noneif writes continue past_CAS_MAX_RETRIES.getsandcason Memcached.### FixedinCHANGELOG.md.docs/BACKENDS.md,docs/STATE.md,CLAUDE.md, and Traditional Chinese translations.Verification
pytest tests/backends/test_memcached.py -k "get_and_delete"(all passed).pytest(704 passed, 0 failed).ruff checkandruff format --check(clean).mypy(0 errors across 82 files).