A user changes their display name, and for the next five minutes half the site still uses the old one: the profile page, the sidebar widget, three API responses, each cached on its own schedule and each wrong in its own way. A single change often makes many cache entries stale, and deleting them one by one means knowing every key that was ever derived from that user. There are two good ways to handle this. Both start with a naming convention.
Name keys hierarchically
Put the thing a key depends on at the front of the key, from the general to the specific:
user:42:profile
user:42:sidebar
user:42:api:/v1/me
episode:512:page
episode:512:transcript
Everything about user 42 now shares the prefix user:42:. Note the trailing colon: without it, user:4 would also match user:42.
Option 1: delete by prefix
iterkeys(prefix=...) is an indexed range scan, so finding a user’s keys costs time in proportion to how many there are, not the size of the cache. It fetches keys a page at a time with no lock held in between, so deleting while iterating is safe.
import tempfile
from diskcache_oxide import Cache
cache = Cache(tempfile.mkdtemp())
for key in ['user:42:profile', 'user:42:sidebar', 'user:420:profile', 'episode:512:page']:
cache.set(key, '...', expire=3600)
def invalidate_prefix(prefix: str) -> int:
removed = 0
for key in cache.iterkeys(prefix=prefix):
removed += cache.delete(key)
return removed
print(invalidate_prefix('user:42:'), 'removed')
print(sorted(cache))
2 removed
['episode:512:page', 'user:420:profile']
This is simple and frees the space right away. It costs one small write per key, so it’s best when a group has tens or hundreds of keys, not hundreds of thousands.
Option 2: generation numbers
Instead of deleting anything, keep a generation number per group and put it in every key. To invalidate the group, change the generation. Every old key becomes unreachable at once, in a single write, however many keys there were. The old entries are never read again, and they leave the cache when they expire or are evicted.
import time
def generation(user_id: int) -> int:
key = f'gen:user:{user_id}'
gen = cache.get(key)
if gen is None:
cache.add(key, time.time_ns()) # if several workers race, one value wins
gen = cache.get(key)
return gen
def user_key(user_id: int, name: str) -> str:
return f'user:{user_id}:g{generation(user_id)}:{name}'
def invalidate_user(user_id: int) -> None:
cache.set(f'gen:user:{user_id}', time.time_ns())
cache.set(user_key(7, 'profile'), {'name': 'Sam'}, expire=3600)
print(cache.get(user_key(7, 'profile')))
invalidate_user(7)
print(cache.get(user_key(7, 'profile')))
Generation numbers cost one extra read per lookup (the generation, which is tiny and hot) and make invalidation free. Give the real entries an expire, since nothing deletes them.
The generations are timestamps rather than a counter starting at 0 for a reason. A generation key is written rarely, and the cache evicts the least recently stored entries first, so under size pressure it can be evicted. A counter would then restart at 0 and bring old entries back to life. A new timestamp is always larger than any generation used before, so a lost generation only costs a few cache misses.
Which to use
- Prefix deletes when groups are small and you want the space back immediately.
- Generation numbers when groups are large, or invalidation is frequent, or many workers might invalidate the same group at the same time.
While caching is switched off
If you turn caching off with NullCache, pass the real cache as invalidate=. Prefix deletes still reach the real cache (iterkeys and delete are both forwarded), so nothing stale is waiting when caching comes back on. See NullCache.
Tags, which group keys without a naming convention, are planned (see the roadmap).