use std::any::Any;
use std::sync::Arc;
use std::time::Duration;
use autumn_web::cache::{
Cache, CacheBackendError, CacheFuture, FillEpoch, FillLockStatus, InvalidationError,
RawCacheBytes, jittered_ttl, record_invalidation_failure,
};
use redis::AsyncCommands as _;
use redis::aio::ConnectionManager;
use thiserror::Error;
use tracing::{debug, warn};
const RELEASE_FILL_LOCK_SCRIPT: &str = r"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
";
const FENCED_INSERT_SCRIPT: &str = r"
local current = redis.call('GET', KEYS[2])
if not current then current = '0' end
if current ~= ARGV[1] then return 0 end
if ARGV[3] == '' then
redis.call('SET', KEYS[1], ARGV[2])
else
redis.call('PSETEX', KEYS[1], ARGV[3], ARGV[2])
end
return 1
";
static FENCED_INSERT_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static FENCED_INSERT: std::sync::LazyLock<redis::Script> =
std::sync::LazyLock::new(|| redis::Script::new(FENCED_INSERT_SCRIPT));
const EPOCH_SEGMENT: &str = "__autumn_epoch__:";
fn is_reserved_key(key: &str) -> bool {
key.starts_with(EPOCH_SEGMENT)
}
#[derive(Debug, Error)]
pub enum RedisCacheError {
#[error("Redis connection error: {0}")]
Connection(#[from] redis::RedisError),
#[error("missing Redis URL in cache config")]
MissingUrl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct InvalidationRetry {
pub max_attempts: u32,
pub base_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for InvalidationRetry {
fn default() -> Self {
Self::new(3, Duration::from_millis(20), Duration::from_millis(200))
}
}
impl InvalidationRetry {
#[must_use]
pub const fn new(max_attempts: u32, base_backoff: Duration, max_backoff: Duration) -> Self {
Self {
max_attempts,
base_backoff,
max_backoff,
}
}
#[must_use]
pub const fn attempts(&self) -> u32 {
if self.max_attempts == 0 {
1
} else {
self.max_attempts
}
}
#[must_use]
pub fn backoff(&self, attempt: u32) -> Duration {
let nominal = self
.base_backoff
.saturating_mul(1_u32 << attempt.min(20))
.min(self.max_backoff);
jittered_ttl(nominal, 0.5).min(self.max_backoff)
}
}
#[derive(Clone)]
pub struct RedisCache {
manager: ConnectionManager,
key_prefix: String,
invalidation_retry: InvalidationRetry,
}
fn ttl_millis_for_redis(ttl: std::time::Duration) -> u64 {
let millis = ttl.as_millis().max(1);
u64::try_from(millis).unwrap_or(u64::MAX)
}
fn escape_redis_glob(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '\\' | '*' | '?' | '[') {
escaped.push('\\');
}
escaped.push(ch);
}
escaped
}
impl RedisCache {
pub async fn connect(
url: &str,
key_prefix: impl Into<String>,
) -> Result<Self, RedisCacheError> {
let client = autumn_web::redis_tls::open_client(url)?;
let manager = ConnectionManager::new(client).await?;
Ok(Self {
manager,
key_prefix: key_prefix.into(),
invalidation_retry: InvalidationRetry::default(),
})
}
pub async fn from_config(
config: &autumn_web::config::CacheRedisConfig,
) -> Result<Self, RedisCacheError> {
let url = config.url.as_deref().ok_or(RedisCacheError::MissingUrl)?;
Self::connect(url, &config.key_prefix).await
}
#[must_use]
pub const fn with_invalidation_retry(mut self, retry: InvalidationRetry) -> Self {
self.invalidation_retry = retry;
self
}
fn prefixed(&self, key: &str) -> String {
format!("{}:{}", self.key_prefix, key)
}
fn fill_lock_key(&self, key: &str) -> String {
format!("__autumn_fill_lock__:{}:{}", self.key_prefix, key)
}
fn epoch_key(&self, namespace: &str) -> String {
self.prefixed(&format!("{EPOCH_SEGMENT}{namespace}"))
}
async fn bump_epoch(&self, namespace: &str) -> Result<(), InvalidationError> {
let key = self.epoch_key(namespace);
self.retry_invalidation(&key, || {
let mut conn = self.manager.clone();
let key = key.clone();
async move { conn.incr::<_, _, i64>(key, 1).await.map(|_| ()) }
})
.await
}
async fn invalidate_namespace_fenced(&self, namespace: &str) -> Result<(), InvalidationError> {
let bumped = self.bump_epoch(namespace).await;
let swept = self
.sweep_with_retry(&self.namespace_pattern(namespace))
.await;
bumped.and(swept)
}
fn redis_get(&self, key: &str) -> Option<Vec<u8>> {
self.try_redis_get(key).ok().flatten()
}
fn try_redis_get(&self, key: &str) -> Result<Option<Vec<u8>>, redis::RedisError> {
let prefixed = self.prefixed(key);
let mut conn = self.manager.clone();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(async move { conn.get::<_, Option<Vec<u8>>>(&prefixed).await })
})
}
fn redis_set(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
let _ = self.try_redis_set(key, bytes, ttl);
}
fn try_redis_set(
&self,
key: &str,
bytes: Vec<u8>,
ttl: Option<std::time::Duration>,
) -> Result<(), CacheBackendError> {
if is_reserved_key(key) {
warn!(key, "RedisCache: refused a write to a reserved epoch key");
return Err(CacheBackendError::new("the key is reserved"));
}
let prefixed = self.prefixed(key);
let mut conn = self.manager.clone();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
if let Some(ttl) = ttl {
let millis = ttl_millis_for_redis(ttl);
redis::cmd("PSETEX")
.arg(&prefixed)
.arg(millis)
.arg(bytes)
.query_async::<()>(&mut conn)
.await
} else {
conn.set::<_, _, ()>(&prefixed, bytes).await
}
})
})
.map_err(|error| CacheBackendError::new(error.to_string()))
}
async fn sweep(&self, pattern: &str) -> Result<(), redis::RedisError> {
let mut conn = self.manager.clone();
let mut cursor: u64 = 0;
loop {
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100u32)
.query_async(&mut conn)
.await?;
let epoch_prefix = self.prefixed(EPOCH_SEGMENT);
let keys: Vec<String> = keys
.into_iter()
.filter(|key| !key.starts_with(&epoch_prefix))
.collect();
if !keys.is_empty() {
conn.del::<_, ()>(keys).await?;
}
cursor = next_cursor;
if cursor == 0 {
return Ok(());
}
}
}
async fn retry_invalidation<F, Fut>(
&self,
target: &str,
mut op: F,
) -> Result<(), InvalidationError>
where
F: FnMut() -> Fut + Send,
Fut: std::future::Future<Output = Result<(), redis::RedisError>> + Send,
{
let attempts = self.invalidation_retry.attempts();
let mut last_error = String::new();
for attempt in 0..attempts {
if attempt > 0 {
tokio::time::sleep(self.invalidation_retry.backoff(attempt - 1)).await;
}
match op().await {
Ok(()) => return Ok(()),
Err(error) => {
debug!(
target,
attempt = attempt + 1,
attempts,
error = %error,
"RedisCache: invalidation attempt failed"
);
last_error = error.to_string();
}
}
}
Err(InvalidationError::new(attempts, last_error))
}
async fn delete_key(&self, key: &str) -> Result<(), InvalidationError> {
if is_reserved_key(key) {
warn!(key, "RedisCache: refused to delete a reserved epoch key");
return Ok(());
}
let prefixed = self.prefixed(key);
self.retry_invalidation(key, || {
let mut conn = self.manager.clone();
let prefixed = prefixed.clone();
async move { conn.del::<_, ()>(prefixed).await }
})
.await
}
async fn sweep_with_retry(&self, pattern: &str) -> Result<(), InvalidationError> {
self.retry_invalidation(pattern, || self.sweep(pattern))
.await
}
fn namespace_pattern(&self, namespace: &str) -> String {
format!(
"{}:{}:*",
escape_redis_glob(&self.key_prefix),
escape_redis_glob(namespace)
)
}
}
fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}
fn report_dropped_invalidation_error(target: &str, error: &InvalidationError) {
warn!(
target,
error = %error,
"RedisCache: invalidation failed; stale data can be served until its TTL"
);
record_invalidation_failure();
}
impl Cache for RedisCache {
fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
self.redis_get(key)
.map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
}
fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>) {
let bytes: Option<Vec<u8>> = value
.downcast_ref::<RawCacheBytes>()
.map(|raw| raw.0.clone())
.or_else(|| {
value
.downcast_ref::<String>()
.and_then(|s| serde_json::to_vec(s).ok())
})
.or_else(|| {
value
.downcast_ref::<i64>()
.and_then(|n| serde_json::to_vec(n).ok())
})
.or_else(|| {
value
.downcast_ref::<i32>()
.and_then(|n| serde_json::to_vec(n).ok())
});
if let Some(bytes) = bytes {
self.redis_set(key, bytes, None);
debug!(key, "RedisCache: inserted via insert_value");
}
}
fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
self.redis_set(key, bytes, ttl);
debug!(key, "RedisCache: inserted via insert_raw_bytes");
}
fn try_get_value(
&self,
key: &str,
) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
self.try_redis_get(key)
.map(|found| {
found.map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
})
.map_err(|error| CacheBackendError::new(error.to_string()))
}
fn try_insert_raw_bytes(
&self,
key: &str,
bytes: Vec<u8>,
ttl: Option<std::time::Duration>,
) -> Result<(), CacheBackendError> {
self.try_redis_set(key, bytes, ttl)
}
fn try_invalidate(&self, key: &str) -> Result<(), InvalidationError> {
if is_reserved_key(key) {
return Err(InvalidationError::new(0, "the key is reserved"));
}
block_on(self.delete_key(key))
}
fn shares_fill_epoch(&self) -> bool {
true
}
fn fill_epoch(&self, namespace: &str) -> FillEpoch {
let key = self.epoch_key(namespace);
let mut conn = self.manager.clone();
let read: Result<Option<u64>, _> = block_on(async move { conn.get(&key).await });
match read {
Ok(epoch) => FillEpoch::Sampled(epoch.unwrap_or(0)),
Err(error) => {
debug!(namespace, error = %error, "RedisCache: epoch read failed");
FillEpoch::Unavailable
}
}
}
fn insert_raw_bytes_if_epoch(
&self,
key: &str,
bytes: Vec<u8>,
ttl: Option<std::time::Duration>,
namespace: &str,
sampled: u64,
) -> bool {
if is_reserved_key(key) {
warn!(key, "RedisCache: refused a write to a reserved epoch key");
return false;
}
let prefixed = self.prefixed(key);
let epoch_key = self.epoch_key(namespace);
let ttl_ms = ttl.map_or_else(String::new, |ttl| ttl_millis_for_redis(ttl).to_string());
let mut conn = self.manager.clone();
let result: Result<i64, _> = block_on(async move {
FENCED_INSERT
.key(&prefixed)
.key(&epoch_key)
.arg(sampled.to_string())
.arg(bytes)
.arg(ttl_ms)
.invoke_async(&mut conn)
.await
});
match result {
Ok(stored) => stored == 1,
Err(error) => {
if !FENCED_INSERT_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
warn!(
key,
error = %error,
"RedisCache: the fenced insert failed, so cached values are not stored. \
The Redis user needs +eval, +evalsha and +script|load"
);
}
debug!(key, error = %error, "RedisCache: fenced insert failed");
false
}
}
}
fn invalidate(&self, key: &str) {
match block_on(self.delete_key(key)) {
Ok(()) => debug!(key, "RedisCache: invalidated"),
Err(error) => report_dropped_invalidation_error(key, &error),
}
}
fn invalidate_namespace(&self, namespace: &str) -> bool {
match block_on(self.invalidate_namespace_fenced(namespace)) {
Ok(()) => true,
Err(error) => {
debug!(namespace, error = %error, "RedisCache: namespace sweep failed");
false
}
}
}
fn clear(&self) {
let pattern = format!("{}:*", escape_redis_glob(&self.key_prefix));
if let Err(error) = block_on(self.sweep_with_retry(&pattern)) {
report_dropped_invalidation_error(&pattern, &error);
}
}
fn invalidate_async<'a>(
&'a self,
key: &'a str,
) -> CacheFuture<'a, Result<(), InvalidationError>> {
Box::pin(self.delete_key(key))
}
fn invalidate_namespace_async<'a>(
&'a self,
namespace: &'a str,
) -> CacheFuture<'a, Result<(), InvalidationError>> {
Box::pin(self.invalidate_namespace_fenced(namespace))
}
fn try_acquire_fill_lock(&self, key: &str, token: &str, ttl: Duration) -> FillLockStatus {
let lock_key = self.fill_lock_key(key);
let millis = ttl_millis_for_redis(ttl);
let mut conn = self.manager.clone();
let token = token.to_owned();
let acquired = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
redis::cmd("SET")
.arg(&lock_key)
.arg(&token)
.arg("NX")
.arg("PX")
.arg(millis)
.query_async::<Option<String>>(&mut conn)
.await
})
});
match acquired {
Ok(Some(_)) => FillLockStatus::Acquired,
Ok(None) => FillLockStatus::Held,
Err(e) => {
debug!(key, error = %e, "RedisCache: fill lock acquire failed, treating as held");
FillLockStatus::Held
}
}
}
fn release_fill_lock(&self, key: &str, token: &str) {
let lock_key = self.fill_lock_key(key);
let mut conn = self.manager.clone();
let token = token.to_owned();
let result: Result<i64, _> = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
let script = redis::Script::new(RELEASE_FILL_LOCK_SCRIPT);
script
.key(&lock_key)
.arg(&token)
.invoke_async(&mut conn)
.await
})
});
match result {
Ok(_) => debug!(key, "RedisCache: released fill lock"),
Err(e) => {
debug!(key, error = %e, "RedisCache: fill lock release failed");
}
}
}
}
pub struct RedisCachePlugin;
impl RedisCachePlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Default for RedisCachePlugin {
fn default() -> Self {
Self::new()
}
}
impl autumn_web::plugin::Plugin for RedisCachePlugin {
fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
Some(autumn_web::plugin_contract::lockstep_contract(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
}
fn build(self, app: autumn_web::app::AppBuilder) -> autumn_web::app::AppBuilder {
app.on_startup(|state| async move {
let config = state
.extension::<autumn_web::config::AutumnConfig>()
.expect("AutumnConfig must be registered before RedisCachePlugin startup");
if !config.cache.is_redis() {
return Ok(());
}
let redis_cfg = &config.cache.redis;
let cache = RedisCache::from_config(redis_cfg).await.map_err(|e| {
autumn_web::AutumnError::service_unavailable_msg(format!(
"Failed to connect RedisCache: {e}"
))
})?;
state.set_cache(Arc::new(cache));
tracing::info!("RedisCache registered as global application cache");
register_cache_health_indicator(&state, &config);
Ok(())
})
}
}
fn register_cache_health_indicator(
state: &autumn_web::AppState,
config: &autumn_web::config::AutumnConfig,
) {
let Some(url) = config.cache.redis.url.as_deref() else {
return;
};
match autumn_web::redis_health::RedisHealthIndicator::shared(state, url) {
Ok(indicator) => {
let indicator = indicator.configured(&config.health);
let group = autumn_web::actuator::HealthIndicator::group(&indicator);
if let Err(error) = state.health_indicator_registry().register(
"redis:cache",
group,
Arc::new(indicator),
) {
tracing::warn!("{error}");
}
}
Err(error) => tracing::warn!(error = %error, "redis:cache health indicator not registered"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_health_indicator_follows_the_health_config() {
let state = autumn_web::AppState::for_test();
let mut config = autumn_web::config::AutumnConfig::default();
config.cache.redis.url = Some("redis://127.0.0.1:1".to_owned());
config.health.redis_readiness = true;
register_cache_health_indicator(&state, &config);
assert!(state.health_indicator_registry().contains("redis:cache"));
}
#[test]
fn no_cache_health_indicator_without_a_url() {
let state = autumn_web::AppState::for_test();
let config = autumn_web::config::AutumnConfig::default();
register_cache_health_indicator(&state, &config);
assert!(!state.health_indicator_registry().contains("redis:cache"));
}
use autumn_web::cache::{FillEpoch, get_cached, insert_cached, insert_cached_fenced};
use std::sync::atomic::{AtomicUsize, Ordering};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::redis::Redis as RedisImage;
#[test]
fn contract_declares_lockstep_with_own_crate() {
use autumn_web::plugin::Plugin;
let contract = RedisCachePlugin::new().contract().expect("a contract");
assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
assert_eq!(
contract.plugin_version.as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
assert_eq!(
contract.autumn_web.as_deref(),
Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
);
assert!(contract.experimental_surfaces.is_empty());
}
#[test]
fn redis_ttl_millis_preserves_subsecond_precision() {
assert_eq!(
ttl_millis_for_redis(std::time::Duration::from_millis(100)),
100
);
assert_eq!(
ttl_millis_for_redis(std::time::Duration::from_millis(1_500)),
1_500
);
}
#[test]
fn redis_ttl_millis_never_uses_zero() {
assert_eq!(ttl_millis_for_redis(std::time::Duration::ZERO), 1);
}
#[test]
fn the_cache_never_builds_a_redis_client_outside_the_shared_tls_guard() {
let needles = [concat!("Client::", "open("), concat!("build_with", "_tls(")];
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_sources(&src, &mut files);
assert!(!files.is_empty(), "no sources under {}", src.display());
files.sort();
let mut offenders = Vec::new();
for file in files {
let source = std::fs::read_to_string(&file).expect("read source");
for (index, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('*') {
continue;
}
if needles.iter().any(|needle| line.contains(needle)) {
offenders.push(format!(
"{}:{}: {}",
file.strip_prefix(&src).unwrap_or(&file).display(),
index + 1,
line.trim()
));
}
}
}
assert!(
offenders.is_empty(),
"build Redis clients through `autumn_web::redis_tls::open_client`, \
not directly — a `rediss://` URL otherwise reaches rustls with no \
process-level CryptoProvider installed and panics (#2172):\n{}",
offenders.join("\n")
);
}
fn collect_rust_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read_dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
collect_rust_sources(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
#[test]
fn the_shared_guard_classifies_the_tls_schemes_this_cache_is_deployed_on() {
assert!(autumn_web::redis_tls::url_needs_tls_crypto_provider(
"rediss://cache.redis.cache.windows.net:6380/"
));
assert!(!autumn_web::redis_tls::url_needs_tls_crypto_provider(
"redis://127.0.0.1:6379/"
));
}
#[test]
fn escape_redis_glob_leaves_plain_prefixes_unchanged() {
assert_eq!(escape_redis_glob("myapp:cache"), "myapp:cache");
}
#[test]
fn escape_redis_glob_escapes_scan_match_metacharacters() {
assert_eq!(escape_redis_glob("tenant:*"), r"tenant:\*");
assert_eq!(escape_redis_glob("a?b"), r"a\?b");
assert_eq!(escape_redis_glob("[ab]"), r"\[ab]");
assert_eq!(escape_redis_glob(r"back\slash"), r"back\\slash");
}
#[tokio::test(flavor = "multi_thread")]
async fn rediss_scheme_is_usable_without_a_tls_cargo_feature_error() {
let result = RedisCache::connect("rediss://127.0.0.1:1/", "test").await;
let Err(err) = result else {
panic!("connecting to a closed port must fail");
};
let message = err.to_string();
assert!(
!message.contains("feature is not enabled") && !message.contains("without the tls"),
"the `redis` crate must be built with a TLS feature (tokio-rustls-comp) \
so `rediss://` URLs actually connect: {message}"
);
}
async fn admin_conn(url: &str) -> redis::aio::MultiplexedConnection {
autumn_web::redis_tls::open_client(url)
.expect("client")
.get_multiplexed_async_connection()
.await
.expect("admin connection")
}
async fn reject_writes(admin: &mut redis::aio::MultiplexedConnection) {
redis::cmd("REPLICAOF")
.arg("127.0.0.1")
.arg(1)
.query_async::<()>(admin)
.await
.expect("REPLICAOF");
}
async fn accept_writes(admin: &mut redis::aio::MultiplexedConnection) {
redis::cmd("REPLICAOF")
.arg("NO")
.arg("ONE")
.query_async::<()>(admin)
.await
.expect("REPLICAOF NO ONE");
}
async fn key_exists(admin: &mut redis::aio::MultiplexedConnection, key: &str) -> bool {
admin.exists::<_, bool>(key).await.expect("EXISTS")
}
const FAST_RETRY: InvalidationRetry = InvalidationRetry {
max_attempts: 3,
base_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(5),
};
#[test]
fn application_keys_cannot_use_the_reserved_epoch_segment() {
assert!(is_reserved_key("__autumn_epoch__:orders"));
assert!(!is_reserved_key("orders:abc"));
assert!(!is_reserved_key("x:__autumn_epoch__:orders"));
}
#[test]
fn default_retry_policy_is_bounded() {
let retry = InvalidationRetry::default();
assert_eq!(retry.max_attempts, 3);
assert!(retry.base_backoff <= retry.max_backoff);
assert!(retry.max_backoff <= Duration::from_secs(1));
}
#[test]
fn retry_backoff_grows_and_never_passes_the_cap() {
let retry = InvalidationRetry {
max_attempts: 10,
base_backoff: Duration::from_millis(10),
max_backoff: Duration::from_millis(80),
};
for _ in 0..200 {
for attempt in 0..10 {
let nominal = retry
.base_backoff
.saturating_mul(1 << attempt.min(20))
.min(retry.max_backoff);
let delay = retry.backoff(attempt);
assert!(delay <= retry.max_backoff, "{delay:?} passes the cap");
assert!(delay >= nominal / 2, "{delay:?} below half of {nominal:?}");
}
}
}
#[test]
fn a_zero_attempt_policy_still_tries_once() {
let retry = InvalidationRetry {
max_attempts: 0,
..InvalidationRetry::default()
};
assert_eq!(retry.attempts(), 1);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_invalidate_async_surfaces_readonly_del_failure() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "inv-fail")
.await
.unwrap()
.with_invalidation_retry(FAST_RETRY);
admin.set::<_, _, ()>("inv-fail:k", "1").await.unwrap();
reject_writes(&mut admin).await;
let err = cache
.invalidate_async("k")
.await
.expect_err("a rejected DEL must surface as an error");
assert_eq!(err.attempts(), 3, "all attempts must run: {err}");
assert!(
err.reason().to_ascii_lowercase().contains("read only"),
"the reason must carry the Redis error: {err}"
);
assert!(
key_exists(&mut admin, "inv-fail:k").await,
"the key must still exist, so the error is true"
);
accept_writes(&mut admin).await;
cache
.invalidate_async("k")
.await
.expect("DEL succeeds once Redis accepts writes");
assert!(!key_exists(&mut admin, "inv-fail:k").await);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_invalidate_retries_until_del_succeeds() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "inv-retry")
.await
.unwrap()
.with_invalidation_retry(InvalidationRetry {
max_attempts: 50,
base_backoff: Duration::from_millis(10),
max_backoff: Duration::from_millis(10),
});
admin.set::<_, _, ()>("inv-retry:k", "1").await.unwrap();
reject_writes(&mut admin).await;
let mut healer = admin.clone();
let heal = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
accept_writes(&mut healer).await;
});
cache
.invalidate_async("k")
.await
.expect("a transient DEL failure must be retried, not dropped");
heal.await.unwrap();
assert!(!key_exists(&mut admin, "inv-retry:k").await);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_invalidate_namespace_async_surfaces_failure() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "inv-ns")
.await
.unwrap()
.with_invalidation_retry(FAST_RETRY);
admin.set::<_, _, ()>("inv-ns:reads:1", "1").await.unwrap();
reject_writes(&mut admin).await;
let err = cache
.invalidate_namespace_async("reads")
.await
.expect_err("a rejected sweep must surface as an error");
assert_eq!(err.attempts(), 3, "{err}");
assert!(key_exists(&mut admin, "inv-ns:reads:1").await);
accept_writes(&mut admin).await;
cache
.invalidate_namespace_async("reads")
.await
.expect("the sweep succeeds once Redis accepts writes");
assert!(!key_exists(&mut admin, "inv-ns:reads:1").await);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_fill_on_another_replica_cannot_resurrect_an_invalidated_value() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let replica_a = RedisCache::connect(&url, "fence").await.unwrap();
let replica_b = RedisCache::connect(&url, "fence").await.unwrap();
let epoch = replica_b.fill_epoch("reads");
assert_eq!(epoch, FillEpoch::Sampled(0));
replica_a
.invalidate_namespace_async("reads")
.await
.expect("invalidation");
let stored = insert_cached_fenced(
&replica_b,
"reads:k",
"old".to_string(),
None,
"reads",
epoch,
);
assert!(!stored, "the shared epoch moved");
assert!(!key_exists(&mut admin, "fence:reads:k").await);
let epoch = replica_b.fill_epoch("reads");
assert!(insert_cached_fenced(
&replica_b,
"reads:forever",
1_i64,
None,
"reads",
epoch
));
let left: i64 = redis::cmd("PTTL")
.arg("fence:reads:forever")
.query_async(&mut admin)
.await
.unwrap();
assert_eq!(left, -1, "no TTL means no expiry");
let epoch = replica_b.fill_epoch("reads");
assert_eq!(epoch, FillEpoch::Sampled(1));
let ttl = Some(Duration::from_secs(60));
assert!(insert_cached_fenced(
&replica_b,
"reads:k",
"new".to_string(),
ttl,
"reads",
epoch
));
assert_eq!(
get_cached::<String>(&replica_a, "reads:k").as_deref(),
Some("new")
);
let left: i64 = redis::cmd("PTTL")
.arg("fence:reads:k")
.query_async(&mut admin)
.await
.unwrap();
assert!(left > 0, "the TTL must survive the Lua insert: {left}");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_epoch_is_per_namespace_and_survives_clear() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache = RedisCache::connect(&url, "fence-ns").await.unwrap();
cache.invalidate_namespace_async("a").await.unwrap();
assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
assert_eq!(cache.fill_epoch("b"), FillEpoch::Sampled(0));
cache.clear();
assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
cache
.invalidate_async("__autumn_epoch__:a")
.await
.expect("a refused delete is not an error");
cache.invalidate("__autumn_epoch__:a");
assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
let mut admin = admin_conn(&url).await;
assert!(key_exists(&mut admin, "fence-ns:__autumn_epoch__:a").await);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_failed_epoch_bump_fails_the_invalidation() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "fence-fail")
.await
.unwrap()
.with_invalidation_retry(FAST_RETRY);
reject_writes(&mut admin).await;
assert!(
cache.invalidate_namespace_async("reads").await.is_err(),
"a `true` must not be reported when the shared fence did not move"
);
assert!(!cache.invalidate_namespace("reads"));
accept_writes(&mut admin).await;
assert!(cache.invalidate_namespace_async("reads").await.is_ok());
}
static GLOBAL_STATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn coherence_async_invalidation_reaches_redis_and_reports_failure() {
let _guard = GLOBAL_STATE.lock().await;
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "e2e")
.await
.unwrap()
.with_invalidation_retry(FAST_RETRY);
autumn_web::cache::set_global_cache(Arc::new(cache));
let namespace = "e2e::reads::count";
admin
.set::<_, _, ()>(format!("e2e:{namespace}:a"), "1")
.await
.unwrap();
let complete = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
let gone = !key_exists(&mut admin, &format!("e2e:{namespace}:a")).await;
admin
.set::<_, _, ()>(format!("e2e:{namespace}:b"), "1")
.await
.unwrap();
reject_writes(&mut admin).await;
let before = autumn_web::cache::invalidation_failures_total();
let refused = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
let after = autumn_web::cache::invalidation_failures_total();
let kept = key_exists(&mut admin, &format!("e2e:{namespace}:b")).await;
accept_writes(&mut admin).await;
autumn_web::cache::clear_global_cache();
assert!(complete && gone, "a healthy Redis drops the namespace");
assert!(!refused, "a rejected sweep must report incomplete");
assert_eq!(after, before + 1, "and count one failure");
assert!(kept, "the key is still there, so the report is true");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_sync_invalidate_counts_final_failure() {
let _guard = GLOBAL_STATE.lock().await;
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let mut admin = admin_conn(&url).await;
let cache = RedisCache::connect(&url, "inv-sync")
.await
.unwrap()
.with_invalidation_retry(FAST_RETRY);
admin.set::<_, _, ()>("inv-sync:k", "1").await.unwrap();
reject_writes(&mut admin).await;
let before = autumn_web::cache::invalidation_failures_total();
autumn_web::cache::Cache::invalidate(&cache, "k");
let after = autumn_web::cache::invalidation_failures_total();
assert_eq!(
after,
before + 1,
"a final DEL failure must increment autumn_cache_invalidation_failures_total once"
);
assert!(key_exists(&mut admin, "inv-sync:k").await);
accept_writes(&mut admin).await;
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_cache_insert_get_invalidate() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache = RedisCache::connect(&url, "test").await.unwrap();
insert_cached(&cache, "hello", "world".to_string(), None);
let raw = cache.get_value("hello").expect("should be present");
let raw_bytes = raw.downcast_ref::<RawCacheBytes>().expect("RawCacheBytes");
let v: serde_json::Value = serde_json::from_slice(&raw_bytes.0).unwrap();
assert_eq!(v, serde_json::json!("world"));
let s: Option<String> = get_cached(&cache, "hello");
assert_eq!(s.as_deref(), Some("world"));
autumn_web::cache::Cache::invalidate(&cache, "hello");
assert!(cache.get_value("hello").is_none());
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_try_methods_round_trip_and_report_outage() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache = RedisCache::connect(&url, "test").await.unwrap();
cache
.try_insert_raw_bytes("k", b"1".to_vec(), None)
.unwrap();
assert!(cache.try_get_value("k").unwrap().is_some());
assert!(cache.try_get_value("absent").unwrap().is_none());
cache.try_invalidate("k").unwrap();
assert!(cache.try_get_value("k").unwrap().is_none());
container.stop().await.unwrap();
assert!(cache.try_get_value("k").is_err());
assert!(
cache
.try_insert_raw_bytes("k", b"1".to_vec(), None)
.is_err()
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_cache_cross_replica_invalidation() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let replica_a = RedisCache::connect(&url, "xreplica").await.unwrap();
let replica_b = RedisCache::connect(&url, "xreplica").await.unwrap();
let start = std::time::Instant::now();
insert_cached(&replica_a, "key", "value".to_string(), None);
let seen: Option<String> = get_cached(&replica_b, "key");
let elapsed = start.elapsed();
assert_eq!(
seen.as_deref(),
Some("value"),
"replica B must read the value written by replica A"
);
autumn_web::cache::Cache::invalidate(&replica_a, "key");
let gone: Option<String> = get_cached(&replica_b, "key");
assert!(gone.is_none());
assert!(
elapsed.as_millis() < 50,
"cross-replica lag {elapsed:?} exceeded 50 ms"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_cache_serde_struct_round_trip() {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct Item {
id: i32,
name: String,
}
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache_a = RedisCache::connect(&url, "structs").await.unwrap();
let cache_b = RedisCache::connect(&url, "structs").await.unwrap();
let item = Item {
id: 1,
name: "widget".into(),
};
insert_cached(&cache_a, "item:1", item.clone(), None);
let retrieved: Option<Item> = get_cached(&cache_a, "item:1");
assert_eq!(retrieved, Some(item.clone()));
let from_b: Option<Item> = get_cached(&cache_b, "item:1");
assert_eq!(from_b, Some(item));
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_cache_response_layer_caches_http_gets() {
use std::convert::Infallible;
use autumn_web::cache::CacheResponseLayer;
use autumn_web::reexports::axum::body::{Body, to_bytes};
use autumn_web::reexports::http::{Request, StatusCode};
use tower::{Service, ServiceBuilder, ServiceExt};
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache: Arc<dyn autumn_web::cache::Cache> =
Arc::new(RedisCache::connect(&url, "http-response").await.unwrap());
let counter = Arc::new(AtomicUsize::new(0));
let inner = {
let counter = counter.clone();
tower::service_fn(move |_req: Request<Body>| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(
autumn_web::reexports::axum::response::Response::builder()
.status(StatusCode::OK)
.header("x-cache-test", "redis")
.body(Body::from("redis-body"))
.expect("infallible response builder"),
)
}
})
};
let mut svc = ServiceBuilder::new()
.layer(CacheResponseLayer::from_shared(cache.clone()))
.service(inner);
let req = Request::get("/redis-backed")
.body(Body::empty())
.expect("infallible response builder");
let resp = svc
.ready()
.await
.expect("service ready")
.call(req)
.await
.expect("infallible service");
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::get("/redis-backed")
.body(Body::empty())
.expect("infallible response builder");
let resp = svc
.ready()
.await
.expect("service ready")
.call(req)
.await
.expect("infallible service");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("x-cache-test")
.and_then(|v| v.to_str().ok()),
Some("redis")
);
let body = to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body collection");
assert_eq!(body.as_ref(), b"redis-body");
assert_eq!(counter.load(Ordering::SeqCst), 1);
cache.invalidate("http:/redis-backed");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_fill_lock_acquire_conflict_release() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache = RedisCache::connect(&url, "fill-lock-test").await.unwrap();
assert_eq!(
cache.try_acquire_fill_lock("k", "token-a", Duration::from_secs(10)),
FillLockStatus::Acquired
);
assert_eq!(
cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
FillLockStatus::Held,
"a second replica must not acquire a lock already held"
);
cache.release_fill_lock("k", "token-b");
assert_eq!(
cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
FillLockStatus::Held,
"releasing with a stale/foreign token must be a no-op"
);
cache.release_fill_lock("k", "token-a");
assert_eq!(
cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
FillLockStatus::Acquired
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_fill_lock_expires_after_ttl() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache = RedisCache::connect(&url, "fill-lock-ttl-test")
.await
.unwrap();
assert_eq!(
cache.try_acquire_fill_lock("k", "crashed-filler", Duration::from_millis(200)),
FillLockStatus::Acquired
);
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(
cache.try_acquire_fill_lock("k", "new-filler", Duration::from_secs(10)),
FillLockStatus::Acquired,
"the lock TTL must bound damage from a crashed filler"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_lock_wait_timeout_falls_back_to_self_fill() {
use autumn_web::cache::GetOrComputeOptions;
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache: Arc<dyn Cache> = Arc::new(
RedisCache::connect(&url, "lock-timeout-test")
.await
.unwrap(),
);
let key = "stuck-key";
assert_eq!(
cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
FillLockStatus::Acquired
);
let fill_count = Arc::new(AtomicUsize::new(0));
let fc = fill_count.clone();
let opts = GetOrComputeOptions::new()
.distributed_fill_lock(true)
.lock_wait_timeout(Duration::from_millis(300))
.lock_poll_interval(Duration::from_millis(50));
let value: i32 =
autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
fc.fetch_add(1, Ordering::SeqCst);
Ok::<i32, String>(123)
})
.await
.unwrap();
assert_eq!(value, 123);
assert_eq!(
fill_count.load(Ordering::SeqCst),
1,
"after the wait timeout the caller must fall back to filling locally"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_lock_wait_timeout_not_overshot_by_poll_interval() {
use autumn_web::cache::GetOrComputeOptions;
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache: Arc<dyn Cache> = Arc::new(
RedisCache::connect(&url, "lock-timeout-overshoot-test")
.await
.unwrap(),
);
let key = "stuck-key-overshoot";
assert_eq!(
cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
FillLockStatus::Acquired
);
let opts = GetOrComputeOptions::new()
.distributed_fill_lock(true)
.lock_wait_timeout(Duration::from_millis(100))
.lock_poll_interval(Duration::from_secs(5));
let start = std::time::Instant::now();
let value: i32 =
autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
Ok::<i32, String>(456)
})
.await
.unwrap();
let elapsed = start.elapsed();
assert_eq!(value, 456);
assert!(
elapsed < Duration::from_millis(500),
"lock_wait_timeout must bound the wait even when lock_poll_interval is much larger; took {elapsed:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_get_or_compute_round_trip() {
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let cache: Arc<dyn Cache> = Arc::new(
RedisCache::connect(&url, "read-through-test")
.await
.unwrap(),
);
let fill_count = Arc::new(AtomicUsize::new(0));
let fc = fill_count.clone();
let v: String = autumn_web::cache::get_or_compute(
&cache,
"rt-key",
Some(Duration::from_secs(60)),
move || async move {
fc.fetch_add(1, Ordering::SeqCst);
Ok::<String, String>("value-from-redis".to_string())
},
)
.await
.unwrap();
assert_eq!(v, "value-from-redis");
let fc = fill_count.clone();
let v: String = autumn_web::cache::get_or_compute(
&cache,
"rt-key",
Some(Duration::from_secs(60)),
move || async move {
fc.fetch_add(1, Ordering::SeqCst);
Ok::<String, String>("should-not-run".to_string())
},
)
.await
.unwrap();
assert_eq!(
v, "value-from-redis",
"second call must hit via RawCacheBytes deserialization"
);
assert_eq!(fill_count.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Docker (testcontainers)"]
async fn redis_cross_replica_single_fill() {
use autumn_web::cache::GetOrComputeOptions;
let container = RedisImage::default().start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let replica_a: Arc<dyn Cache> =
Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
let replica_b: Arc<dyn Cache> =
Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
let fill_count = Arc::new(AtomicUsize::new(0));
let key = "hot-key";
let mut handles = Vec::new();
for replica in [replica_a, replica_b] {
for _ in 0..4 {
let replica = replica.clone();
let fc = fill_count.clone();
let opts = GetOrComputeOptions::new()
.distributed_fill_lock(true)
.lock_poll_interval(Duration::from_millis(30))
.lock_wait_timeout(Duration::from_secs(5));
handles.push(tokio::spawn(async move {
autumn_web::cache::get_or_compute_with::<String, String, _, _>(
&replica,
key,
opts,
move || async move {
fc.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(300)).await;
Ok("cross-replica-value".to_string())
},
)
.await
}));
}
}
for h in handles {
let v = h.await.unwrap().unwrap();
assert_eq!(v, "cross-replica-value");
}
assert_eq!(
fill_count.load(Ordering::SeqCst),
1,
"the distributed fill lock must limit total fills to 1 across both replicas"
);
}
}