Caching¶
The cache module provides a pluggable cache layer with two built-in backends (in-memory and Redis), a global manager for read-through helpers, and a stable cache-key helper for query memoization.
Everything in surql::cache is gated behind the cache feature. The Redis backend additionally requires cache-redis.
Concepts¶
| Type | Role |
|---|---|
CacheBackend | Async trait: get, set, delete, exists, clear. |
MemoryCache | In-process LRU+TTL backend backed by a tokio::sync::RwLock<HashMap>. |
RedisCache | Redis-backed backend with lazy connection setup and JSON-on-the-wire values. |
CacheManager | Owns a backend, tracks table to keys associations for invalidation, records hit/miss statistics. |
CacheConfig | Layered configuration (CacheConfigBuilder, CacheOptions) covering backend kind, TTLs, key prefix. |
CacheStatsSnapshot | Copyable view of the hit, miss, size, and eviction counters. |
When full, MemoryCache first drops every expired entry and evicts the least-recently-used live entry only if none had expired. A manager built on the memory backend reports the backend's size and evictions in stats_snapshot(); on Redis and custom backends those two stay at 0.
Every key the manager stores is key_prefix followed by the key you pass, always: "x" and "surql:x" are two different entries, whatever the prefix is.
Quick start¶
Configure the global manager¶
use surql::cache::{configure_cache, CacheConfigBuilder, CacheBackendKind};
let config = CacheConfigBuilder::new()
.backend(CacheBackendKind::Memory)
.default_ttl_secs(60)
.max_size(1024)
.build();
let manager = configure_cache(config)?;
configure_cache builds the CacheManager from the supplied config and installs it as the process-wide default returned by get_cache_manager. Subsequent calls overwrite the previous manager.
Read-through with the cached helper¶
use surql::cache::cached;
let users: Vec<User> = cached("users:active", Some(30), || async {
db.query("SELECT * FROM user WHERE active = true").await
}).await?;
The closure runs only on a miss. When no manager is configured the closure runs every call and the result is returned directly, so library code can call cached unconditionally and let the consumer opt in to caching by configuring the global manager. A cached value that no longer deserialises as the requested type (a different type under the same key, or a changed shape) counts as a miss: the closure runs and its result replaces the entry.
ttl_secs is Option<u64>; None falls back to the manager's configured default_ttl_secs.
Decorator-style API¶
use surql::cache::{cached_with, get_or_init_manager};
let manager = get_or_init_manager();
let value = cached_with(&manager, "key", Some(30), || async {
fetch_value().await
}).await?;
cached_with lets you pass an explicit manager instead of the global one. Use it when you need separate cache surfaces (for example a per-tenant cache) inside the same process.
Stable keys for memoised functions¶
use surql::cache::cache_key_for;
let key = cache_key_for("queries", "find_user_by_email", &("alice@example.com",))?;
let user: Option<User> = cached(&key, Some(30), || async {
db.query("SELECT * FROM user WHERE email = $email")
.bind(("email", "alice@example.com"))
.await
}).await?;
cache_key_for hashes the supplied identifier and serialisable argument list into a stable string of the form {module}.{name}:{8-byte hex}. The hash covers the JSON array [module, name, args], so ("a.b", "c") and ("a", "b.c") get different keys even though their readable part is the same.
Redis backend¶
use surql::cache::{configure_cache, CacheConfigBuilder, CacheBackendKind};
let config = CacheConfigBuilder::new()
.backend(CacheBackendKind::Redis)
.redis_url("redis://127.0.0.1:6379")
.key_prefix("surql:")
.build();
let manager = configure_cache(config)?;
RedisCache lazily opens its connection on the first get / set. Values are JSON-encoded on the wire so the backend can be shared with non-Rust consumers that adhere to the same prefix and value contract. The connection is a redis ConnectionManager shared by every call: when the server drops it, the call that finds out fails and a new connection is made in the background, so the calls after it succeed again. An unreachable server is an error after two connection attempts, not a stall.
The manager applies key_prefix to every key, so the RedisCache it builds carries no prefix of its own and surql:users:active is stored under exactly that name. A standalone RedisCache::new(url, prefix, ttl) stores every key under prefix, and its clear(None) deletes only keys under it. With an empty prefix that would be every key in the Redis database, so clear(None) refuses; likewise CacheManager::clear with an empty key_prefix on Redis. Always give a Redis-backed cache a prefix. The Debug output of CacheConfig, CacheManager and RedisCache redacts the credentials a redis://user:password@host URL carries.
Statistics and invalidation¶
use surql::cache::get_cache_manager;
let manager = get_cache_manager().expect("cache configured");
let stats = manager.stats_snapshot();
println!("hit ratio: {:.2}", stats.hit_ratio());
manager.invalidate_table("user").await?;
manager.invalidate_key("users:active").await?;
manager.invalidate_pattern("users:*").await?;
manager.clear().await?;
invalidate_table deletes every key the manager has associated with the given table; associations are recorded when callers tag a get_or_set invocation with the relevant table list, and those of entries that have since expired or been evicted are swept out as the tracking grows, so it stays proportional to the live entries. invalidate_key removes a single key, and clear drops every entry under the manager's key_prefix.
invalidate_pattern takes a glob matched against the keys as you pass them to set (the prefix is applied for you, literally): * matches any run of characters, ? a single character, and \ makes the next character literal. Every other character, including [, ], <, > and non-ASCII letters, matches only itself, on both backends, so invalidate_pattern("café:*") removes the café: entries and nothing else.
Installing a custom backend¶
use std::sync::Arc;
use surql::cache::{install_backend, CacheBackend, CacheConfigBuilder};
let backend: Arc<dyn CacheBackend> = Arc::new(MyBackend::default());
let manager = install_backend(CacheConfigBuilder::new().build(), backend);
install_backend skips the CacheBackendKind resolution path and mounts the supplied backend directly. The resulting CacheManager honours the rest of the supplied CacheConfig (TTL defaults, prefix, tracking flags).