Cache
The cache module caches function results and arbitrary values behind a swappable backend. Use it to avoid recomputing expensive calls.
- TTLCache: cache with per-entry TTL, optional maxsize with LRU eviction, and serialization.
- @cached: decorator that caches function results automatically with stampede protection.
Quick start
Cache an async function's result with @cached. The Memory backend needs no extra service, so this runs as-is. Swap in Redis or Postgres for production:
from grelmicro import Grelmicro
from grelmicro.cache import Cache, JsonSerializer, TTLCache, cached
from grelmicro.cache.memory import MemoryCacheAdapter
micro = Grelmicro(uses=[Cache(MemoryCacheAdapter())])
cache = TTLCache(ttl=300, serializer=JsonSerializer())
@cached(cache)
async def get_user(user_id: int) -> dict:
return await db.fetch_user(user_id)
Backend
The cache is technology-agnostic and supports multiple backends.
You must load a cache backend before using TTLCache. Wire the backend
into a Grelmicro app via the Cache component. For Redis, pass the
RedisProvider directly to Cache(...).
Install
The Redis backend needs the redis extra and the Postgres backend needs the postgres extra: pip install "grelmicro[redis]" or pip install "grelmicro[postgres]". See the installation guide for uv and poetry.
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.cache.memory import MemoryCacheAdapter
micro = Grelmicro(uses=[Cache(MemoryCacheAdapter())])
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[Cache(redis)])
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.providers.postgres import PostgresProvider
postgres = PostgresProvider("postgresql://localhost:5432/app")
micro = Grelmicro(uses=[Cache(postgres)])
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.providers.sqlite import SQLiteProvider
sqlite = SQLiteProvider("app.db")
micro = Grelmicro(uses=[Cache(sqlite)])
async with micro: opens the provider and the cache backend together.
| Redis | Postgres | SQLite | Memory | |
|---|---|---|---|---|
| Use case | Production | Production (when Postgres is already deployed) | Single-host with restart durability | Testing / single-process |
| Multi-node | Yes | Yes | No (single file) | No |
| Persistence | Yes (auto-expiring keys) | Yes (table-backed) | Yes (file-backed) | No |
The Postgres adapter stores entries in a single grelmicro_cache table keyed on key TEXT PRIMARY KEY with value BYTEA and expires_at TIMESTAMPTZ. get filters expired rows with WHERE expires_at > NOW(), set is one INSERT ... ON CONFLICT DO UPDATE, delete and clear are single statements. The table is created on first connect: pass auto_migrate=False when your own migration tool owns the schema. Set cleanup_interval= to enable a background janitor that reclaims rows expired for more than one hour.
Choosing a backend
Pick the backend that matches your deployment, not the fastest one on paper.
- Memory: use for tests and single-process apps. Entries live in the process and disappear on restart. Each node keeps its own copy, so it does not share a cache across nodes.
- Redis: use for a distributed cache shared by many nodes. Keys auto-expire and reads stay fast, so this is the default for production. Reach for it when you already run or can add Redis.
- PostgreSQL: use when Postgres is already in your stack or you want table-backed persistence. It needs no extra infrastructure and survives restarts. Slightly slower than Redis, but the right default when you want one fewer moving part.
- SQLite: use for a single-host app that wants a cache surviving restarts with no extra service. Entries live in one file, so it does not share a cache across hosts.
TTLCache
TTLCache is the main cache class. It delegates storage to the registered backend and handles TTL, optional maxsize with LRU eviction, serialization, and statistics.
from grelmicro.cache import TTLCache
# Uses the registered backend (MemoryCacheAdapter or RedisCacheAdapter)
cache = TTLCache(maxsize=100, ttl=300)
# Or pass a backend explicitly
cache = TTLCache(maxsize=100, ttl=300, backend=my_backend)
All TTLCache methods are async:
await cache.set("key", b"value")
result = await cache.get("key")
await cache.delete("key")
await cache.clear()
Serialization
Backends store raw bytes. To cache Python objects, pass a serializer:
Type-safe roundtrips using Pydantic's Rust-based TypeAdapter (fastest option):
from pydantic import BaseModel
from grelmicro.cache import PydanticSerializer, TTLCache
class User(BaseModel):
id: int
name: str
cache = TTLCache[User](ttl=300, serializer=PydanticSerializer(User))
await cache.set("user", User(id=1, name="Alice"))
user = await cache.get("user") # returns User instance
For plain dicts and lists, using orjson when available:
from grelmicro.cache import JsonSerializer, TTLCache
cache = TTLCache(ttl=300, serializer=JsonSerializer())
await cache.set("user", {"id": 1, "name": "Alice"})
user = await cache.get("user") # returns dict
For any picklable Python object. Use only with trusted, in-process
backends. Deserialization can execute arbitrary code, so a shared
or compromised backend can run code inside the application. Prefer
JsonSerializer or PydanticSerializer for shared backends like
Redis or Memcached.
from grelmicro.cache import PickleSerializer, TTLCache
cache = TTLCache(ttl=300, serializer=PickleSerializer())
await cache.set("data", {"complex": [1, 2, 3]})
data = await cache.get("data")
Without a serializer, only bytes values are accepted.
Per-Entry TTL
Override the default TTL for individual entries:
await cache.set("session", b"token", ttl=3600) # 1 hour instead of default
Get or Set
get_or_set returns the cached value, or computes it once and stores it. Pass a sync or async factory. It runs only on a miss:
user = await cache.get_or_set(
"user:1",
lambda: fetch_user(1),
tags=["users"],
)
The factory shares the same stampede protection as @cached(lock=True). When many callers miss the same key at once, the factory runs once and the rest reuse its result. This works across replicas when a Coordination backend is configured.
Pass stale_ttl= to serve the last good value when the factory fails, the same serve-stale-on-error behavior as @cached(stale_ttl=...).
from grelmicro import Grelmicro
from grelmicro.cache import Cache, JsonSerializer
from grelmicro.cache.memory import MemoryCacheAdapter
cache = Cache(MemoryCacheAdapter())
micro = Grelmicro(uses=[cache])
ttl_cache = cache.ttl(ttl=300, serializer=JsonSerializer())
async def main() -> None:
async with micro:
# The factory runs only on a miss, then the value is cached.
user = await ttl_cache.get_or_set(
"user:1",
lambda: {"id": 1, "name": "Alice"},
tags=["users"],
)
print(user)
Batch Operations
Read, write, and delete many keys in one call:
await cache.set_many({"user:1": user1, "user:2": user2}, tags=["users"])
found = await cache.get_many(["user:1", "user:2", "user:3"])
# Missing keys are absent from the result.
await cache.delete_many(["user:1", "user:2"])
from grelmicro import Grelmicro
from grelmicro.cache import Cache, JsonSerializer
from grelmicro.cache.memory import MemoryCacheAdapter
cache = Cache(MemoryCacheAdapter())
micro = Grelmicro(uses=[cache])
ttl_cache = cache.ttl(ttl=300, serializer=JsonSerializer())
async def main() -> None:
async with micro:
# Write many entries in one call.
await ttl_cache.set_many(
{"user:1": {"id": 1}, "user:2": {"id": 2}},
tags=["users"],
)
# Read many keys at once. Missing keys are absent from the result.
found = await ttl_cache.get_many(["user:1", "user:2", "user:3"])
print(found)
# Delete many keys in one call.
await ttl_cache.delete_many(["user:1", "user:2"])
Tags and Invalidation
Tags group entries so you can drop a whole group at once. Tag an entry on set, set_many, or get_or_set, then invalidate by tag with delete_tags:
await cache.set("user:1", user, tags=["users", "user:1"])
await cache.delete_tags("user:1") # drop one user
await cache.delete_tags("users") # drop every user
The @cached decorator takes tags too. Each tag is a template filled in from the call's arguments, so one decorator tags every entry with both a shared tag and a per-call tag:
@cached(cache, tags=["users", "user:{user_id}"])
async def get_user(user_id: int) -> dict:
return await db.fetch_user(user_id)
# Later, after a write:
await cache.delete_tags("user:42") # drop the entry for user_id=42
await cache.delete_tags("users") # drop every cached user
Literal tags with no {...} pass through unchanged. Tags work the same across Memory, Redis, and Postgres. Invalidating by tag stays consistent even when keys expire on their own.
from grelmicro import Grelmicro
from grelmicro.cache import Cache, JsonSerializer, cached
from grelmicro.cache.memory import MemoryCacheAdapter
cache = Cache(MemoryCacheAdapter())
micro = Grelmicro(uses=[cache])
ttl_cache = cache.ttl(ttl=300, serializer=JsonSerializer())
@cached(ttl_cache, tags=["users", "user:{user_id}"])
async def get_user(user_id: int) -> dict:
return {"id": user_id, "name": "Alice"}
async def update_user(user_id: int) -> None:
# Drop only this user's cached entry.
await ttl_cache.delete_tags(f"user:{user_id}")
async def reset_all_users() -> None:
# Drop every cached user at once.
await ttl_cache.delete_tags("users")
async def main() -> None:
async with micro:
await get_user(1)
await update_user(1)
await reset_all_users()
Keep keys and tags bounded
Every distinct key and tag is stored. On the memory backend the tag-to-key map grows with cardinality and is not evicted until the tagged entries expire. Deriving keys or tags straight from untrusted input (a raw user id, a full URL, a free-text field) lets a caller inflate memory or backend storage without limit. Map untrusted values onto a bounded set first, such as a hash bucket or an allowlist, and prefer a short shared tag plus one per-entity tag over a fresh tag per request.
@cached Decorator
The @cached decorator automatically caches function results. It works with both sync and async functions.
For the plain "memoize this function for N seconds" case, pass ttl= and nothing else. The decorator builds a private process-local cache for this function alone:
from grelmicro.cache import cached
@cached(ttl=30)
async def get_rates() -> dict:
return await fetch_rates()
That private cache lives only in this process and is never shared across replicas. To share results across replicas, invalidate by tag, or reuse one store across functions, pass a TTLCache instead:
from grelmicro.cache import JsonSerializer, TTLCache, cached
cache = TTLCache(ttl=300, serializer=JsonSerializer())
@cached(cache)
async def get_user(user_id: int) -> dict:
return await db.fetch_user(user_id)
Passing both cache and ttl, or neither, raises TypeError.
Custom Keys
By default @cached derives the key from the repr() of the arguments. Pass key= for a stable, readable key instead. The template fills in from the call's arguments, so key="user:{user_id}" keys the entry under user:42 for a call with user_id=42:
@cached(cache, key="user:{user_id}")
async def get_user(user_id: int) -> dict:
return await db.fetch_user(user_id)
Arguments not named in the template do not affect the key, so calls that differ only in those arguments share one entry. Defaults fill in when an argument is omitted. For a fully dynamic key, pass a key_maker callable instead. It receives (func, args, kwargs) and returns the key. Passing both key and key_maker raises TypeError. A custom key fully determines the lookup, so typed= has no effect when key= or key_maker is set.
from grelmicro import Grelmicro
from grelmicro.cache import Cache, JsonSerializer, cached
from grelmicro.cache.memory import MemoryCacheAdapter
cache = Cache(MemoryCacheAdapter())
micro = Grelmicro(uses=[cache])
ttl_cache = cache.ttl(ttl=300, serializer=JsonSerializer())
@cached(ttl_cache, key="user:{user_id}")
async def get_user(user_id: int) -> dict:
return {"id": user_id, "name": "Alice"}
async def main() -> None:
async with micro:
# Keyed under "user:1" instead of the default argument-repr key.
await get_user(1)
await ttl_cache.delete("user:1")
Stampede Protection
A cache stampede (or "dog-pile") happens when many callers miss the same key at once and all recompute it together. By default @cached folds those misses in-process (lock="local"). Raise it to lock=True to fold across replicas, drop it to lock=False to opt out, and add early= to refresh hot keys before they expire:
| Setting | What it does | Cost | Use when |
|---|---|---|---|
lock="local" (default) |
fold misses in-process only, never touches a backend | free, no I/O | the common case |
lock=True |
fold concurrent misses, across replicas when a Coordination backend is configured |
one backend acquire per cold miss | you need cross-replica dedup |
lock=False |
no protection, every concurrent miss recomputes | none | misses are cheap or rare |
early=0.1 |
probabilistic early refresh (XFetch) in the last 10% of the TTL | one background recompute per refresh | the hottest keys, where no caller should ever block |
lock=True always dedups in-process first, so the backend is hit at most once per cold miss. early= works alongside any lock mode.
@cached(cache) # default: in-process stampede folding
async def get_user(user_id: int) -> dict:
return await db.fetch_user(user_id)
@cached(cache, lock=True) # fold misses, across replicas if a lock backend is set
async def get_billing(user_id: int) -> dict:
return await billing.fetch(user_id)
@cached(cache, early=0.1) # refresh hot keys before they expire
async def get_homepage_feed() -> dict:
return await build_feed()
lock is per-key: concurrent misses on different keys run in parallel. Only callers that request the same key wait in turn, so one slow computation does not block unrelated keys.
lock=True folds misses across replicas when the active Grelmicro app has a Coordination backend, and folds them in-process when it does not. Use lock="local" to force the in-process path even when a Coordination backend is configured.
early= returns the cached value immediately and recomputes in the background, so a hot key refreshes before it expires and no caller ever waits on a cold miss. It costs one extra recompute per refresh and stores a small sidecar entry next to the value so replicas coordinate the refresh window.
When to use: your cached function is expensive (database query, API call, heavy computation) and may be called concurrently with the same arguments.
Serve Stale on Error
Set stale_ttl to keep serving the last good value when a recompute fails. Each result is also kept as a fallback copy for ttl + stale_ttl seconds. After the TTL, the next miss recomputes as usual, but if that recompute raises, the most recent value is served instead of propagating the error, for up to stale_ttl seconds past the TTL.
cache = TTLCache(ttl=60)
@cached(cache, stale_ttl=600)
async def get_exchange_rates() -> dict:
return await rates_api.fetch() # a flaky external call
A flaky upstream then degrades to slightly stale data instead of an error storm. Once the recompute succeeds again, the fresh value takes over. If the upstream stays down longer than stale_ttl, the error propagates.
stale_ttl composes with lock and early. An explicit cache.delete(...) or cache.delete_tags(...) drops the fallback too, so invalidation is never undone by a later stale serve. Each stale serve records the grelmicro.cache.stale_serves metric, so a rising count signals an unhealthy upstream.
Decorator Parameters
cache and ttl are mutually exclusive. Pass one or the other, not both.
| Parameter | Type | Default | Description |
|---|---|---|---|
cache |
TTLCache |
None |
The cache instance to store results in. Mutually exclusive with ttl. |
ttl |
float |
None |
TTL in seconds for a private per-function cache. Mutually exclusive with cache. |
maxsize |
int |
0 |
Max entries in the private per-function cache, 0 means unlimited (used only when ttl is set). |
key |
str |
None |
Key template rendered from the arguments, like "user:{user_id}". Mutually exclusive with key_maker. |
key_maker |
Callable |
None |
Custom key generation function. Receives (func, args, kwargs). Mutually exclusive with key. |
skip |
Callable |
None |
Predicate receiving the result. Returns True to skip caching. |
typed |
bool |
False |
Cache arguments of different types separately. |
lock |
True, False, or "local" |
"local" |
Concurrent-miss (stampede) protection. |
early |
float in [0, 1) |
None |
Probabilistic early refresh in the late TTL window. |
stale_ttl |
float |
None |
Serve-stale-on-error budget in seconds. Serve the last good value for this long past the TTL when a recompute fails. |
tags |
Sequence[str] |
() |
Tags to attach to each result. Templates like "user:{user_id}" fill in from the arguments. Invalidate with cache.delete_tags(...). |
Redis Backend Configuration
The Redis URL can be passed directly or read from environment variables:
| Environment Variable | Description | Default |
|---|---|---|
REDIS_URL |
Full Redis URL (e.g. redis://localhost:6379/0) |
|
REDIS_HOST |
Redis hostname | |
REDIS_PORT |
Redis port | 6379 |
REDIS_DB |
Redis database number | 0 |
REDIS_PASSWORD |
Redis password |
Set either REDIS_URL or REDIS_HOST (not both).
Use the prefix parameter to isolate cache keys from other data in the same Redis instance.
Warning
Cache Key Stability: Cache keys are derived from repr() of function arguments. Keys are stable within a single process but may vary across Python versions. Objects with default __repr__ (e.g., custom class instances) include memory addresses, which means cache misses will always occur. Use a custom key_maker for such objects.