Skip to content

perf(memcached): one worker call per operation; delete_many counts removals - #273

Merged
allen0099 merged 1 commit into
masterfrom
perf/memcached-batching-174-176
Sep 26, 2026
Merged

allen0099 merged 1 commit into
masterfrom
perf/memcached-batching-174-176

Conversation

@allen0099

@allen0099 allen0099 commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #174, closes #176. With #175 and #177 already merged, this completes #119.

delete_many (#174)

MemcachedBackend inherited the base per-key loop. That meant one asyncio.to_thread hop per key, and it returned the number of keys it was given.

pymemcache's own delete_many doesn't fix this. It always returns True, and on HashClient it still sends one DELETE per key (_run_cmd("delete", key, ...) in a loop), so it can't report what was removed.

The override instead:

  • runs the whole batch in one worker call;
  • sends an acknowledged DELETE (noreply=False) per key, which is the same number of round trips as pymemcache's batched call;
  • counts only the keys that existed;
  • namespaces and hashes keys through _make_key (over-long or illegal keys included), and sends a duplicate key only once;
  • returns 0 without I/O for an empty list.

This now matches the memory and Redis backends, which already count removals. The base-class docstring saying "Memcached keeps this per-key loop" is updated.

One thread hop per multi-step operation (#176)

Before this change, each round trip ran in its own asyncio.to_thread call: up to three for increment and two for each of the other three. Each operation now runs in a single sync helper inside one asyncio.to_thread call:

  • increment: INCR, ADD, INCR;
  • get_and_delete: the GETS/CAS loop;
  • delete_if_equals and expire_if_equals: GETS, compare, CAS.

Unchanged behaviour:

  • the CAS loop still retries 16 times, then raises CacheXError;
  • validate_ttl/validate_delta still run before any I/O;
  • the "not a counter" / "counter vanished" error mapping is the same;
  • an unreachable server still raises.

One small fix along the way: expire_if_equals now converts its ttl before the GETS, so a ttl past 2038-01-19 raises before any I/O, as set/set_if_absent/increment already did. Before, the GETS ran first.

Docs

  • docs/BACKENDS.md (EN and zh-TW): one worker trip per call, and what delete_many() returns now.
  • CLAUDE.md: updated to match.

Tests

New tests:

  • test_memcached_delete_many_counts_only_existing_keys (live): the count includes a hashed 300-byte key and ignores a duplicate and a missing key.
  • test_memcached_delete_many_sends_each_namespaced_key_once (stub): checks the namespaced/hashed keys, the dedup, and noreply=False.
  • test_memcached_delete_many_with_no_keys_does_no_io.
  • test_memcached_multi_step_operations_take_one_thread_hop, parametrized over the five operations. It counts asyncio.to_thread calls on each operation's longest path.
  • test_ttl_past_2038_is_rejected_before_io gains an expire_if_equals case.

Mutation checks:

  • With master's memcached.py, exactly 7 tests fail: the 5 single-hop cases, the stub delete_many test and the new 2038 case.
  • Counting keys attempted instead of removed, or dropping the dedup, each fails the stub delete_many test.

Verification:

  • uv run pytest without live servers: 808 passed, 191 skipped.
  • pre-commit passes, and both strict docs builds pass.
  • Full suite against live Redis and Memcached (coordinator run, commit 79edb4a): 999 passed.

CHANGELOG

Under Unreleased → Changed:

- **Memcached runs each operation in one worker call, and `delete_many()`
  counts what it removed.** `increment`, `get_and_delete`,
  `delete_if_equals` and `expire_if_equals` used to hand every round trip to
  its own worker thread; `delete_many()` took one per key and returned how
  many keys it was given. Each call now makes a single trip, and
  `delete_many()` returns how many of the keys existed.
  `expire_if_equals` rejects a ttl past 2038-01-19 before any I/O.
  ([#174](https://github.com/allen0099/FastAPI-CacheX/issues/174),
  [#176](https://github.com/allen0099/FastAPI-CacheX/issues/176))

…movals

delete_many inherited the base per-key loop: one thread hop per key, and
it returned the number of keys it was given. It now sends one
acknowledged DELETE per unique namespaced key inside a single worker call
and counts the ones that existed. pymemcache's own delete_many returns
True regardless and still issues one DELETE per key on HashClient, so it
cannot report removals.

increment, get_and_delete, delete_if_equals and expire_if_equals each
ran every round trip in its own asyncio.to_thread call. Each now runs as
one sync helper in a single call, with the same CAS retry, validation and
error-mapping behaviour. expire_if_equals converts its ttl before the
GETS, so a ttl past 2038 fails before any I/O like set does.

Closes #174
Closes #176
@allen0099 allen0099 added this to the 0.3.8 milestone Sep 26, 2026
@allen0099 allen0099 added enhancement New feature or request backends Cache backends and their atomic primitives labels Sep 26, 2026
@allen0099
allen0099 merged commit c15ad19 into master Sep 26, 2026
11 checks passed
@allen0099
allen0099 deleted the perf/memcached-batching-174-176 branch September 26, 2026 23:04
allen0099 added a commit that referenced this pull request Sep 27, 2026
Copies the CHANGELOG sections of #207, #208, #209, #211, #212, #272,
#273, #274, #275, #276, #279 and #284 into Unreleased. The #273 entry
drops expire_if_equals from its list of methods that changed, since that
primitive is new in 0.3.8.
allen0099 added a commit that referenced this pull request Sep 27, 2026
Copies the CHANGELOG sections of #207, #208, #209, #211, #212, #272,
#273, #274, #275, #276, #279 and #284 into Unreleased. The #273 entry
drops expire_if_equals from its list of methods that changed, since that
primitive is new in 0.3.8.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backends Cache backends and their atomic primitives enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memcached: run each multi-step operation in one worker call Memcached: override delete_many with a batched delete

1 participant