Redis, Local Cache & Queue Drivers
Rullst keeps cache and queue backends explicit. Enabling a Cargo feature
only compiles the adapter; it does not inspect REDIS_URL, switch drivers, or
silently fall back when Redis is unavailable.
Cache choices
Cache::memory() uses a process-local DashMap. Values disappear on restart
and are not shared between replicas. A TTL too large for the monotonic clock
to represent, such as u64::MAX, is stored as “never expires” instead of
panicking:
#![allow(unused)]
fn main() {
use rullst_core::cache::{Cache, CacheError};
use std::sync::Arc;
async fn load_profile(cache: &Cache) -> Result<Arc<String>, CacheError> {
cache
.remember("profile:42", 300, || async {
Ok("serialized profile".to_string())
})
.await
}
async fn read_profile() -> Result<(), CacheError> {
let cache = Cache::memory();
let profile = load_profile(&cache).await?;
let _ = profile;
Ok(())
}
}
For a shared Redis cache, enable cache-redis (or the umbrella redis
feature) and construct the adapter explicitly:
[dependencies]
rullst-core = { version = "=13.0.0-alpha.4", features = ["cache-redis"] }
#![allow(unused)]
fn main() {
use rullst_core::cache::Cache;
async fn cache_featured_catalog() -> Result<(), Box<dyn std::error::Error>> {
let redis_url = std::env::var("REDIS_URL")?;
let cache = Cache::redis(redis_url)?;
cache.put("catalog:featured", "[...]", Some(600)).await?;
Ok(())
}
}
A zero TTL removes the key, and a TTL beyond Redis’s expiry range is stored
without expiry, as in the memory driver. Constructing the driver validates the
Redis URL but does not establish a connection. The first operation opens one
multiplexed async connection that later operations share; it is not reopened
per command. When that connection breaks, the failing operation returns a typed
CacheError and the next one reconnects. Operations also return CacheError
if Redis is unavailable. Choose an application-specific policy: fail startup,
retry with bounds, or explicitly select Cache::memory() for a documented
single-instance development mode.
The built-in Redis cache prefixes keys with rullst:cache:. flush() scans and
unlinks keys under that prefix; use dedicated credentials/database boundaries
when multiple applications share a Redis service.
ORM .remember(...) queries
The ORM has a separate opt-in query-cache contract behind its redis feature:
[dependencies]
rullst-orm = { version = "=13.0.0-alpha.4", features = ["redis"] }
The generated .remember(...), cache invalidation, orm:events:* publications
and Redis hash helpers follow this ORM feature (or the facade’s redis /
orm-redis). From 13.0 the application does not declare a redis feature of
its own; earlier macro output checked the application’s features instead.
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "users")]
struct User {
id: i32,
active: bool,
}
async fn load_recent_users() -> Result<(), Box<dyn std::error::Error>> {
let redis_url = std::env::var("REDIS_URL")?;
Orm::init_redis_with_namespace(&redis_url, "academy-production").await?;
let recent = User::query()
.where_eq("active", true)
.remember(30)
.get()
.await?;
let _ = recent;
Ok(())
}
}
Generated save_to_redis/get_from_redis/increment_redis_field model
hashes use the same namespace, and tenant models bind the active tenant into
their key and require with_tenant(...). Hashes that 12.0 and 12.1 wrote under
orm:<table>:<id> are handled as described in
Model hashes stored by 12.0 and 12.1.
Use a stable, unique namespace for every application that shares a Redis
database. Query keys bind that namespace, the table, generated SQL and typed
bindings and, for a model with a tenant_column, an opaque digest of the
active tenant; other models share one global entry. They do not expose raw
tenant identifiers. Entries live under rullst:orm:cache:v4:<namespace>: and
model hashes under rullst:orm:hash:v1:<namespace>:. Cache entries written by
earlier versions are never read again and expire through their TTL. The older
Orm::init_redis(url) API remains available and uses default; only use it
with a dedicated Redis database.
The failure and consistency rules are explicit:
remember(0)is rejected.- Missing Redis initialization is a configuration error for a remembered query outside a transaction.
- Redis command failures or corrupt JSON fall back to the authoritative database; a successful read is returned even if cache population fails.
- Explicit and task-scoped ORM transactions always bypass query cache.
- Generated model saves/deletes invalidate the table’s global entries and, for
a tenant-scoped model, those of the tenant active at the write, only after
commit; rollback keeps existing cache entries. Each write follows the
table’s key index (at most 10,000 live keys), never a keyspace
SCAN. Raw SQL, bulk builders and writes from another process cannot be inferred. Keep defensive TTLs and do not cache authorization or reads that require a stronger distributed consistency contract.
The Core Cache facade and ORM query cache use different keyspaces and APIs;
initializing one does not initialize the other.
Model hashes stored by 12.0 and 12.1
Releases up to 12.1 stored every generated model hash under the shared key
orm:<table>:<id>. For a model without a tenant scope, get_from_redis reads
that key while the namespaced hash is missing, and the next save_to_redis or
increment_redis_field moves it to the namespaced key in one Redis script
(every field is kept; an existing namespaced hash wins and the stale legacy
hash is removed). Nothing has to be run. Applications that share one Redis
database also shared these keys, so the first one to write a hash takes it
over. A 12.x hash of a model with #[orm(encrypted)] fields holds them in
plaintext, so reading it fails closed until save_to_redis() rewrites it.
Tenant models never read, move or delete the 12.x key, which every tenant
shared, so until migrated get_from_redis returns None and
increment_redis_field starts from zero. Migrate them once after deploying
13:
- List the keys of each tenant model table:
redis-cli --scan --pattern 'orm:<table>:*'. - Read each hash with
HGETALL; every value is the JSON of one field. - Check its tenant column against the tenant that owns row
<id>in the database, and skip mismatches: another tenant may have overwritten it. - Decode the hash into the model (for example with
serde_jsonfrom the parsed values), or reload the row when the hash only cached it, and callwith_tenant(tenant, model.save_to_redis()). - Remove the 12.x key with
UNLINK.
Queue choices
Rullst provides explicit SQLite and Redis queue constructors:
[dependencies]
rullst-core = { version = "=13.0.0-alpha.4", features = ["queue-sqlite"] }
serde_json = "1"
#![allow(unused)]
fn main() {
use rullst_core::queue::Queue;
use serde_json::json;
async fn enqueue_receipt() -> Result<(), Box<dyn std::error::Error>> {
let queue = Queue::sqlite("sqlite://jobs.sqlite?mode=rwc").await?;
let job_id = queue
.dispatch("send_receipt", json!({ "invoice_id": 42 }))
.await?;
println!("queued {job_id}");
Ok(())
}
}
With queue-redis, construct Queue::redis(redis_url) instead. The Redis
driver uses atomic Lua transitions for pending, processing, failed, and
dead-letter state. Failed jobs (with their payloads) and dead letters are each
retained up to 10,000 entries; recording one more evicts the oldest in the same
script. RedisDriver::try_with_failure_retention(failed_jobs, dead_letters)
accepts 1–100,000 for each (pass the configured driver to Queue::custom).
Failed jobs recorded before this bound was introduced (for example by 12.x
workers) are not indexed, so they are neither counted, listed nor evicted;
retry_failed_job still finds them by ID and purge_failed_jobs removes them.
list_all_jobs returns at most 1,000 rows, failures and dead letters first,
and list_job_previews (new in v13) returns the same rows with each
payload and error cut to a byte budget inside one Lua script;
retry_failed_job moves a failed job back to the pending list with its attempt
counter intact; purge_failed_jobs deletes every failed job and dead letter. Like the cache, each driver shares one lazily opened
multiplexed connection and reconnects after a failed operation. Production validation must still cover Redis persistence,
eviction policy, credentials/TLS, failover, monitoring, and worker recovery in
the target topology.
There is no automatic interchange between the SQLite and Redis queues: they store independent state. Switching a live deployment requires an explicit drain/migration plan.
Real-time boundary
Core’s current WebSocket broadcast/presence helpers are process-local. They
release channels without subscribers and empty presence rooms, so the registry
tracks live rooms rather than every name ever used. Redis Pub/Sub, Kafka, and
RabbitMQ real-time transports remain roadmap work; do not describe the cache or
queue adapter as cross-instance real-time sync. The v13
Redis Streams messaging candidate in rullst-messaging
is at-least-once brokered delivery, not WebSocket fan-out.
Deployment checklist
- Choose the backend in application configuration and make fallback policy explicit.
- Never commit Redis credentials; prefer TLS and least-privilege network access.
- Namespace application/tenant keys above the built-in driver prefix where
isolation is required.
TenantCachesupplies validated tenant namespaces astenants:<tenant>:<key>, where%and:in the tenant segment are written as%25and%3Aso a key containing:cannot reach another tenant. - Test disconnects, timeouts, retries, eviction, restart, and worker recovery.
- Benchmark the deployed service. Rullst does not claim universal cache latency, memory usage, or infrastructure cost.