Cache
The cache layer mirrors Laravel’s Cache facade. Configure stores in
config/cache.toml; reach them with oxide::cache::Cache::* from
anywhere. Three drivers ship today: array (in-memory), file
(disk), redis (cargo feature).
API
use oxide::cache::Cache;use std::time::Duration;
// Readlet user: Option<User> = Cache::get("user:42").await?;
// Write with TTLCache::put("user:42", &user, Some(Duration::from_secs(300))).await?;
// Write foreverCache::forever("config:flags", &flags).await?;
// Get-or-compute (the killer pattern)let user: User = Cache::remember("user:42", Duration::from_secs(300), || async { User::find_id(42).await?.ok_or_else(|| anyhow::anyhow!("not found"))}).await?;
// Forever variantlet cfg: Cfg = Cache::remember_forever("cfg", || async { build_cfg().await }).await?;
// Existence + deleteCache::has("user:42").await?;Cache::forget("user:42").await?;
// Atomic countersCache::increment("page_views", 1).await?;Cache::decrement("stock", 1).await?;
// WipeCache::flush().await?;
// Different storeCache::store("array").get::<Foo>("k").await?;Values are serialized via serde_json — anything Serialize + DeserializeOwned works.
Configuration
config/cache.toml
default = "${CACHE_DRIVER:-redis}"prefix = "${CACHE_PREFIX:-myapp_cache_}"
[stores.array]driver = "array"
[stores.file]driver = "file"path = "storage/framework/cache/data"
[stores.redis]driver = "redis"connection = "cache" # name from database.toml [redis.<name>]prefix is prepended to every key — collision avoidance when sharing
Redis with other apps. Per-store override available with
prefix = "..." on a specific [stores.X] block.
config/database.toml [redis.*] section
[redis.options]prefix = "${REDIS_PREFIX:-myapp_database_}"
[redis.default]host = "${REDIS_HOST:-127.0.0.1}"port = "${REDIS_PORT:-6379}"database = "${REDIS_DB:-0}"
[redis.cache]host = "${REDIS_HOST:-127.0.0.1}"port = "${REDIS_PORT:-6379}"database = "${REDIS_CACHE_DB:-1}"Same Redis instance, different DB numbers — Laravel-shape isolation.
Cache writes go to DB 1; queue writes go to DB 0. To use separate
Redis instances, override REDIS_URL per-connection or set distinct
host values.
Drivers
| Driver | Cargo feature | When to use |
|---|---|---|
array | always available | tests, per-process memoization |
file | always available | tiny apps without Redis; CI; dev with no docker |
redis | features = ["redis"] | production |
database | — | not yet implemented; use Redis or file |
The default scaffold from oxide new enables features = ["redis"]
on the oxide dep.
Get-or-compute pattern
The most useful method by far. Read-through cache: returns the cached value if present, otherwise calls the closure, stores the result, and returns it.
let stats: Stats = Cache::remember("homepage:stats", Duration::from_secs(60), || async { expensive_aggregate_query().await}).await?;Cache hit → instant. Miss → runs the query, stashes for 60s, returns. Subsequent calls in the next 60s hit the cache.
remember_forever for values that don’t change (“flags loaded at
boot”, “the public key”). Pair with Cache::forget("flags") from
your admin endpoint to invalidate.
Atomic counters
Cache::increment("page_views", 1).await?;Cache::increment("page_views", 5).await?;let n = Cache::increment("page_views", 0).await?;Redis driver uses INCRBY — atomic across processes. Array driver is
process-local, no atomicity guarantee across replicas. File driver is
read-modify-write — best-effort, not safe under concurrent writers
across processes.
Switching stores
// Default store (whatever cache.default points at)Cache::get("k").await?;
// Named storeCache::store("array").put("session:tmp", &v, None).await?;Cache::store("file").get::<Cfg>("baked").await?;Cache::store(name) returns a CacheClient — same methods as the
static facade, scoped to that store. Useful for explicit
multi-cache patterns (hot data in array, persistent in redis).
Single-key scope vs full flush
Cache::forget("user:42").await?; // single keyCache::flush().await?; // every key in this storeThe Redis driver’s flush walks SCAN over <prefix>* and deletes
matches — does not issue FLUSHDB, so other apps sharing the
same Redis are untouched. The file driver rm -rfs the cache root
and recreates it. Array driver clears its HashMap.
Mental model
Cache::* (facade) │ └─ resolves via Cache::default_store() │ ▼ [config/cache.toml] default = "redis" │ ▼ [stores.redis] driver="redis", connection="cache" │ ▼ [config/database.toml] [redis.cache] │ ▼ redis://127.0.0.1:6379/1Single source of truth for Redis (database.toml [redis.*]); cache
config just names which connection to use.
Distributed locks — Cache::lock
For “exactly one process should be running this critical section” use cases — stock decrements, scheduled-task overlap prevention, payment deduplication. Laravel-shape:
use oxide::cache::Cache;use std::time::Duration;
// Closure form — block up to 5s; auto-release on completion or panic.let result = Cache::lock("update-stock", Duration::from_secs(10)) .block(Duration::from_secs(5), || async { // ... do the protected work Ok::<_, anyhow::Error>(42) }) .await?;
// Manual form — try once, no blockinglet lock = Cache::lock("update-stock", Duration::from_secs(10));if lock.get().await? { // protected work lock.release().await?;}
// Try-or-skip patternlet outcome = Cache::lock("nightly-report", Duration::from_secs(60)) .run(|| async { run_report().await }) .await?;match outcome { Some(report) => println!("ran: {report:?}"), None => println!("another worker has the lock — skipped"),}
// Stale-lock recovery (no token check)Cache::lock("update-stock", Duration::from_secs(10)).force_release().await?;Method shape
| Method | Behavior |
|---|---|
lock.get() | Try acquire once. Returns Ok(true) if got it. Non-blocking. |
lock.wait_until_acquired(wait) | Poll until acquired or wait elapses. Returns Ok(bool). |
lock.block(wait, closure) | Acquire (blocking up to wait); run closure; release. Errors if not acquired in time. |
lock.run(closure) | Acquire (non-blocking); if got it, run + release; returns Ok(Some(T)) or Ok(None). |
lock.release() | Release only if we still own it (token-checked). |
lock.force_release() | Release regardless of ownership. Use to clear stale locks from crashed processes. |
Atomicity per driver
| Driver | Acquire | Release | Safe for |
|---|---|---|---|
redis | SET NX EX (atomic) | Lua GET+DEL (atomic) | distributed across hosts ✓ |
array | runtime RwLock | guarded by same lock | single process only |
file | O_EXCL create | check-and-delete | same host, different processes |
For prod distributed scenarios, always use the redis driver.
The Lua-scripted release prevents the classic “I released your lock
because mine expired” bug — every Lock instance carries a unique
random UUID token; release only deletes if the stored value matches.
TTL is a safety net, not a deadline
Set the TTL longer than the work you expect to do, but short enough that a crashed process doesn’t block siblings forever. Common sizes:
- Quick critical sections (DB writes): 10–30s
- Long jobs (file processing, batch import): 5–10 minutes
- Deduplication (idempotency keys): hours, with explicit
releaseat the end
If your work could exceed the TTL, refresh it midway by re-acquiring or — when the use case allows — choose a longer TTL up front.
Cache tags — Cache::tags
Group cache keys under one or more tags so you can invalidate them as a unit. Use case: “wipe every cached user” without enumerating keys.
use oxide::cache::Cache;use std::time::Duration;
// Write under tagsCache::tags(&["users"]) .put("user:42", &user, Some(Duration::from_secs(300))) .await?;
Cache::tags(&["users", "people"]) .put("user:43", &user43, None) .await?;
// Read — must include the same tag(s) you wrote withlet u: Option<User> = Cache::tags(&["users"]).get("user:42").await?;
// Get-or-compute under tagslet u: User = Cache::tags(&["users"]) .remember("user:42", Some(Duration::from_secs(300)), || async { User::find_id(42).await?.ok_or_else(|| anyhow::anyhow!("not found")) }) .await?;
// Invalidate every key tagged "users" — O(1), no enumerationCache::tags(&["users"]).flush().await?;How it works
The implementation uses tag-version rotation (the standard Laravel-style trick):
- Each tag has a random version ID stored under
tag:version:<name>. - When you
put, the storage key istagged:<sha256(tags + versions + user_key)>. - When you
get, the same hash is recomputed using current versions — a match means the value is still valid. flush()writes a fresh random version for every tag in the set. Every previously stored key now hashes to a different storage key, so all reads miss. The old data wasn’t deleted; it just becomes unreachable and expires naturally.
Net effect: tag invalidation is O(N) in the number of tags, not
in the number of keys. Works on every cache driver (array, file,
redis) without driver-specific support.
Tradeoff to know
Old key versions linger until they expire (TTL) or get evicted.
Always set a sensible TTL on tagged values — Cache::forever
under tags works, but the storage cost grows on every flush. For
infinite-lifetime tagged values, flush should be paired with
periodic cache cleanup of the underlying store.
For Redis: tagged values count toward your Redis memory budget until they expire. This is the same tradeoff Laravel users have faced for a decade — fine in practice if your TTLs are reasonable.
Multiple tags semantics
// Write — applies BOTH tagsCache::tags(&["users", "premium"]).put("user:42", &u, None).await?;
// Read — must use the same tag set (order doesn't matter, sorted internally)Cache::tags(&["users", "premium"]).get::<User>("user:42").await?; // hitCache::tags(&["premium", "users"]).get::<User>("user:42").await?; // also hitCache::tags(&["users"]).get::<User>("user:42").await?; // MISS — subsetReading with a subset of tags doesn’t find the value because the hash includes the full tag set. Matches Laravel.
To invalidate via either tag:
Cache::tags(&["users"]).flush().await?;// user:42 was tagged ["users", "premium"], so its hash now fails to// match — the next read won't find it.Switching stores
Cache::store("redis").tags(&["users"]).put("u:1", &u, None).await?;Cache::store("file").tags(&["users"]).get::<User>("u:1").await?; // None — different storeTags don’t cross stores; each store has its own tag-version registry.
Roadmap
- Database driver —
[stores.database]writing to acachetable. Vendor-published migration likefailed_jobs. - Tag introspection —
Cache::tags(...).keys()to list keys for debugging. Not in Laravel; would require driver-specific support.