Suprnova ships a Laravel-shape Cache facade backed by one of three
drivers - in-memory, Redis or MongoDB - 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- build aRedisCacheon the Redis facade'scacheconnection (or the oneREDIS_CACHE_CONNECTIONnames) and bind it. The store sends one command while it is built, so the boot fails closed if that connection cannot answer. There is no silent downgrade to memory.MongoDb- with thedatabase-mongodbfeature, build aMongoCacheon the default MongoDB connection and bind it. The boot fails when no MongoDB connection is registered. See MongoDB: Cache.
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, redis or mongodb |
memory |
CACHE_PREFIX |
Key prefix applied to every store operation | the slug of APP_NAME, then -cache- (suprnova-cache- without APP_NAME) |
REDIS_CACHE_CONNECTION |
The Redis connection the store's commands run on | cache |
REDIS_CACHE_LOCK_CONNECTION |
The Redis connection the store's locks run on | default |
CACHE_DEFAULT_TTL |
Default TTL in seconds for Cache::put, Cache::remember, and Cache::tags_put called with None; 0 means no default |
3600 |
CACHE_SWEEP_INTERVAL |
Seconds between sweeps of the in-memory cache's expired entries; 0 turns the sweep off |
60 |
Unset CACHE_DRIVER parses to Memory; any other value (case-
insensitive, trimmed) that isn't memory/in-memory/inmemory/redis/
mongodb returns an error at boot, and so does mongodb in a build without
the database-mongodb feature.
The Redis server, its database and the connection's own key prefix come
from the Redis connections: REDIS_URL, REDIS_CACHE_DB for
the cache connection (database 1 unless you set it, even when
REDIS_URL names another), and REDIS_PREFIX.
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, Cache::remember_forever (and its alias Cache::sear),
and Cache::tags_forever bypass CACHE_DEFAULT_TTL entirely; the value
never expires regardless of the configured default.
Cache::put(key, value, None), Cache::remember(key, None, ..), and
Cache::tags_put(tags, key, value, None) do 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?;
On Redis, flush deletes the keys that start with the connection's
REDIS_PREFIX followed by CACHE_PREFIX, matched literally: a prefix with
glob characters such as *, ?, or [ matches only itself, and a key
under the connection prefix alone survives. When both prefixes are empty
the pattern matches every key in the connection's database, so flush
then empties the whole database.
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, and so are its tags: a
touched tagged value is still removed by flush_tags after its original TTL
would have run out.
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.
When the value knows how long it is good for, compute the lifetime from
it with Cache::remember_with_ttl, as Laravel's remember takes a
closure for its lifetime:
let token: Token = remember_with_ttl.await?;
The lifetime closure runs only on a miss, after the value is computed.
None stores the value for CACHE_DEFAULT_TTL, as remember's None
does, and Some(Duration::ZERO) returns the value without storing it.
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.
use ConditionalIncrement;
// Counts only while the counter is below 100; a full counter is left alone.
match increment_if_below.await?
Cache::increment_if_below adds to a counter only while it is below a
ceiling, reading a missing key as 0, so a caller can be refused without
being counted. The comparison and the write are one atomic step on both
built-in backends, under the write lock in InMemoryCache and in one
EVAL in RedisCache, so concurrent callers stepping by 1 never take the
counter past the ceiling; the throttle middleware
counts with it. A custom CacheStore that doesn't override
increment_if_below gets a default that reads and then increments, which
isn't atomic; override it with your backend's atomic primitive.
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?;
tags_put with a None TTL applies CACHE_DEFAULT_TTL, like put. To
store a tagged value that never expires, use Cache::tags_forever:
tags_forever.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"]).
A tag's index of keys drops entries that can no longer be reached, so it
doesn't grow without bound for a tag that is written often but rarely
flushed. Flushing one tag removes the deleted keys from the indexes of their
other tags too. On the in-memory backend the periodic sweep removes expired
keys from the indexes. On Redis every tagged write checks a sample of the
tag's index and removes keys that expired, were forgotten, or were
overwritten without the tag, and flush() clears every index.
Hit and miss events
Cache::get dispatches CacheHit when the key holds a value and
CacheMissed when it holds nothing, as Laravel's Repository::get does.
Cache::has, Cache::missing and every read built on them (pull,
remember, remember_forever, sear) dispatch the same, once per read.
Each event carries the store's name (memory, redis) and the key you
passed; CacheHit also carries the stored value when the read fetched it,
and None for has and missing, which only check presence. A stored
JSON null is a value: it is present, and its read is a hit.
use Arc;
use ;
;
.await;
A listener that fails is logged, and the read still answers. When no
listener and no events fake is installed the read builds no event. The
Debug output of a CacheHit leaves the value out, since a cached value
can be a token or a user's data.
Redis connections
The Redis store runs on the Redis facade's named connections,
as Laravel's createRedisDriver builds its store: its commands on the
cache connection, which reads REDIS_CACHE_DB, and its locks on the
default connection. REDIS_CACHE_CONNECTION and
REDIS_CACHE_LOCK_CONNECTION name others, or set them in code with
CacheConfigBuilder::connection and lock_connection.
Every key the store writes is the connection's REDIS_PREFIX, then
CACHE_PREFIX, then your key. With APP_NAME=Shop, REDIS_PREFIX=shop-
and CACHE_PREFIX unset, Cache::put("k", ..) writes shop-shop-cache-k.
Cache::set_connection moves the bound store's later commands to another
connection, as Laravel's Cache::setConnection; its locks stay where they
are. A store with no Redis connection, such as the memory store, answers an
error.
use ;
// In the bootstrap.
define?;
// Later writes and reads go to database 5.
set_connection?;
put.await?;
RedisCache::connect(&config) still builds a store on config.url by
hand, with config.prefix as its whole key prefix. The bootstrap uses
RedisCache::connect_named(&config), which builds it on the named
connections.
Upgrading keeps the old keys only if you ask
Before this release the Redis store opened its own connection from
REDIS_URL and used REDIS_PREFIX, suprnova_cache: by default, as its
cache prefix. It now runs on the cache connection's database
(REDIS_CACHE_DB, 1 by default) under the new prefixes, so an upgraded
deployment finds its Redis cache empty. That is safe for a cache, which
refills as it is read. To keep the old keys, set:
REDIS_CACHE_CONNECTION=default
REDIS_PREFIX=
CACHE_PREFIX=suprnova_cache:
REDIS_CACHE_CONNECTION=default keeps the database REDIS_URL names, the
empty REDIS_PREFIX drops the connection prefix, and CACHE_PREFIX puts
back the old prefix (use the value you had in REDIS_PREFIX, if you set
one).
Three backends
| Feature | InMemoryCache |
RedisCache |
MongoCache |
|---|---|---|---|
| Shared across processes | No | Yes | Yes |
| Persistence | No | Yes, if Redis is configured for it | Yes |
Atomic add |
Yes (write-lock) | Yes (SET NX) |
Yes (an upsert that collides with a live entry) |
Atomic increment/decrement |
Yes (write-lock) | Yes (INCRBY/DECRBY) |
Yes ($inc in one findOneAndUpdate) |
| Tagged cache | Yes | Yes | Yes (a tags array on the entry) |
| Locks | Yes | Yes (cross-process) | Yes (cross-process, cache_locks) |
| Sub-second TTL | Yes (tokio::time::Instant) |
Yes (PX/PEXPIRE) |
Yes (millisecond expires_at, TTL index) |
| Selected via | CACHE_DRIVER=memory (default) |
CACHE_DRIVER=redis |
CACHE_DRIVER=mongodb |
There is no SQL database cache driver - the three backends above are the
ones the framework ships. MongoCache needs the database-mongodb
feature; see MongoDB: Cache. 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 key that expires and is never read again has no such trigger, so the
cache the framework binds also sweeps: every CACHE_SWEEP_INTERVAL
seconds, 60 unless you set it, it removes every expired entry. 0
turns the sweep off. The sweep runs on a task that holds the cache
weakly and ends with it.
A cache you build yourself is swept only if you build it to be. Build it inside the runtime, in your bootstrap for instance, since the sweep is a task on it:
use Duration;
use ;
let cache = with_periodic_sweep;
InMemoryCache::new() is not swept; call purge_expired(), which
returns the count of entries removed, when you want one. Redis handles
its own expiration server-side; the equivalent isn't needed there.
A dyn CacheStore that your bootstrap binds is kept: the framework's
boot binds its own store only when none is bound.
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.
Transient command retries
A dropped socket used to fail whatever Cache::get happened to be in flight.
The Redis connection manager reconnects on its own, but the command that hit
the dead socket still returns its error to you.
Read-shaped commands now retry once: GET, EXISTS, and the SCAN / SSCAN
pages behind Cache::flush and Cache::flush_tags. The queue driver's XLEN,
ZCARD, and XPENDING reads and the rate limiter's Retry-After computation
retry the same way. Set REDIS_COMMAND_RETRIES to add further retries on top
of the built-in one.
Budget the retry in seconds, not in the 50 ms pause that precedes it. Once a connection has dropped, the next attempt waits for the replacement connection before it can send anything, so it pays the driver's whole connect budget and then its response timeout:
- The cache driver allows up to 3 connect retries, at most 500 ms apart, each capped by a 2 s connect timeout, with a 5 s response timeout.
- The queue and rate-limit drivers take the redis-rs defaults: up to 6 connect retries with an uncapped exponential delay starting at 100 ms, each capped by a 1 s connect timeout, with a 500 ms response timeout.
REDIS_COMMAND_RETRIES is clamped at 10, and that clamp bounds attempts, not
seconds: at the maximum a single read makes 12 attempts, which against a down
Redis is tens of seconds to minutes on one call. A timed-out command counts as
transient as well as a dropped one, so a merely slow Redis makes every wrapped
read issue up to that many commands rather than one. Raise the setting only
where the caller can afford to wait.
Writes never retry, at any setting. A transient error means the connection
failed, not that the server declined the command - the server may already have
run it - so retrying a SET, an INCR, a lock acquisition, a rate-limit hit,
or a queue pop risks a second execution. Those commands surface the failure to
you, and your retry decision is the informed one.
Why Suprnova diverges
Laravel's command_retries config raises the retry budget for every Redis
command, because its command() method is a single choke point that knows
which command it is running and consults a 60-entry read-only allowlist.
Suprnova's drivers call typed commands directly, so the allowlist becomes a
per-call-site decision, and REDIS_COMMAND_RETRIES can only deepen the retries
for commands that are already safe to repeat. There is no setting that makes a
queue pop retry.
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.
Live-Redis suites
The framework's own Redis tests are #[ignore]d, so cargo test never needs a
server. Run them with -- --ignored and point them at an instance:
cache_redis_integrationreadsCACHE_REDIS_TEST_URL, falling back toREDIS_URLand then toredis://127.0.0.1:6379. Every test scopes itself to a unique key prefix, so it is safe against a shared development Redis.cache_redis_retrycovers the transient-command retry and requiresCACHE_REDIS_TEST_URLexplicitly, with no fallback. It issuesCLIENT KILL TYPE normal, which disconnects every other client on the instance, so it must be given a throwaway server. With the variable unset it prints a skip line and passes without connecting.
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
