Skip to content

Make cache keys memcached-safe - #11

Merged
willgearty merged 5 commits into
learning-unlimited:django5from
sanketkokate712:fix/memcached-safe-cache-keys
Sep 20, 2026
Merged

willgearty merged 5 commits into
learning-unlimited:django5from
sanketkokate712:fix/memcached-safe-cache-keys

Conversation

@sanketkokate712

@sanketkokate712 sanketkokate712 commented Sep 4, 2026 •

Copy link
Copy Markdown

Fixes #8. Built off django5, as requested in the issue thread.

What changed

ArgCache.key() and Token.key_filt() now hash the variable portion of the key with SHA-256, keeping the cache name — and the TOKEN__ prefix — in front so keys stay recognizable:

before: tests.caches.get_calls|test depend on cache
after:  tests.caches.get_calls|45b2a8bc0f63a5137c861a2338a00df2956b6a7c7d35bd19b25a3a2573bb33f2

before: TOKEN__tests.caches.get_calls|test depend on cache
after:  TOKEN__tests.caches.get_calls|e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

pretty_name is built from self.name and self.params, not from per-argument keys, so view_all.html renders exactly as before.

One deviation from the proposed patch

The proposal carried over the existing marinade_dish(arg).decode('UTF-8') if isinstance(marinade_dish(arg), bytes) else marinade_dish(arg) idiom. That normalization is genuinely needed — force_str() returns str(x).encode('utf8'), i.e. bytes, for anything that is not already a str — but it calls marinade_dish() twice per argument. This PR collapses it into a single marinade_dish_str() helper in marinade.py and uses that at all three call sites.

That also fixes a latent bug in marinade_dish() itself, which used the same doubled idiom in its list branch: nested lists cost 2^depth marinades, and a QuerySet argument was fully evaluated to a list twice.

Tests

New CacheKeySafetyTest promotes CacheKeyWarning to an error via warnings.catch_warnings() / simplefilter('error', CacheKeyWarning), then exercises the paths that build keys:

  • awkward arguments — spaces, tabs/newlines/nulls, frozenset, dict, nested list, None, a 500-character string, non-ASCII; each called twice, for the miss path and the hit path
  • model arguments and invalidation — cached methods, plus the token keys built and deleted when a dependent row is saved, plus delete_all() through the global token
  • the cached inclusion tag — which produced the longest and ugliest keys of the lot

These tests clear the cache and reset the shared module-level counters around themselves, because both outlive an individual test and these cases deliberately cache values under arguments that other tests also use.

Verified on Python 3.11 / Django 5.2:

  • with the fix: 22 tests, OK (1 skipped — the pre-existing @unittest.skip for Upgrade to Django 3.x and Python 3.10 #2), and zero CacheKeyWarning lines, down from 34 on django5
  • with src/ reverted: all three new tests fail, so the regression test actually bites

Notes for review

  • Key format change. Every key changes, so the first deploy after this lands starts from a cold cache and repopulates. There is no stale-read risk, since the old keys are never looked up again.
  • Known residual. Only the argument portion is hashed, per the issue. A pathologically long cache name — e.g. argcache.extras.template.CachedInclusionNode.render_given_args*<...> plus Django's :1: prefix and the 64-character digest — could still approach memcached's 250-byte cap. Happy to bound the name too if you would rather close that off in this PR.
  • The test matrix still has no memcached run, so this remains enforced only through validate_key.

Copilot AI lite review requested due to automatic review settings September 4, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes appear correct and well-covered by the new warning-as-error regression tests, with only a minor docstring capitalization nit noted.

Pull request overview

This PR makes cache keys memcached-safe by hashing the variable/argument portion of cache keys while keeping the human-recognizable cache/token name prefix intact, and adds regression tests that fail on CacheKeyWarning.

Changes:

  • Hash ArgCache.key() and Token.key_filt() argument components with SHA-256 to avoid memcached-invalid characters and unbounded key length.
  • Introduce marinade_dish_str() to normalize marinade_dish() output to str without repeated conversions.
  • Add CacheKeySafetyTest that promotes CacheKeyWarning to an error and exercises key-building paths (awkward args, model invalidation, cached inclusion tag).
File summaries
File Description
tests/tests.py Adds regression tests that error on CacheKeyWarning and exercise key construction/invalidation paths.
src/marinade.py Adds marinade_dish_str() helper and uses it to avoid repeated marinading/decoding in list handling.
src/cache_token.py Hashes the filtered-args portion of token keys while preserving TOKEN__ and token name prefixes.
src/argcache.py Hashes the argument portion of ArgCache keys while preserving the cache name prefix.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/tests.py Outdated
ArgCache.key() and Token.key_filt() built keys by stringifying arguments
through marinade_dish() with no sanitization or length bound, so keys
routinely contained spaces, brackets, braces and quotes, and could exceed
memcached's 250-byte limit. The locmem backend used by the tests only
warns (CacheKeyWarning), but a memcached backend would either error out
or silently drop the cached value.

Hash the variable portion of the key with SHA-256, keeping the cache name
(and the TOKEN__ prefix) in front so keys stay recognizable when
debugging. pretty_name is built from the name and params rather than from
per-argument keys, so the view_all page is unaffected.

While normalizing the marinaded arguments, collapse the repeated
"marinade_dish(arg) if isinstance(marinade_dish(arg), bytes)" idiom into
marinade_dish_str(). It is still needed, because force_str() returns
bytes for anything that is not already a str, but calling marinade_dish()
twice per argument meant nested lists cost 2^depth marinades and a
QuerySet argument was evaluated twice.

Add CacheKeySafetyTest, which promotes CacheKeyWarning to an error with
warnings.catch_warnings() and then exercises the key-building paths:
awkward argument types, cached model methods along with the token keys
used to expire them, and the cached inclusion tag that produced the
longest keys. The tests clear the cache and reset the shared counters
around themselves, since both outlive an individual test.

Fixes learning-unlimited#8.
@sanketkokate712
sanketkokate712 force-pushed the fix/memcached-safe-cache-keys branch from 53600be to 9c709f9 Compare September 4, 2026 19:08
force_str() still returned str(x).encode('utf8') for anything that was
not already a str, a leftover from the Python 3 migration. That is why
every caller needed an "isinstance(..., bytes)" check, which the previous
commit collected into marinade_dish_str(). Fix it at the source instead,
so marinade_dish() always returns a str and the wrapper can go.

The marinaded strings are unchanged (only their type is), so cache keys
are the same as before this commit.
Hashing only the arguments left the name unbounded, and names can get
long: the cached inclusion tag's render cache is named after both the
node class and the tag function (111 characters in the test suite). Hash
the whole thing instead, so every value key is 64 hex characters and
every token key is 71.

The hashed string is exactly what the key used to be before this PR,
name + '|' + marinaded arguments, so no two calls that had different keys
before now share one. Both ArgCache.key() and Token.key_filt() go through
the new hash_key() helper so the two can't drift apart. Token keys keep
their TOKEN__ prefix, which is now what separates them from value keys:
the global token has the same name as its cache, so for a function with
no arguments the two hashes would otherwise be identical.
cache.clear() is not implemented by every cache backend. Use
registry.dump_all_caches() instead, which calls argcache's own
delete_all() on every registered cache and so works on any backend.

Also spell GitHub with a capital H in the class docstring.
The existing jobs use the locmem backend, which only warns about keys
memcached can't store, so a key bug like learning-unlimited#8 passes CI. Add a job that
runs the same suite with PyMemcacheCache against a memcached service
container; that backend raises InvalidCacheKey for such keys, so they
fail the build instead.

tests/settings.py switches to memcached when ARGCACHE_TEST_MEMCACHED is
set to its address, and leaves the default locmem setup alone otherwise.

Run locally against memcached:1.6, the new job passes on this branch;
on django5 without the key fix, 13 of 19 tests fail with InvalidCacheKey.

@willgearty willgearty left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together! I've left some comments below. Also, as you note, we don't actually test this with memcached. If you're up for it, a matrix entry with a memcached service and CACHES set to PyMemcacheCache would turn this class of bug from advisory into a hard failure.

Comment thread tests/tests.py Outdated
Comment thread tests/tests.py Outdated
Comment thread src/marinade.py Outdated
Comment thread src/argcache.py Outdated
@sanketkokate712

Copy link
Copy Markdown
Author

Thanks for the review! I took you up on the memcached suggestion. bd200ca adds a test-memcached job (Python 3.12 / Django 5.2) with a memcached:1.6 service. tests/settings.py switches to PyMemcacheCache when ARGCACHE_TEST_MEMCACHED is set, and stays on locmem otherwise.

I made it a separate job rather than a matrix entry so the memcached container only starts where it's needed. Happy to fold it into the matrix if you'd prefer that.

Before pushing, I ran it locally against memcached:1.6. All 22 tests pass on this branch. On django5 without the fix, 13 of 19 tests fail, all with InvalidCacheKey, so this kind of bug now fails the build instead of just warning.

The workflow run is waiting for approval again.

@willgearty willgearty linked an issue Sep 19, 2026 that may be closed by this pull request

@willgearty willgearty left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for addressing my comments and for your contribution!

@willgearty
willgearty merged commit 715e8d1 into learning-unlimited:django5 Sep 20, 2026
10 checks passed
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.

Cache keys are not memcached-safe

3 participants