Cache¶
Some work is expensive and rarely changes — a dashboard aggregate, a slow third-party API response, a rendered fragment. Recomputing it on every request is pure waste. A cache lets you compute once and serve the result from a fast key/value store until it's worth recomputing.
arvel's cache is an async store over cashews, with the backend
selectable by config. This page covers the basic get/put API, the remember compute-or-fetch
helper, the @cached decorator, choosing a backend, and atomic locks.
Needs the [redis] extra to share or persist
The in-process array driver is core — the default, great for one instance and tests. A cache
shared across instances and surviving restarts needs Redis: uv add 'arvel[redis]', then set
cache.default = "redis".
Basic usage¶
from arvel.support import cache # the global helper → the default driver
await cache().put("answer", 42, ttl=600) # store for 10 minutes
await cache().get("answer") # 42
await cache().get("missing", default=0) # 0
await cache().has("answer") # True — tests presence, so a stored None still counts
await cache().forget("answer") # delete
has() reports whether the key exists, not whether its value is truthy: after put("k", None),
has("k") is True. Use it to tell a cached None apart from a miss (get with a default can't).
cache() mirrors config() — it returns the default cache driver, so you never hand-build
CacheManager().driver(). (It lives in arvel.support, not arvel, because the bare name
arvel.cache is the cache package.)
Remember¶
remember returns the cached value if present, otherwise runs the callback, caches its
result, and returns it — the single most useful cache pattern:
async def expensive() -> list[dict]:
return await db.select("SELECT * FROM report")
rows = await cache().remember("daily-report", ttl=3600, callback=expensive)
Use remember_forever to cache without expiry:
Memoize a function with @cached¶
To cache an async function's result keyed by its arguments, decorate it:
from arvel import cached
@cached(ttl=300)
async def top_posts(limit: int):
return await Post.where(published=True).order_by("-views").limit(limit).get()
The first call computes and stores; later calls with the same args return the cached value (a
cached None is remembered too, not recomputed). Pass key="…" to fix the cache key, or use bare
@cached for forever.
Choosing a backend¶
The active driver comes from cache.default in config (defaults to array, the in-memory
store, which needs no extras). Redis is enabled by installing the [redis] extra and setting
cache.url to your Redis URL.
Named stores¶
For more than one store — say a shared app cache and a separate Redis for throttle counters — name
them under stores, each with its own driver (and, for redis, its own url). Resolve one by
name with CacheManager().driver("<name>"); a store's driver is what selects the backend, so two
stores can share a driver, and a store can be named anything:
# config/cache.py
CACHE = {
"default": "array",
"url": env("REDIS_URL"), # the default redis url
"stores": {
"sessions": {"driver": "redis", "url": env("SESSION_REDIS_URL")},
"throttle": {"driver": "redis"}, # uses the top-level url
},
}
More verbs: add, pull, forever, touch, decrement¶
await cache().add("lock:seeded", True, ttl=60) # True first time, False while present
await cache().pull("flash-message") # get, then delete, in one call
await cache().forever("app-settings", settings) # store with no expiry
await cache().touch("session:abc", 1800) # refresh a key's TTL (alias of `expire`)
await cache().decrement("stock:sku-1", 3) # mirrors `increment`
add is atomic — on Redis it's a single SET NX, so concurrent callers racing to seed the
same key never both "win": exactly one Cache.add returns True.
Stale-while-revalidate with flexible¶
flexible serves a cached value immediately, but recomputes it in the background once it's a
little stale — callers never wait on the recompute unless the value is old enough to be unusable:
posts = await cache().flexible(
"top-posts",
(60, 3600), # fresh for 60s; stale-but-servable for up to 1h
load_top_posts,
)
- Within 60s of the last compute: serves the cached value.
- Between 60s and 1h: serves the (stale) cached value immediately, and fires a single background recompute (concurrent stale hits during this window all get served the same stale value — only one of them actually triggers a recompute).
- Past 1h, or on a miss: recomputes inline and waits for the result.
Atomic locks¶
A lock prevents two workers from doing the same work at once (e.g. regenerating a report).
lock() returns a CacheLock with an owner token (secrets.token_hex, unique per handle) — only
the holder that acquired it can release it:
cache = CacheManager().driver()
async with cache.lock("report:daily", seconds=60):
await regenerate_report() # only one holder runs this at a time; raises
# LockAcquireFailed immediately if already held
async with lock: tries once and raises LockAcquireFailed if the lock is already held — to
wait instead, use block:
lock = cache.lock("report:daily", seconds=60)
try:
await lock.block(wait_seconds=5) # retries until acquired or LockTimeout
await regenerate_report()
finally:
await lock.release() # no-ops if this handle doesn't own it anymore
force_release()frees the lock unconditionally, regardless of owner (an operator's escape hatch for a wedged lock).restore_lock(name, owner)recreates a lock handle from a stored owner token — release a lock from a different process/instance than the one that acquired it.- The
secondsTTL bounds how long the lock is held even if the holder crashes, so a dead process can't wedge it forever.
Tags¶
Tag related cache entries so they can be invalidated together — a plain flush() clears
everything, but tags scope that down:
await cache().tags("people", "artists").put("user:1:profile", profile, ttl=3600)
await cache().tags("people", "artists").get("user:1:profile") # visible through these tags
await cache().get("user:1:profile") # None — untagged read misses it
await cache().tags("people").flush() # removes every entry ever tagged "people"
An entry written through a tag combination is readable only through that same combination;
flushing a single tag removes every entry ever written with it, even via a different combination
(so tags("people", "artists")->flush()'s only tag, "people", also invalidates it).
The Redis facade¶
For direct Redis access — commands, pipelines, pub/sub — beyond what the cache abstraction
offers, use the Redis facade (needs the [redis] extra, same as the redis cache driver):
from arvel.support.facades import Redis
await Redis.command("SET", "greeting", "hi")
await Redis.command("GET", "greeting") # b"hi"
async with Redis.pipeline() as pipe: # batch N ops in one round trip
pipe.command("INCR", "views").command("INCR", "likes")
incremented_views, incremented_likes = await pipe.execute()
await Redis.publish("orders", "order.created:42")
async for message in Redis.subscribe("orders"):
print(message) # yields each published message
break
await Redis.eval("return redis.call('GET', KEYS[1])", keys=["greeting"])
Configure the connection via the redis config section (redis.url for the default connection,
redis.connections.{name}.url for additional named ones); Redis.connection("reporting")
resolves a named connection.
Worked example: cache-aside in a handler¶
async def dashboard(request):
stats = await cache().remember(f"stats:{request.user().id}", ttl=300, callback=compute_stats)
return {"stats": stats}
Common mistakes & gotchas¶
rememberwith a per-user key but a shared name. Key by what varies (stats:{user_id}), or one user sees another's cached value.- Caching
Noneambiguously.get(key)returns the default (None) for a miss and for a storedNone— pass a sentinel default if you must tell them apart. - No TTL on volatile data.
putwithoutttlkeeps the value until evicted/forgotten; set a TTL for anything that goes stale, and use a lock around expensive regeneration to avoid a stampede when it expires. - Expecting
arrayto persist. The in-memory store is per-process and cleared on restart — use Redis for anything shared across workers or restarts.
How it works¶
Cache is a facade over a CacheManager (a driver manager); driver() resolves the configured
backend and caches the CacheRepository wrapping a real cashews.Cache. remember is
get-or-set: a hit returns immediately, a miss runs the callback, stores the result under the
TTL, and returns it. add uses cashews' atomic set(..., exist=False) (a single SET NX on
Redis). flexible stores (value, stored_at) and fires its background revalidation via
asyncio.create_task, single-flight-guarded by add() so concurrent stale hits refresh once.
Locks are arvel's own CacheLock (not cashews' built-in lock, which lacks owner
semantics): Redis acquires with a raw SET NX PX and releases with a Lua compare-and-delete
script (atomic across processes); the array driver uses a plain in-process dict (no await
between its check and its write, so nothing else can interleave). Tags key entries by their
sorted tag-name combination and track membership per tag via cashews' generic set_add/
set_pop commands — works identically on both drivers, so flush() doesn't need driver-specific
code. cashews (and redis.asyncio, for the Redis facade) are imported lazily, so import arvel
stays light until you actually touch the cache or Redis.
See also¶
- Queues & Jobs — cache a job's expensive lookups.
- About arvel — the cashews engine behind the cache.