Caching
Caching stores the result of expensive work — a heavy query, a rendered
fragment, a third-party API call — so the next request gets it instantly instead
of recomputing. Rustango gives you one Cache trait with swappable backends
(in-memory, Redis, database), a compute-on-miss helper (get_or_set), and typed
JSON helpers. Swap the backend without touching a single call site — like
Laravel's Cache facade.
New to a term here? cache, TTL, key, backend — see the glossary.
Source:
rustango::cache(Cache,InMemoryCache,NullCache,get_or_set,get_json,set_json,BoxedCache,from_settings) — behind thecachefeature (on by default).RedisCacheneeds thecache-redisfeature (off by default).Runnable version: every snippet is copied from
cache_doc.rs(cargo test -p rustango --test cache_doc); the database backend is dogfooded on SQLite bycache_db_backend_sqlite_live.rs.
Table of contents
- Step 1 — Pick a backend
- Step 2 — get / set / delete
- Step 3 — get_or_set (cache-aside)
- Typed JSON values
- TTL and expiry
- Swapping backends
- Caching under multi-tenancy
- Reference
- See also
Step 1 — Pick a backend
Every backend implements the same Cache trait, so your code is identical
whichever you choose. App code holds a BoxedCache (Arc<dyn Cache>) and
never names the concrete type:
use rustango::cache::{BoxedCache, InMemoryCache};
use std::sync::Arc;
let cache: BoxedCache = Arc::new(InMemoryCache::new());
| Backend | Feature | [cache] backend | Use for |
|---|---|---|---|
InMemoryCache | cache | memory (default) | dev, tests, single process (per-process HashMap + TTL) |
RedisCache | cache-redis | redis | production; shared across replicas |
DatabaseCache | cache | db / database | production without Redis; a rustango_cache table |
FileCache | cache | file | one file per key under file_cache_dir; shared only if the directory is |
NullCache | cache | null / none | disable caching (every read misses) — handy in tests |
There is no postgres value — the DB backend is db or database. A
CacheSettings doc comment claimed otherwise until
#1400, and an unrecognised
value gets you an in-memory cache with a warning.
Step 2 — get / set / delete
The core of the trait is four async methods. set takes an optional TTL
(None = no expiry); get returns Option<String> (None on a miss):
use rustango::cache::{Cache, InMemoryCache};
let cache = InMemoryCache::new();
assert_eq!(cache.get("greeting").await?, None); // miss
cache.set("greeting", "hello", None).await?; // store, no expiry
assert_eq!(cache.get("greeting").await?.as_deref(), Some("hello"));
assert!(cache.exists("greeting").await?);
cache.delete("greeting").await?; // gone
There are batch variants too — get_many / set_many / delete_many.
Step 3 — get_or_set (cache-aside)
This is the one you'll reach for most. get_or_set returns the cached value, or
— on a miss — runs your factory, stores the result with a TTL, and returns it.
The factory only runs on a miss:
use rustango::cache::get_or_set;
use std::time::Duration;
let stats: HomeStats = get_or_set(
&*cache, // &dyn Cache
"home:stats",
|| async { compute_home_stats(&pool).await }, // runs only on a miss
Some(Duration::from_secs(60)), // cache for 60s
).await?;
The backing test calls get_or_set twice for the same key and asserts the
factory ran exactly once — the second call is served from the cache.
Invalidate on write. Cache-aside means stale data until the TTL expires. For data that changes, also
delete(key)when you write it — e.g. from apost_savesignal — so the next read recomputes.
Typed JSON values
get_json / set_json serialize any Serialize/Deserialize type to JSON, so
you cache structs and lists, not just strings:
use rustango::cache::{get_json, set_json};
#[derive(serde::Serialize, serde::Deserialize)]
struct Profile { id: i64, name: String }
set_json(&*cache, "profile:7", &profile, None).await?;
let back: Option<Profile> = get_json(&*cache, "profile:7").await?; // None on a miss
(get_or_set uses these under the hood, which is why its value type must be
Serialize + Deserialize.)
TTL and expiry
Pass a Duration to set (or get_or_set) and the entry disappears after it.
Verified: a 50 ms entry is readable immediately and gone after 80 ms.
cache.set("flash", "x", Some(Duration::from_millis(50))).await?;
// ...50ms later...
assert_eq!(cache.get("flash").await?, None); // expired
InMemoryCache::with_default_ttl(d) sets a default TTL applied when you pass
None.
A TTL is an upper bound, not a guarantee
InMemoryCache is size-bounded by default — 256 MiB or 100 000 entries,
whichever it hits first — with approximate-LRU eviction. A flood of unique keys
cannot grow the process without limit, but it does mean an entry can vanish
before its TTL expires if it is the least recently used when the budget is
reached. Eviction drops already-expired entries first, then the least-recently
used, until both are 10% under budget. Entries written with set_forever
(feature flags) are outside the budgets and never evicted.
So treat a cache read as "may be absent" even inside the TTL. That is true of every cache backend, but here it has a cause you can reason about and tune:
InMemoryCache::new()
.with_max_bytes(512 * 1024 * 1024) // raise the budget
.with_max_entries(0) // 0 = unbounded (pre-bounding behaviour)
TTL itself is enforced lazily, on read — there is no background eviction thread, so an expired entry still occupies its slot until something asks for it or eviction reaches it.
Swapping backends
Because everything is the Cache trait, switching from in-memory to Redis is a
one-line change at startup — usually driven by config so it differs per
environment:
// Build the cache from `[cache]` settings. `from_settings_async` is the one
// to reach for — it can build the backends that need to connect.
let cache: BoxedCache = rustango::cache::from_settings_async(&settings.cache).await?;
backend | from_settings (sync) | from_settings_async |
|---|---|---|
memory, null, file | ✓ | ✓ |
redis | panics — cannot be built synchronously | ✓ |
db | panics — needs a runtime &Pool | error; build it where the pool is |
The sync resolver panics rather than substituting a backend
(#1400). It used to warn and
return an in-memory cache, which is not a degraded shared cache — it is a
different one. A per-process cache multiplies CacheRateLimitLayer's limit by
the replica count, and stops verify_single_use failing closed, so the same
reset link works once per replica. Both of those are documented as working
because the cache is shared, and a warning at boot does not reach whoever
debugs that a week later.
db cannot come from [cache] at all, because DatabaseCache needs a pool
that settings do not carry:
let cache = DatabaseCache::new(pool.clone(), "rustango_cache");
cache.ensure_table().await?;
let boxed: BoxedCache = std::sync::Arc::new(cache);
In production, point it at Redis (shared across all your replicas):
use rustango::cache::RedisCache; // needs the `cache-redis` feature
let cache: BoxedCache = std::sync::Arc::new(RedisCache::new("redis://localhost").await?);
Same get / set / get_or_set calls — only the constructor changed.
Caching under multi-tenancy
Cache is a flat &str-keyed store, which under tenancy makes the natural
key the leaky key: a handler — or worse a background task, which has no
ambient tenant to borrow from — writes "stats:monthly" for one tenant and
every other tenant reads it back.
Wrap the shared cache in a ScopedCache so the namespace is applied for
you and the call site cannot forget:
use rustango::cache::ScopedCache;
// From the Org the resolver already produced:
let cache = ScopedCache::for_tenant(shared.clone(), &t.org.slug);
cache.set("stats:monthly", &json, ttl).await?; // stored as tenant:acme:stats:monthly
cache.get("stats:monthly").await?; // reads only acme's entry
cache.clear().await?; // drops ONLY acme's entries
ScopedCache is itself a Cache, so it drops into anything taking a
BoxedCache — cache_page, cache_fragment, the rate limiters,
DistributedLock. It forwards to the inner backend with mapped keys rather
than reimplementing anything, so native primitives (Redis INCRBY, SET NX)
keep their atomicity. get_many / set_many / delete_many forward too, but no
shipped backend batches them yet: each is one round trip per key.
Atomic counters and locks. Cache::incr backs rate limiting
and per-account lockout; Cache::add (set-if-absent) backs DistributedLock.
Cache::add is atomic on all three — RedisCache (SET NX), InMemoryCache
(which holds its lock across the read-modify-write) and DatabaseCache, which
does the test-and-set under row locks so a race resolves to exactly one winner.
Cache::incr is atomic on the first two but falls back to the non-atomic
default on DatabaseCache. So a DistributedLock is safe on any of the three;
a counter that must be exact across replicas wants Redis.
Two things worth knowing:
| It is a namespace, not a boundary | Everything still lives in one backend, and code holding the unscoped cache can read any key. The point is that the ergonomic path is the correct one. |
clear() needs key enumeration | It routes through Cache::delete_prefix, and every built-in backend implements it: InMemoryCache filters its map, DatabaseCache issues DELETE … LIKE 'prefix%' with %, _ and the escape character themselves escaped, RedisCache uses SCAN+MATCH with glob metacharacters escaped, and FileCache scans its directory and matches the key stored in each entry. A backend that does not implement it now gets an error rather than a fallback. DatabaseCache stores keys over 255 bytes hashed and matches a prefix over 190 bytes on its first 190 bytes, so it may delete extra keys, never fewer. |
The unscoped Cache::clear() is still process-global, so reach for the scoped
view whenever a single tenant's change is what triggered the invalidation.
Reference
Cache trait: get · set(key, value, ttl) · delete · exists ·
get_many / set_many / delete_many · get_or(key, default).
Free helpers: get_or_set(cache, key, factory, ttl) ·
get_json / set_json · from_settings(&CacheSettings).
What's built on the cache: server-side sessions,
distributed rate limiting (CacheRateLimitLayer), idempotency
keys, and feature flags all take a BoxedCache — so one Redis instance backs
all of them.
See also
- Background jobs — the other half of keeping requests fast (defer work instead of caching its result).
- Sessions — a server-side store built on
Cache. - Middleware —
CacheRateLimitLayershares a counter across replicas via the cache. - ORM cookbook — invalidate cached reads from a
post_savesignal.
