CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/cache-key-discriminator-audit

Audits whether a cache key carries every discriminator the cached response actually depends on, so two requests that must not share a slot cannot collide. Ranks identity discriminators (tenant, user, authorization scope, plan tier) above presentation ones (locale, region, currency, feature flag), maps each missing discriminator to the data-exposure or wrong-content consequence it causes, classifies each key-and-value pair into a critical / high / medium severity band (including the Python lru_cache-on-an-instance-method trap), and writes the fix as a namespaced key builder plus the matching HTTP Vary header. Use when a cache key is being designed or changed, when a per-user or per-tenant response is about to be stored in a shared cache or CDN, or when investigating a report that one user or tenant saw another's data.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

lru-cache-trap.mdreferences/

The lru_cache-on-an-instance-method trap (full mechanism)

The common folk description of this bug is that self is not part of the cache key, so all instances share one entry. That is wrong, and the real mechanism matters because it changes the fix.

What the Python documentation actually says:

  • functools.cached_property "stores results at the instance level" and functools.lru_cache stores them "at the class level" (Python FAQ, how do I cache method calls?).
  • "If a method is cached, the self instance argument is included in the cache" (functools docs).
  • lru_cache "creates a reference to the instance unless special efforts are made to pass in weak references", and "instances are kept alive until they age out of the cache or until the cache is cleared" (Python FAQ).
  • The arguments "must be hashable", and "Distinct argument patterns may be considered to be distinct calls with separate cache entries" (functools docs).

So the entry is keyed on the whole argument tuple, self included, in one cache that lives on the class. Whether two instances collide therefore depends entirely on how self hashes:

  • Default classes do not collide. "Objects which are instances of user-defined classes are hashable by default. They all compare unequal (except with themselves), and their hash value is derived from their id()" (Python glossary, hashable). Identity-based hashing means each instance gets its own entries. The defect here is retention, not collision: a class-level cache pins every request-scoped object it has seen until the entry ages out of maxsize.
  • Value-equality classes do collide. "Hashable objects which compare equal must have the same hash value", and "Hashability makes an object usable as a dictionary key ... because these data structures use the hash value internally" (Python glossary). A frozen dataclass, an attrs class, or any class with a hand-written __eq__ and __hash__ opts into value-based hashing. If the fields that participate in equality omit the tenant, then a repository object for tenant A and one for tenant B compare equal, hash equal, and share every cached entry. That is a genuine cross-tenant collision, and it is invisible in the key expression because the collision happens inside __hash__.

Fixes, in order of preference:

  1. Do not cache on the instance. Make the method a module-level function and pass the discriminators explicitly as arguments, so the key is readable at the call site.
  2. Use functools.cached_property when the value is per-instance and takes no arguments. It stores at the instance level and does not create a reference to the instance, so the result is released with the instance (Python FAQ).
  3. If the method must stay cached and the class defines value equality, add the missing discriminator to the equality fields so the collision cannot form.

SKILL.md

tile.json