Suprnova ships a Laravel-shape Cache facade backed by one of two
drivers - in-memory or Redis - picked explicitly at boot via
CACHE_DRIVER. The facade is a thin layer over a CacheStore trait, so
custom backends plug in the same way the built-ins do.
The facade
use Cache;
use Duration;
put.await?;
let cached: = get.await?;
if has.await?
forget.await?;
Every method serialises through serde_json at the facade boundary, so
any T: Serialize + DeserializeOwned round-trips. The trait under the
facade (CacheStore) only sees opaque JSON strings.
Bootstrap
The cache is bound during Server::run()'s driver-bootstrap step (see
Request Lifecycle). Cache::bootstrap reads the
configured CacheConfig (or constructs one from env) and dispatches on
CacheConfig::driver:
Memory- bind anInMemoryCachewith the configured prefix and default TTL. Always succeeds.Redis- connect toREDIS_URLand bind the resultingRedisCache. Fails closed if the URL is unreachable. There is no silent downgrade to memory.
Workers (queue:work, schedule:run, workflow:work) go through the
same bootstrap, so a job using Cache::get sees the same backend the
HTTP handler does.
Why Suprnova diverges
Laravel's cache.php config picks a default store and Laravel will
quietly swap to array (in-process) when a misconfigured backend fails
in some code paths. That's a productive default for php artisan tinker
and a footgun in production - a single Redis miss silently changes the
guarantees of every tag flush and lock acquisition in the app.
Suprnova picks the opposite default. CACHE_DRIVER=memory is explicit
(and the default for cargo run), and CACHE_DRIVER=redis against an
unreachable Redis returns an error from Server::from_config. The
binary exits non-zero with a remediation message; supervisord/systemd
sees a boot failure instead of a half-working app.
Configuration
| Env | Meaning | Default |
|---|---|---|
CACHE_DRIVER |
memory or redis |
memory |
REDIS_URL |
Redis URL (consulted only when driver=redis) |
redis://127.0.0.1:6379 |
REDIS_PREFIX |
Key prefix applied to every store operation | suprnova_cache: |
CACHE_DEFAULT_TTL |
Default TTL in seconds for Cache::put(None); 0 means no default |
3600 |
Unset CACHE_DRIVER parses to Memory; any other value (case-
insensitive, trimmed) that isn't memory/in-memory/inmemory/redis
returns an error at boot.
You can also build the config programmatically when you don't want env parsing:
use ;
register;
CacheConfigBuilder::build is deterministic - unset fields fall back
to CacheConfig::default() rather than re-reading env.
The forever contract holds across backends
Cache::forever and Cache::remember_forever bypass
CACHE_DEFAULT_TTL entirely; the value never expires regardless of the
configured default. Cache::put(key, value, None) does apply the
default - that's the point of having one.
The default-TTL resolution happens at the facade layer. Both CacheStore
backends honour None literally at the store boundary (no expiration),
which is why forever actually means forever on both memory and Redis.
Reads, writes, deletes
use Cache;
use Duration;
// Write with an explicit TTL
put.await?;
// Write forever - bypasses CACHE_DEFAULT_TTL
forever.await?;
// Read (None on miss or expired)
let session: = get.await?;
// Existence - true means present and not expired
if has.await?
// Laravel-spelled negation
if missing.await?
// Read-and-delete in one call
let one_shot: = pull.await?;
// Returns true if the key existed and was removed
forget.await?;
// Wipe everything (prefix-scoped on both backends)
flush.await?;
Cache::pull is not atomic - it's a get followed by a forget,
same shape as Laravel's Repository::pull. For atomic dequeue use
Cache::lock (see below).
Refresh a TTL without rewriting
let refreshed = touch.await?;
touch returns true if the key existed and the TTL was extended,
false otherwise. The stored value is untouched.
Add - write-if-absent (atomic)
let won = add.await?;
if won
Cache::add writes only if the key is empty (or has expired). Returns
true on write, false on contention. Atomic on both built-in
backends:
InMemoryCacheholds a write-lock across the existence check + insertRedisCacheusesSET key value NX EX ttl(orNXwithoutEX)
Custom CacheStore implementations that don't override add_raw fall
back to a non-atomic check-then-put, matching Laravel's
Repository::add fallback for stores without a native add.
Remember - get-or-compute
let user = remember.await?;
let cfg = remember_forever.await?;
remember calls your closure only on miss, then stores the result. The
closure returns Result<T, FrameworkError>, so domain failures bubble
through ? rather than poisoning the cache.
Cache::sear(key, default) is the Laravel-spelled alias for
remember_forever. Same body, same semantics - ships under both names
so migrated code reads the same way.
Remember is NOT stampede-safe
remember is a non-atomic get-then-put pair. N concurrent misses
for the same cold key run the closure N times and write N results. That
matches Laravel's Repository::remember exactly, and it's fine for the
common case (the closure is idempotent, the writes are identical).
It is not fine when:
- The closure is expensive (1s+ to compute or hits a slow upstream)
- The key is popular enough that a cold-cache event sends N requests at once at the backing store
- The closure has side effects beyond computing the value
For those, wrap with Cache::lock:
use Cache;
use Duration;
let key = "rebuild:user:1";
if let Some = lock.await?
// Lost the race - the winner is computing. Read whatever they wrote,
// or fall back to a stale value.
let user = .await?
.ok_or_else?;
Locks
Cache::lock returns a LockGuard holding the ownership token. Locks
are advisory and cross-process when backed by Redis.
use Cache;
use Duration;
if let Some = lock.await?
// Some(guard) means we own it. None means another holder beat us.
The guard exposes:
| Method | Use for |
|---|---|
guard.token() |
Read the ownership token (Rust-side name) |
guard.owner() |
Same value, Laravel-spelled alias |
guard.refresh(ttl) |
Extend the TTL - returns false if we no longer own the lock |
guard.release() |
Release if we still own the lock - returns false if the token no longer matches |
There is intentionally no Drop auto-release. A Redis lock must be
acknowledged across process boundaries; auto-release on drop would
either silently steal a stolen lock back (wrong) or hide release
failures in destructor panics (worse). The release is explicit so
errors propagate.
refresh lets a long-running job extend its own lock to avoid a
self-inflicted timeout - see Idempotency for the
in-tree consumer.
Atomic counters
// Initialises to 0 if absent, then increments. Returns the new value.
let visits = increment.await?;
// Same shape for negative steps
let remaining = decrement.await?;
// Custom amount
let total = increment.await?;
Atomic on both built-in backends: InMemoryCache uses a write-locked
HashMap::entry; RedisCache uses INCRBY/DECRBY. The stored value
is a JSON-encoded integer, so Cache::get::<i64>("page:visits") round-
trips with the same key.
Tagged cache
Tags let you invalidate a whole family of related entries with one call. The classic use case is per-resource caches that have to flush together when the resource changes.
use Cache;
use Duration;
// Store under one or more tags
tags_put.await?;
tags_put.await?;
// Update path: drop every key tagged `user:1`
flush_tags.await?;
Tag membership is per-entry: each tagged write installs that write's tag set as the entry's source of truth, replacing any prior tags. Two consequences worth knowing:
- An untagged
Cache::putover a previously tagged key clears the entry's tags. A subsequentflush_tagsof the old tag will not delete the live untagged value. - Overwriting
tags_put(&["a"], …)withtags_put(&["b"], …)makes the entry respond only toflush_tags(&["b"]).
Stale forward-index references are pruned during the flush walk and on
flush(), so they don't accumulate indefinitely for tags that are
written but never flushed.
Two backends
| Feature | InMemoryCache |
RedisCache |
|---|---|---|
| Shared across processes | No | Yes |
| Persistence | No | Yes, if Redis is configured for it |
Atomic add |
Yes (write-lock) | Yes (SET NX) |
Atomic increment/decrement |
Yes (write-lock) | Yes (INCRBY/DECRBY) |
| Tagged cache | Yes | Yes |
| Locks | Yes | Yes (cross-process) |
| Sub-second TTL | Yes (tokio::time::Instant) |
Yes (PX/PEXPIRE) |
| Selected via | CACHE_DRIVER=memory (default) |
CACHE_DRIVER=redis |
There is no Database cache driver - the two backends above are the
ones the framework ships. Custom backends can implement CacheStore
and bind into the container directly; see the test-injection pattern
below.
In-memory expiration
InMemoryCache evicts expired entries lazily on read: get_raw,
has, and add_raw purge an entry the first time they observe it
expired. Re-accessed keys never accumulate corpses.
A workload that writes a high-cardinality set of short-lived keys and
never reads them back has no such trigger. Call
InMemoryCache::purge_expired() from a periodic task in that case -
it returns the count of entries removed. Redis handles its own
expiration server-side; the equivalent isn't needed there.
Redis TTL precision
Every Redis TTL goes through PX / PEXPIRE, not EX / EXPIRE.
That avoids two pitfalls:
- Sub-second
Durations would truncate to0 secondsunderEX, which Redis rejects (SET … EX 0) or, worse, interprets as "delete the key" (EXPIRE key 0). Duration::ZEROis clamped to 1 ms before the call, so neither rejection path is reachable from user code.
Testing
Bind an InMemoryCache into the TestContainer and the facade
resolves it like any other store:
use Arc;
use ;
use TestContainer;
async
TestContainer::bind writes to the thread-local scope, so parallel
tests do not leak cache state into each other. See the
Service Container chapter for the three-layer lookup
model.
Patterns
A few recurring shapes worth naming:
// Hierarchical, colon-separated keys - same convention Laravel uses
put.await?;
put.await?;
// TTL by data volatility
put.await?;
put.await?;
forever.await?;
// Cache-by-tag invalidation around a write
async
Next
- Configuration - how
Config::registerand env vars combine - Rate Limiting - the Laravel-shape
RateLimiterfacade is built on top ofCache - Idempotency - the request-dedupe middleware uses
Cache::lockend-to-end - Service Container - how
CacheStoreis bound and resolved - Error Model - what
Cache::*returns when Redis is unreachable mid-request
