Rustango docs
← Guides

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.

Caching in Rustango: get_or_set checks the cache, runs the factory only on a miss, stores the result with a TTL, and serves hits instantly; the same Cache trait backs InMemory, Redis, and DB

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 the cache feature (on by default). RedisCache needs the cache-redis feature (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 by cache_db_backend_sqlite_live.rs.

Table of contents


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());
BackendFeature[cache] backendUse for
InMemoryCachecachememory (default)dev, tests, single process (per-process HashMap + TTL)
RedisCachecache-redisredisproduction; shared across replicas
DatabaseCachecachedb / databaseproduction without Redis; a rustango_cache table
FileCachecachefileone file per key under file_cache_dir; shared only if the directory is
NullCachecachenull / nonedisable 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 a post_save signal — 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?;
backendfrom_settings (sync)from_settings_async
memory, null, file✓✓
redispanics — cannot be built synchronously✓
dbpanics — needs a runtime &Poolerror; 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 boundaryEverything 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 enumerationIt 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 — CacheRateLimitLayer shares a counter across replicas via the cache.
  • ORM cookbook — invalidate cached reads from a post_save signal.