Manual contentsDigging DeeperBrowse 126 chapters
Manual 20 min read

Cache

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 suprnova::Cache;
use std::time::Duration;

Cache::put("user:1", &user, Some(Duration::from_secs(3600))).await?;

let cached: Option<User> = Cache::get("user:1").await?;

if Cache::has("user:1").await? {
    // hit
}

Cache::forget("user:1").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 an InMemoryCache with the configured prefix and default TTL. Always succeeds.
  • Redis - build a RedisCache on the Redis facade's cache connection (or the one REDIS_CACHE_CONNECTION names) 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 the database-mongodb feature, build a MongoCache on 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 suprnova::{Config, CacheConfig, cache::CacheDriver};

Config::register(
    CacheConfig::builder()
        .driver(CacheDriver::Redis)
        .connection("cache")
        .lock_connection("default")
        .prefix("myapp:")
        .default_ttl(7200)
        .build(),
);

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 suprnova::Cache;
use std::time::Duration;

// Write with an explicit TTL
Cache::put("session:42", &session, Some(Duration::from_secs(1800))).await?;

// Write forever - bypasses CACHE_DEFAULT_TTL
Cache::forever("config:features", &features).await?;

// Read (None on miss or expired)
let session: Option<Session> = Cache::get("session:42").await?;

// Existence - true means present and not expired
if Cache::has("session:42").await? { /* … */ }

// Laravel-spelled negation
if Cache::missing("session:42").await? { /* warm */ }

// Read-and-delete in one call
let one_shot: Option<String> = Cache::pull("notice:welcome:42").await?;

// Returns true if the key existed and was removed
Cache::forget("session:42").await?;

// Wipe everything (prefix-scoped on both backends)
Cache::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 = Cache::touch("session:42", Duration::from_secs(1800)).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 = Cache::add(
    "daily:winner",
    &user_id,
    Some(Duration::from_secs(86_400)),
).await?;
if won {
    send_winner_email(user_id).await?;
}

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:

  • InMemoryCache holds a write-lock across the existence check + insert
  • RedisCache uses SET key value NX EX ttl (or NX without EX)

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 = Cache::remember(
    "user:1",
    Some(Duration::from_secs(3600)),
    || async { User::find(1).await },
).await?;

let cfg = Cache::remember_forever("config:app", || async {
    load_config_from_db().await
}).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 = Cache::remember_with_ttl(
    "api:token",
    |token: &Token| Some(Duration::from_secs(token.expires_in)),
    || async { fetch_token().await },
).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 suprnova::Cache;
use std::time::Duration;

let key = "rebuild:user:1";

if let Some(guard) = Cache::lock(key, Duration::from_secs(10)).await? {
    let user = Cache::remember(
        "user:1",
        Some(Duration::from_secs(3600)),
        || async { User::find(1).await },
    ).await?;
    guard.release().await?;
    return Ok(user);
}

// Lost the race - the winner is computing. Read whatever they wrote,
// or fall back to a stale value.
let user = Cache::get::<User>("user:1").await?
    .ok_or_else(|| FrameworkError::internal("cache miss after losing rebuild lock"))?;

Locks

Cache::lock returns a LockGuard holding the ownership token. Locks are advisory and cross-process when backed by Redis.

use suprnova::Cache;
use std::time::Duration;

if let Some(guard) = Cache::lock("job:42", Duration::from_secs(30)).await? {
    do_exclusive_work().await?;
    guard.release().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 = Cache::increment("page:visits", 1).await?;

// Same shape for negative steps
let remaining = Cache::decrement("quota:remaining", 1).await?;

// Custom amount
let total = Cache::increment("stats:downloads", 10).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 suprnova::cache::ConditionalIncrement;

// Counts only while the counter is below 100; a full counter is left alone.
match Cache::increment_if_below("seats:taken", 1, 100).await? {
    ConditionalIncrement::Incremented(taken) => { /* seat number `taken` is yours */ }
    ConditionalIncrement::Unchanged(_) => { /* sold out, and nothing was counted */ }
}

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 suprnova::Cache;
use std::time::Duration;

// Store under one or more tags
Cache::tags_put(
    &["users", "user:1"],
    "user:1:profile",
    &profile,
    Some(Duration::from_secs(3600)),
).await?;

Cache::tags_put(
    &["users", "user:1"],
    "user:1:posts",
    &posts,
    Some(Duration::from_secs(600)),
).await?;

// Update path: drop every key tagged `user:1`
Cache::flush_tags(&["user:1"]).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:

Cache::tags_forever(&["settings"], "settings:site", &settings).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::put over a previously tagged key clears the entry's tags. A subsequent flush_tags of the old tag will not delete the live untagged value.
  • Overwriting tags_put(&["a"], …) with tags_put(&["b"], …) makes the entry respond only to flush_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 std::sync::Arc;
use suprnova::{CacheHit, EventFacade, FrameworkError, Listener};

struct TraceHits;

#[suprnova::async_trait]
impl Listener<CacheHit> for TraceHits {
    async fn handle(&self, event: &CacheHit) -> Result<(), FrameworkError> {
        tracing::debug!(store = %event.store, key = %event.key, "cache hit");
        Ok(())
    }
}

EventFacade::listen::<CacheHit, _>(Arc::new(TraceHits)).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 suprnova::{Cache, Redis};

// In the bootstrap.
Redis::define("reports", "redis://127.0.0.1:6379/5")?;

// Later writes and reads go to database 5.
Cache::set_connection("reports")?;
Cache::put("report:today", &totals, None).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 std::time::Duration;
use suprnova::{CacheConfig, InMemoryCache};

let cache = InMemoryCache::with_periodic_sweep(&CacheConfig::default(), Duration::from_secs(30));

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 to 0 seconds under EX, which Redis rejects (SET … EX 0) or, worse, interprets as "delete the key" (EXPIRE key 0).
  • Duration::ZERO is 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 std::sync::Arc;
use suprnova::{Cache, CacheStore, InMemoryCache};
use suprnova::container::testing::TestContainer;

#[tokio::test]
async fn cache_round_trips() {
    let _guard = TestContainer::fake();
    TestContainer::bind::<dyn CacheStore>(Arc::new(InMemoryCache::new()));

    Cache::put("k", &"v", None).await.unwrap();

    let v: Option<String> = Cache::get("k").await.unwrap();
    assert_eq!(v.as_deref(), Some("v"));
}

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_integration reads CACHE_REDIS_TEST_URL, falling back to REDIS_URL and then to redis://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_retry covers the transient-command retry and requires CACHE_REDIS_TEST_URL explicitly, with no fallback. It issues CLIENT 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
Cache::put("users:1:profile", &profile, None).await?;
Cache::put("posts:123:comments:count", &count, None).await?;

// TTL by data volatility
Cache::put("stats:active", &count, Some(Duration::from_secs(60))).await?;
Cache::put("config:features", &features, Some(Duration::from_secs(3600))).await?;
Cache::forever("translations:en", &translations).await?;

// Cache-by-tag invalidation around a write
async fn update_user(id: i64, data: UserUpdate) -> Result<User, FrameworkError> {
    let user = User::update(id, data).await?;
    Cache::flush_tags(&[&format!("user:{}", id)]).await?;
    Ok(user)
}

Next

  • Configuration - how Config::register and env vars combine
  • Rate Limiting - the Laravel-shape RateLimiter facade is built on top of Cache
  • Idempotency - the request-dedupe middleware uses Cache::lock end-to-end
  • Service Container - how CacheStore is bound and resolved
  • Error Model - what Cache::* returns when Redis is unreachable mid-request