Skip to content

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;
// Read
let user: Option<User> = Cache::get("user:42").await?;
// Write with TTL
Cache::put("user:42", &user, Some(Duration::from_secs(300))).await?;
// Write forever
Cache::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 variant
let cfg: Cfg = Cache::remember_forever("cfg", || async { build_cfg().await }).await?;
// Existence + delete
Cache::has("user:42").await?;
Cache::forget("user:42").await?;
// Atomic counters
Cache::increment("page_views", 1).await?;
Cache::decrement("stock", 1).await?;
// Wipe
Cache::flush().await?;
// Different store
Cache::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

DriverCargo featureWhen to use
arrayalways availabletests, per-process memoization
filealways availabletiny apps without Redis; CI; dev with no docker
redisfeatures = ["redis"]production
databasenot 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 store
Cache::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 key
Cache::flush().await?; // every key in this store

The 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/1

Single 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 blocking
let lock = Cache::lock("update-stock", Duration::from_secs(10));
if lock.get().await? {
// protected work
lock.release().await?;
}
// Try-or-skip pattern
let 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

MethodBehavior
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

DriverAcquireReleaseSafe for
redisSET NX EX (atomic)Lua GET+DEL (atomic)distributed across hosts ✓
arrayruntime RwLockguarded by same locksingle process only
fileO_EXCL createcheck-and-deletesame 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 release at 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 tags
Cache::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 with
let u: Option<User> = Cache::tags(&["users"]).get("user:42").await?;
// Get-or-compute under tags
let 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 enumeration
Cache::tags(&["users"]).flush().await?;

How it works

The implementation uses tag-version rotation (the standard Laravel-style trick):

  1. Each tag has a random version ID stored under tag:version:<name>.
  2. When you put, the storage key is tagged:<sha256(tags + versions + user_key)>.
  3. When you get, the same hash is recomputed using current versions — a match means the value is still valid.
  4. 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 tags
Cache::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?; // hit
Cache::tags(&["premium", "users"]).get::<User>("user:42").await?; // also hit
Cache::tags(&["users"]).get::<User>("user:42").await?; // MISS — subset

Reading 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 store

Tags don’t cross stores; each store has its own tag-version registry.

Roadmap

  • Database driver[stores.database] writing to a cache table. Vendor-published migration like failed_jobs.
  • Tag introspectionCache::tags(...).keys() to list keys for debugging. Not in Laravel; would require driver-specific support.