Skip to main content

NullCache

Caching switched off without changing call sites: NullCache, the CacheLike protocol, and keeping invalidations flowing while it’s off.

Sooner or later you need to turn caching off. In tests, so each one starts clean. In local development, so you see your change instead of yesterday’s. In production, at the worst possible moment, while you chase a bug that might be a stale entry and might not. Without help, that means if cache is not None: around every call, or a hand-written fake that drifts a little further from the real API with every release.

NullCache is a cache that’s switched off. It has the same methods and signatures as Cache, so swapping one for the other is a one-line change, and the code that uses it never knows.

from diskcache_oxide import Cache, CacheLike, NullCache

def make_cache(settings) -> CacheLike:
    if settings.cache_enabled:
        return Cache(settings.cache_dir)
    return NullCache()

One rule

A NullCache behaves like a real cache in which every entry expires the instant it’s written and is swept straight away.

Every row of this table follows from that sentence, so you never have to guess, and once it clicks you never have to look it up again:

Call Result Because
get(key, default) default the entry already expired
cache[key] raises KeyError the entry already expired
set(...) True the write succeeded, then expired
add(...) True the key was absent, so the add succeeded
incr(key, delta, default) default + delta it starts from the default each time; KeyError if default=None
delete, touch, key in cache False there’s nothing there
pop(key, default) default there’s nothing there
len(cache), cache.volume() 0 it’s empty and uses no disk
expire(), clear(), cull() 0 nothing to remove
iteration, iterkeys() nothing it’s empty
bool(cache), cache.enabled False caching is off

The constructor accepts the same arguments as Cache and ignores them, and the directory is optional. Bad argument types still raise as they would on Cache (a non-str key, expire=True), so code that passes with a NullCache in tests also works with a real one.

Watch add()

The rule means add() always succeeds on a NullCache. If you use add() to claim a job so only one worker runs it, then with caching off every worker’s claim succeeds and the job runs once per worker. That’s harmless for idempotent work and wrong for work with side effects, like sending email. Keep claims like that on a cache that’s never switched off.

Cache(durable=True) goes the other way: its add() returns False when a write fails, so a claim fails closed there and open here. Keep claims on a handle that isn’t durable and handle its exceptions. See A cache that can’t take a request down.

if cache: means “caching is on”

bool(Cache(...)) is always True and bool(NullCache()) is always False, and both have an enabled property. (In diskcache, an empty real cache is falsy, which makes this check unreliable.)

if cache:
    log.info('Caching is on')

Keep invalidating while caching is off: invalidate=

Turning caching off creates a trap. While it’s off, your app keeps changing data, and the code that would have deleted the now-stale cache entries is talking to a NullCache. When you turn caching back on, the real cache serves the stale values.

invalidate= closes the trap. Give the NullCache the real cache, and it forwards the operations that remove data to it, while everything else stays switched off:

real = Cache('/var/cache/my-app')
cache = NullCache(invalidate=real)

cache.set('user:42', profile)    # not cached
cache.get('user:42')             # None
cache.delete('user:42')          # deleted from the real cache
cache.pop('user:42')             # deleted from the real cache; returns the default
cache.clear()                    # clears the real cache
cache.iterkeys(prefix='user:')   # the real cache's keys, so list-then-delete still works

When caching comes back on, nothing you invalidated by removing it is waiting.

Only removals are forwarded, so invalidating by writing is lost while caching is off. Overwriting the stale value with a fresh set, or bumping a version key with incr so readers build a new key, does nothing, and the old value is served once caching is back on:

cache = NullCache(invalidate=real)   # real holds 'user:42' = old_profile, and 'users:version' = 3

cache.set('user:42', new_profile)    # dropped: real still has old_profile
cache.incr('users:version')          # dropped: still 3, so readers keep reading the old keys

If your app invalidates that way, delete the entry instead (or as well), or clear() the real cache when you turn caching back on.

CacheLike

CacheLike is a typing.Protocol that both Cache and NullCache satisfy. Annotate with it and ty or pyright will accept either:

from diskcache_oxide import CacheLike

def load_user(user_id: str, cache: CacheLike) -> dict:
    if (user := cache.get(f'user:{user_id}')) is not None:
        return user
    user = db.load_user(user_id)
    cache.set(f'user:{user_id}', user, expire=300)
    return user

A test in diskcache-oxide’s own suite checks that Cache, NullCache and CacheLike keep identical public signatures, so they can’t drift apart.

transact() and TransactionalCacheLike

NullCache.transact() is a no-op context manager that yields the NullCache, so code written as with cache.transact() as tx: runs unchanged with caching off. There’s nothing to commit or roll back. It accepts retry= as Cache.transact() does, and a keyword-only key=, which it ignores.

With invalidate=, it never opens a transaction on the real cache: a switched-off cache must never take a write lock or raise Timeout. So removals forwarded inside the block land at once, and an exception in the block doesn’t undo them.

CacheLike has no transact(). To accept either class and call it, annotate with TransactionalCacheLike, which adds it. See Transactions. A FanoutCache’s transact takes a key, so for code that accepts a FanoutCache or a NullCache use ShardedCacheLike instead (see Typing); the key= a NullCache ignores is what lets it satisfy both.