use crate::time::Duration;
use crate::{
ConcurrentCacheBase, ConcurrentCacheRefreshOnHit, ConcurrentCacheTtl, ConcurrentCached,
};
use parking_lot::Mutex;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt::Display;
use std::marker::PhantomData;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
static SELF_HEAL_CONDITIONAL_DEL: LazyLock<redis::Script> = LazyLock::new(|| {
redis::Script::new(
"if redis.call('GET', KEYS[1]) == ARGV[1] then \
return redis.call('DEL', KEYS[1]) else return 0 end",
)
});
pub struct RedisCacheBuilder<K, V> {
ttl: Option<Duration>,
refresh: bool,
namespace: String,
prefix: Option<String>,
connection_string: Option<String>,
pool_max_size: Option<u32>,
pool_min_idle: Option<u32>,
pool_max_lifetime: Option<Duration>,
pool_idle_timeout: Option<Duration>,
pool_connection_timeout: Option<Duration>,
strict_deserialization: bool,
_phantom: PhantomData<fn() -> (K, V)>,
}
const ENV_KEY: &str = "CACHED_REDIS_CONNECTION_STRING";
const DEFAULT_NAMESPACE: &str = "cached-redis-store:";
fn ttl_millis(ttl: Duration) -> Result<u64, RedisCacheError> {
if ttl.is_zero() {
return Err(RedisCacheError::redis(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"invalid ttl: must be greater than zero",
format!("got {ttl:?}"),
))));
}
let millis = ttl.as_millis();
Ok(millis.min(i64::MAX as u128).max(1) as u64)
}
fn ttl_millis_i64(ttl: Duration) -> Result<i64, RedisCacheError> {
Ok(ttl_millis(ttl)? as i64)
}
const KEY_FIELD_SEPARATOR: char = ':';
const KEY_FIELD_ESCAPE: char = '%';
fn escape_key_field(field: &str) -> std::borrow::Cow<'_, str> {
if !field.contains([KEY_FIELD_SEPARATOR, KEY_FIELD_ESCAPE]) {
return std::borrow::Cow::Borrowed(field);
}
let mut out = String::with_capacity(field.len() + 4);
for c in field.chars() {
match c {
KEY_FIELD_SEPARATOR => out.push_str("%3A"),
KEY_FIELD_ESCAPE => out.push_str("%25"),
_ => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
fn canonical_namespace(namespace: &str) -> &str {
namespace.trim_end_matches(KEY_FIELD_SEPARATOR)
}
fn join_key_fields(namespace: &str, prefix: &str, key: &str) -> String {
let mut out = String::with_capacity(namespace.len() + prefix.len() + key.len() + 2);
out.push_str(namespace);
out.push(KEY_FIELD_SEPARATOR);
out.push_str(prefix);
out.push(KEY_FIELD_SEPARATOR);
out.push_str(key);
out
}
fn generate_redis_key(namespace: &str, prefix: &str, key: &str) -> String {
join_key_fields(
&escape_key_field(canonical_namespace(namespace)),
&escape_key_field(prefix),
&escape_key_field(key),
)
}
fn clear_match_pattern(namespace: &str, prefix: &str) -> String {
fn escape_glob(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '*' | '?' | '[' | ']' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
join_key_fields(
&escape_glob(&escape_key_field(canonical_namespace(namespace))),
&escape_glob(&escape_key_field(prefix)),
"*",
)
}
#[cfg(test)]
mod clear_pattern_tests {
use super::{clear_match_pattern, escape_key_field, generate_redis_key};
#[test]
fn plain_segments_get_scope_and_trailing_star() {
assert_eq!(clear_match_pattern("ns", "p"), "ns:p:*");
assert_eq!(clear_match_pattern("", "p"), ":p:*");
assert_eq!(clear_match_pattern("ns", ""), "ns::*");
}
#[test]
fn glob_metacharacters_in_segments_are_escaped() {
assert_eq!(clear_match_pattern("ns", "cache[v2]"), "ns:cache\\[v2\\]:*");
assert_eq!(clear_match_pattern("n*s", "p?x"), "n\\*s:p\\?x:*");
assert_eq!(clear_match_pattern("back\\slash", "p"), "back\\\\slash:p:*");
}
#[test]
fn separator_and_escape_characters_are_percent_escaped_in_the_pattern() {
assert_eq!(clear_match_pattern("a:b", "p"), "a%3Ab:p:*");
assert_eq!(clear_match_pattern("ns", "a:b"), "ns:a%3Ab:*");
assert_eq!(clear_match_pattern("100%", "p"), "100%25:p:*");
}
#[test]
fn percent_escaping_and_glob_escaping_do_not_interfere() {
assert_eq!(clear_match_pattern("a:b*", "p"), "a%3Ab\\*:p:*");
assert_eq!(clear_match_pattern("ns", "[v2]:%"), "ns:\\[v2\\]%3A%25:*");
}
fn assert_pattern_scopes_keys(namespace: &str, prefix: &str, key: &str) {
fn unescape_glob(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.chars();
while let Some(c) = chars.next() {
if c == '\\' {
out.extend(chars.next());
} else {
out.push(c);
}
}
out
}
let pattern = clear_match_pattern(namespace, prefix);
let literal = unescape_glob(
pattern
.strip_suffix('*')
.expect("the clear pattern always ends with the key wildcard"),
);
let generated = generate_redis_key(namespace, prefix, key);
assert!(
generated.starts_with(&literal),
"clear pattern {pattern:?} (literal scope {literal:?}) must cover key {generated:?}"
);
assert_eq!(
&generated[literal.len()..],
escape_key_field(key).as_ref(),
"the wildcard must stand for exactly the escaped key field"
);
}
#[test]
fn pattern_scope_and_generated_keys_agree() {
assert_pattern_scopes_keys("ns", "p", "k");
assert_pattern_scopes_keys("", "p", "k");
assert_pattern_scopes_keys("ns", "", "k");
assert_pattern_scopes_keys("cached-redis-store:", "p", "k");
assert_pattern_scopes_keys("a:b", "p", "k");
assert_pattern_scopes_keys("ns", "a:b", "k:with:colons");
assert_pattern_scopes_keys("100%", "p%", "50%:k");
assert_pattern_scopes_keys("n*s", "cache[v2]", "k?");
assert_pattern_scopes_keys("back\\slash", "p", "k\\");
}
#[test]
fn pattern_no_longer_covers_a_neighbouring_cache() {
let scope = clear_match_pattern("a:b", "p");
let literal = scope
.strip_suffix('*')
.expect("the clear pattern always ends with the key wildcard");
let neighbour = generate_redis_key("a", "b:p", "k");
assert!(
!neighbour.starts_with(literal),
"clear pattern {scope:?} must not cover the neighbouring key {neighbour:?}"
);
}
#[test]
fn empty_namespace_scope_does_not_cover_a_cache_named_after_its_prefix() {
let scope = clear_match_pattern("", "p");
assert_eq!(scope, ":p:*");
let literal = scope
.strip_suffix('*')
.expect("the clear pattern always ends with the key wildcard");
for neighbour_prefix in ["q", "p", ""] {
let neighbour = generate_redis_key("p", neighbour_prefix, "k");
assert!(
!neighbour.starts_with(literal),
"clear pattern {scope:?} must not cover the key {neighbour:?} of a cache \
whose namespace equals this cache's prefix"
);
}
}
}
#[cfg(test)]
mod generate_key_tests {
use super::{DEFAULT_NAMESPACE, generate_redis_key};
#[test]
fn default_namespace_trailing_colon_trimmed_and_rejoined() {
assert_eq!(
generate_redis_key(DEFAULT_NAMESPACE, "my_prefix", "my_key"),
"cached-redis-store:my_prefix:my_key"
);
assert!(DEFAULT_NAMESPACE.ends_with(':'));
}
#[test]
fn empty_fields_keep_their_separators() {
assert_eq!(generate_redis_key("", "p", "k"), ":p:k");
assert_eq!(generate_redis_key("ns", "", "k"), "ns::k");
assert_eq!(generate_redis_key("", "", "k"), "::k");
assert_eq!(generate_redis_key(":", "", "k"), "::k"); }
#[test]
fn full_form_and_multiple_trailing_colons() {
assert_eq!(generate_redis_key("ns", "p", "k"), "ns:p:k");
assert_eq!(generate_redis_key("ns:::", "p", "k"), "ns:p:k");
}
#[test]
fn interior_separators_and_escape_characters_are_escaped() {
assert_eq!(generate_redis_key("a:b", "p", "k"), "a%3Ab:p:k");
assert_eq!(generate_redis_key("ns", "a:b", "k"), "ns:a%3Ab:k");
assert_eq!(generate_redis_key("ns", "p", "a:b"), "ns:p:a%3Ab");
assert_eq!(generate_redis_key("a%3Ab", "p", "k"), "a%253Ab:p:k");
assert_eq!(generate_redis_key("100%", "p", "k"), "100%25:p:k");
}
#[test]
fn interior_separators_no_longer_collide() {
let with_interior = generate_redis_key("ns:evil", "", "k");
let split_across = generate_redis_key("ns", "evil", "k");
assert_ne!(
with_interior, split_across,
"an interior separator must not alias a differently-split namespace/prefix pair"
);
assert_eq!(with_interior, "ns%3Aevil::k");
assert_eq!(split_across, "ns:evil:k");
}
#[test]
fn distinct_triples_map_to_distinct_keys() {
let triples = [
("", "p", "k"),
("p", "", "k"),
("", "", "p:k"),
("a", "b", "c"),
("a:b", "c", "d"),
("a", "b:c", "d"),
("a", "b", "c:d"),
("a:b:c", "", "d"),
("a%3Ab", "c", "d"),
("a", "%", "d"),
("a", "%25", "d"),
("ns", "p", ""),
("ns", "", "p"),
];
let mut seen = std::collections::HashMap::new();
for (namespace, prefix, key) in triples {
let generated = generate_redis_key(namespace, prefix, key);
if let Some(previous) = seen.insert(generated.clone(), (namespace, prefix, key)) {
panic!(
"{:?} and {:?} both map to {generated:?}",
previous,
(namespace, prefix, key)
);
}
}
}
#[test]
fn every_field_is_recoverable_from_the_key() {
fn unescape_field(field: &str) -> String {
field.replace("%3A", ":").replace("%25", "%")
}
fn decode(generated: &str) -> (String, String, String) {
let fields: Vec<&str> = generated.splitn(3, ':').collect();
match fields.as_slice() {
[namespace, prefix, key] => (
unescape_field(namespace),
unescape_field(prefix),
unescape_field(key),
),
other => panic!("unexpected key layout: {other:?}"),
}
}
for (namespace, prefix, key) in [
("ns", "p", "k"),
("", "p", "k"),
("ns", "", "k"),
("", "", ""),
("a:b", "c:d", "e:f"),
("100%", "%3A", "%25"),
("cached-redis-store", "my_prefix", "my_key"),
] {
let generated = generate_redis_key(namespace, prefix, key);
assert_eq!(
decode(&generated),
(namespace.to_string(), prefix.to_string(), key.to_string()),
"key {generated:?} must decode back to its fields"
);
}
}
}
#[cfg(test)]
mod ttl_millis_tests {
use super::{ttl_millis, ttl_millis_i64};
use crate::time::Duration;
#[test]
fn zero_is_rejected() {
assert!(ttl_millis(Duration::ZERO).is_err());
assert!(ttl_millis_i64(Duration::ZERO).is_err());
}
#[test]
fn whole_seconds_become_milliseconds() {
assert_eq!(ttl_millis(Duration::from_secs(1)).unwrap(), 1_000);
assert_eq!(ttl_millis(Duration::from_secs(60)).unwrap(), 60_000);
assert_eq!(ttl_millis_i64(Duration::from_secs(60)).unwrap(), 60_000);
}
#[test]
fn subsecond_precision_is_preserved() {
assert_eq!(ttl_millis(Duration::from_millis(1)).unwrap(), 1);
assert_eq!(ttl_millis(Duration::from_millis(250)).unwrap(), 250);
assert_eq!(ttl_millis(Duration::from_millis(999)).unwrap(), 999);
}
#[test]
fn nonzero_submillisecond_clamps_to_one() {
let one_ns = Duration::from_nanos(1);
assert!(!one_ns.is_zero());
assert_eq!(one_ns.as_millis(), 0);
assert_eq!(ttl_millis(one_ns).unwrap(), 1);
assert_eq!(ttl_millis_i64(one_ns).unwrap(), 1);
assert_eq!(ttl_millis(Duration::from_nanos(999_999)).unwrap(), 1);
assert_eq!(ttl_millis(Duration::from_micros(500)).unwrap(), 1);
}
#[test]
fn zero_never_reaches_the_clamp() {
assert!(ttl_millis(Duration::ZERO).is_err());
assert!(ttl_millis_i64(Duration::ZERO).is_err());
}
#[test]
fn subsecond_mixed_passes_through() {
assert_eq!(ttl_millis(Duration::from_millis(1_500)).unwrap(), 1_500);
assert_eq!(ttl_millis(Duration::new(5, 1_000_000)).unwrap(), 5_001);
}
#[test]
fn very_large_clamps_to_i64_max() {
let huge = Duration::from_secs(u64::MAX);
assert_eq!(ttl_millis(huge).unwrap(), i64::MAX as u64);
assert_eq!(ttl_millis_i64(huge).unwrap(), i64::MAX);
}
}
#[cfg(test)]
mod builder_ttl_setter_tests {
use super::RedisCacheBuilder;
use crate::time::Duration;
#[test]
fn ttl_secs_and_ttl_millis_set_duration() {
let b = RedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_secs(7);
assert_eq!(b.ttl, Some(Duration::from_secs(7)));
let b = RedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_millis(250);
assert_eq!(b.ttl, Some(Duration::from_millis(250)));
}
#[test]
fn ttl_setters_override_last_writer_wins() {
let b = RedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl(Duration::from_secs(10))
.ttl_secs(5);
assert_eq!(b.ttl, Some(Duration::from_secs(5)));
let b = RedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_secs(10)
.ttl_millis(500);
assert_eq!(b.ttl, Some(Duration::from_millis(500)));
let b = RedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_millis(500)
.ttl(Duration::from_secs(3));
assert_eq!(b.ttl, Some(Duration::from_secs(3)));
}
}
#[cfg(test)]
mod builder_empty_prefix_tests {
use super::super::BuildError;
use super::{RedisCacheBuildError, RedisCacheBuilder};
use crate::time::Duration;
#[test]
fn empty_prefix_with_default_namespace_is_rejected() {
let result = RedisCacheBuilder::<String, String>::new()
.prefix("")
.ttl(Duration::from_secs(1))
.build();
assert!(
matches!(
result,
Err(RedisCacheBuildError::Build(BuildError::InvalidValue {
field: "prefix",
..
}))
),
"expected InvalidValue for empty prefix"
);
}
#[test]
fn empty_prefix_with_custom_namespace_is_rejected() {
let result = RedisCacheBuilder::<String, String>::new()
.prefix("")
.ttl(Duration::from_secs(1))
.namespace("my-ns")
.build();
assert!(
matches!(
result,
Err(RedisCacheBuildError::Build(BuildError::InvalidValue {
field: "prefix",
..
}))
),
"expected InvalidValue for empty prefix"
);
}
#[test]
fn non_empty_prefix_passes_the_guard() {
let result = RedisCacheBuilder::<String, String>::new()
.prefix("my-prefix")
.ttl(Duration::from_secs(1))
.namespace("")
.build();
assert!(
!matches!(
result,
Err(RedisCacheBuildError::Build(BuildError::InvalidValue {
field: "prefix",
..
}))
),
"prefix guard must not fire when prefix is non-empty"
);
}
}
#[cfg(test)]
mod var_error_sanitize_tests {
use super::{RedisCacheBuildError, sanitize_var_error};
#[test]
fn not_unicode_value_is_redacted() {
#[cfg(unix)]
let raw = {
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(b"redis://user:s3cret@host\xff".to_vec())
};
#[cfg(not(unix))]
let raw = std::ffi::OsString::from("redis://user:s3cret@host");
let sanitized = sanitize_var_error(std::env::VarError::NotUnicode(raw));
let err = RedisCacheBuildError::MissingConnectionString {
env_key: "CACHED_REDIS_CONNECTION_STRING".to_string(),
error: sanitized,
};
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(
!display.contains("s3cret"),
"Display leaked the value: {display}"
);
assert!(!debug.contains("s3cret"), "Debug leaked the value: {debug}");
assert!(display.contains("[REDACTED connection string]"));
}
#[test]
fn not_present_is_preserved() {
assert!(matches!(
sanitize_var_error(std::env::VarError::NotPresent),
std::env::VarError::NotPresent
));
}
}
#[cfg(test)]
mod credential_leak_tests {
use super::{RedisCacheBuildError, RedisCacheBuilder};
use crate::time::Duration;
#[test]
fn bad_url_with_password_does_not_leak_password_in_build_error() {
let secret = "super_secret_password_xyz";
let bad_url = format!("not-redis://:{secret}@nonexistent-host:9999");
let result = RedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.build();
let err = result.expect_err("build must fail with a bad URL");
let display = err.to_string();
let debug = format!("{err:?}");
assert!(
!display.contains(secret),
"Display must not expose the password; got: {display}"
);
assert!(
!debug.contains(secret),
"Debug must not expose the password; got: {debug}"
);
assert!(
!display.contains(&bad_url) && !debug.contains(&bad_url),
"neither Display nor Debug may echo the raw URL; got display={display}, debug={debug}"
);
assert!(
matches!(err, RedisCacheBuildError::Connection { .. }),
"expected Connection error, got: {err:?}"
);
}
#[test]
fn resolve_connection_string_returns_redacting_wrapper() {
let secret = "resolve_secret_abc";
let raw = format!("redis://:{secret}@127.0.0.1:6379/0");
let builder = RedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&raw);
let cs = builder
.resolve_connection_string()
.expect("connection string was set");
let display = cs.to_string();
let debug = format!("{cs:?}");
assert_eq!(display, "[REDACTED connection string]");
assert_eq!(debug, "[REDACTED connection string]");
assert!(
!display.contains(secret) && !debug.contains(secret),
"wrapper must not expose the password in Display/Debug"
);
assert_eq!(cs.reveal(), raw);
assert!(cs.reveal().contains(secret));
}
#[test]
fn sync_connection_error_debug_does_not_leak_url_or_password() {
let secret = "sync_debug_secret_123";
let bad_url = format!("not-redis://:{secret}@nonexistent-host:9999");
let result = RedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.build();
let err = result.expect_err("build must fail with a bad URL");
assert!(
matches!(err, RedisCacheBuildError::Connection { .. }),
"expected Connection error, got: {err:?}"
);
let debug = format!("{err:?}");
assert!(
!debug.contains(secret),
"enum Debug must not expose the password; got: {debug}"
);
assert!(
!debug.contains(&bad_url),
"enum Debug must not echo the raw URL; got: {debug}"
);
}
#[test]
fn connection_boxed_source_debug_does_not_leak_password() {
let secret = "boxed_source_secret_xyz999";
let bad_url = format!("not-redis://:{secret}@nonexistent-host:9999");
let result = RedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.build();
let err = result.expect_err("build must fail with a bad URL");
if let RedisCacheBuildError::Connection { ref source } = err {
let src_debug = format!("{source:?}");
let src_display = source.to_string();
assert!(
!src_debug.contains(secret),
"boxed source Debug must not expose the password; got: {src_debug}"
);
assert!(
!src_display.contains(secret),
"boxed source Display must not expose the password; got: {src_display}"
);
let mut cause = source.source();
while let Some(c) = cause {
let c_str = format!("{c:?}{c}");
assert!(
!c_str.contains(secret),
"cause chain must not expose the password; got: {c_str}"
);
cause = c.source();
}
} else {
panic!("expected Connection error, got: {err:?}");
}
}
#[test]
fn pool_build_error_does_not_leak_password() {
let secret = "pool_build_secret_abc123";
let bad_url = format!("redis://:{secret}@127.0.0.1:1");
let result = RedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.connection_pool_min_idle(1)
.connection_pool_connection_timeout(Duration::from_millis(50))
.build();
let err = result.expect_err("build must fail against a refused host");
assert!(
matches!(err, RedisCacheBuildError::Pool { .. }),
"expected Pool error, got: {err:?}"
);
let debug = format!("{err:?}");
assert!(
!debug.contains(secret),
"Pool Debug leaked the password: {debug}"
);
if let RedisCacheBuildError::Pool { ref source } = err {
let src = format!("{source:?}{source}");
assert!(
!src.contains(secret),
"boxed Pool source leaked the password: {src}"
);
let mut cause = source.source();
while let Some(c) = cause {
let c_str = format!("{c:?}{c}");
assert!(
!c_str.contains(secret),
"cause chain leaked the password: {c_str}"
);
cause = c.source();
}
}
}
}
#[cfg(test)]
mod error_explicit_source_tests {
use super::{RedisCacheBuildError, RedisCacheError};
use std::error::Error;
#[test]
fn redis_cache_build_error_pool_source_is_accessible() {
let inner = std::io::Error::other("synthetic pool error");
let err = RedisCacheBuildError::Pool {
source: Box::new(inner),
};
let src = err.source().expect("Pool source must be Some");
assert!(
src.downcast_ref::<std::io::Error>().is_some(),
"source should downcast to std::io::Error; got: {src:?}"
);
}
#[test]
fn redis_cache_error_redis_source_is_accessible() {
let inner = std::io::Error::other("synthetic redis error");
let err = RedisCacheError::Redis {
source: Box::new(inner),
};
let src = err.source().expect("Redis source must be Some");
assert!(
src.downcast_ref::<std::io::Error>().is_some(),
"source should downcast to std::io::Error; got: {src:?}"
);
}
#[test]
fn redis_cache_error_pool_source_is_accessible() {
let inner = std::io::Error::other("synthetic redis pool error");
let err = RedisCacheError::Pool {
source: Box::new(inner),
};
let src = err.source().expect("Pool source must be Some");
assert!(
src.downcast_ref::<std::io::Error>().is_some(),
"source should downcast to std::io::Error; got: {src:?}"
);
}
}
#[cfg(test)]
mod legacy_json_version_gate_tests {
use super::{RedisCacheError, deserialize_cached_redis_value};
#[test]
fn json_with_wrong_version_value_is_rejected() {
let bytes = br#"{"value": "hello", "version": 99}"#.to_vec();
match deserialize_cached_redis_value::<String>(&bytes) {
Ok(_) => panic!(
"JSON with an unexpected `version` value must not be accepted as a legacy entry"
),
Err(RedisCacheError::CacheDeserialization { cached_value, .. }) => {
assert_eq!(
cached_value, bytes,
"raw bytes must be preserved in the error"
);
}
Err(other) => panic!("expected CacheDeserialization, got: {other:?}"),
}
}
#[test]
fn json_with_null_version_is_rejected() {
let bytes = br#"{"value": 42, "version": null}"#.to_vec();
match deserialize_cached_redis_value::<u64>(&bytes) {
Ok(_) => panic!("JSON with version=null must not be accepted as a legacy entry"),
Err(RedisCacheError::CacheDeserialization { .. }) => {}
Err(other) => panic!("expected CacheDeserialization, got: {other:?}"),
}
}
#[test]
fn json_with_correct_version_one_is_accepted() {
let bytes = br#"{"value": "ok", "version": 1}"#.to_vec();
let result = deserialize_cached_redis_value::<String>(&bytes);
assert!(
result.is_ok(),
"JSON with version=1 must be accepted as a legacy entry"
);
assert_eq!(result.unwrap().value, "ok");
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ConnectionString(String);
impl ConnectionString {
#[must_use]
pub fn reveal(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for ConnectionString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED connection string]")
}
}
impl std::fmt::Display for ConnectionString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED connection string]")
}
}
use thiserror::Error;
#[non_exhaustive]
#[derive(Error)]
pub enum RedisCacheBuildError {
#[error("redis connection error: {source}")]
Connection {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("redis pool error: {source}")]
Pool {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error(transparent)]
Build(#[from] super::BuildError),
#[error("Connection string not specified or invalid in env var {env_key:?}: {error}")]
MissingConnectionString {
env_key: String,
#[source]
error: std::env::VarError,
},
#[cfg(feature = "redis_async_cache")]
#[cfg_attr(docsrs, doc(cfg(feature = "redis_async_cache")))]
#[error(
"client_side_caching requires RESP3 but the connection URL explicitly pins \
protocol=resp2; remove the protocol parameter or set it to resp3"
)]
Resp2DowngradeWithClientSideCaching,
}
impl std::fmt::Debug for RedisCacheBuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connection { source } => f
.debug_struct("Connection")
.field("source", source)
.finish(),
Self::Pool { source } => f.debug_struct("Pool").field("source", source).finish(),
Self::Build(e) => f.debug_tuple("Build").field(e).finish(),
Self::MissingConnectionString { env_key, error } => f
.debug_struct("MissingConnectionString")
.field("env_key", env_key)
.field("error", error)
.finish(),
#[cfg(feature = "redis_async_cache")]
Self::Resp2DowngradeWithClientSideCaching => {
f.write_str("Resp2DowngradeWithClientSideCaching")
}
}
}
}
fn sanitize_var_error(e: std::env::VarError) -> std::env::VarError {
match e {
std::env::VarError::NotPresent => std::env::VarError::NotPresent,
std::env::VarError::NotUnicode(_) => {
std::env::VarError::NotUnicode("[REDACTED connection string]".into())
}
}
}
impl RedisCacheBuildError {
pub(crate) fn connection(e: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Connection {
source: Box::new(e),
}
}
}
impl<K, V> Default for RedisCacheBuilder<K, V>
where
K: Display,
V: Serialize + DeserializeOwned,
{
fn default() -> Self {
Self::new()
}
}
impl<K, V> RedisCacheBuilder<K, V>
where
K: Display,
V: Serialize + DeserializeOwned,
{
#[must_use]
pub fn new() -> RedisCacheBuilder<K, V> {
Self {
ttl: None,
refresh: false,
namespace: DEFAULT_NAMESPACE.to_string(),
prefix: None,
connection_string: None,
pool_max_size: None,
pool_min_idle: None,
pool_max_lifetime: None,
pool_idle_timeout: None,
pool_connection_timeout: None,
strict_deserialization: false,
_phantom: PhantomData,
}
}
#[must_use]
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
#[must_use]
pub fn ttl_secs(self, secs: u64) -> Self {
self.ttl(Duration::from_secs(secs))
}
#[must_use]
pub fn ttl_millis(self, millis: u64) -> Self {
self.ttl(Duration::from_millis(millis))
}
#[must_use]
pub fn refresh_on_hit(mut self, refresh: bool) -> Self {
self.refresh = refresh;
self
}
#[must_use]
pub fn namespace<S: AsRef<str>>(mut self, namespace: S) -> Self {
self.namespace = namespace.as_ref().to_string();
self
}
#[must_use]
pub fn prefix<S: AsRef<str>>(mut self, prefix: S) -> Self {
self.prefix = Some(prefix.as_ref().to_string());
self
}
#[must_use]
pub fn connection_string(mut self, cs: &str) -> Self {
self.connection_string = Some(cs.to_string());
self
}
#[must_use]
pub fn connection_pool_max_size(mut self, max_size: u32) -> Self {
self.pool_max_size = Some(max_size);
self
}
#[must_use]
pub fn connection_pool_min_idle(mut self, min_idle: u32) -> Self {
self.pool_min_idle = Some(min_idle);
self
}
#[must_use]
pub fn connection_pool_max_lifetime(mut self, max_lifetime: Duration) -> Self {
self.pool_max_lifetime = Some(max_lifetime);
self
}
#[must_use]
pub fn connection_pool_idle_timeout(mut self, idle_timeout: Duration) -> Self {
self.pool_idle_timeout = Some(idle_timeout);
self
}
#[must_use]
pub fn connection_pool_connection_timeout(mut self, connection_timeout: Duration) -> Self {
self.pool_connection_timeout = Some(connection_timeout);
self
}
#[must_use]
pub fn strict_deserialization(mut self, strict: bool) -> Self {
self.strict_deserialization = strict;
self
}
pub fn resolve_connection_string(&self) -> Result<ConnectionString, RedisCacheBuildError> {
match self.connection_string {
Some(ref s) => Ok(ConnectionString(s.to_string())),
None => std::env::var(ENV_KEY).map(ConnectionString).map_err(|e| {
RedisCacheBuildError::MissingConnectionString {
env_key: ENV_KEY.to_string(),
error: sanitize_var_error(e),
}
}),
}
}
fn create_pool(&self) -> Result<r2d2::Pool<redis::Client>, RedisCacheBuildError> {
let s = self.resolve_connection_string()?;
let client: redis::Client = redis::Client::open(s.reveal()).map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to open redis client (connection string redacted)",
)))
})?;
let pool_builder = r2d2::Pool::builder();
let pool_builder = if let Some(max_size) = self.pool_max_size {
pool_builder.max_size(max_size)
} else {
pool_builder
};
let pool_builder = if let Some(min_idle) = self.pool_min_idle {
pool_builder.min_idle(Some(min_idle))
} else {
pool_builder
};
let pool_builder = if let Some(max_lifetime) = self.pool_max_lifetime {
pool_builder.max_lifetime(Some(max_lifetime))
} else {
pool_builder
};
let pool_builder = if let Some(idle_timeout) = self.pool_idle_timeout {
pool_builder.idle_timeout(Some(idle_timeout))
} else {
pool_builder
};
let pool_builder = if let Some(connection_timeout) = self.pool_connection_timeout {
pool_builder.connection_timeout(connection_timeout)
} else {
pool_builder
};
let pool = pool_builder.build(client).map_err(|_| RedisCacheBuildError::Pool {
source: Box::new(redis::RedisError::from((
redis::ErrorKind::Io,
"failed to establish initial redis pool connection (connection string redacted)",
))),
})?;
Ok(pool)
}
pub fn build(self) -> Result<RedisCache<K, V>, RedisCacheBuildError> {
if self.prefix.is_none() {
return Err(super::BuildError::MissingRequired("prefix").into());
}
let ttl = match self.ttl {
Some(ttl) => {
super::validate_ttl(ttl)?;
ttl
}
None => Duration::ZERO,
};
if self.prefix.as_deref().is_some_and(str::is_empty) {
return Err(super::BuildError::InvalidValue {
field: "prefix",
reason: "prefix must be non-empty: it is what scopes cache_clear to this \
cache; with an empty prefix cache_clear would delete every key \
under the namespace",
}
.into());
}
let connection_string = self.resolve_connection_string()?;
let pool = self.create_pool()?;
Ok(RedisCache {
ttl: Mutex::new(ttl),
refresh: AtomicBool::new(self.refresh),
connection_string,
pool,
namespace: self.namespace,
prefix: self.prefix.unwrap_or_default(),
strict_deserialization: self.strict_deserialization,
_phantom: PhantomData,
})
}
}
pub struct RedisCache<K, V> {
pub(super) ttl: Mutex<Duration>,
pub(super) refresh: AtomicBool,
pub(super) namespace: String,
pub(super) prefix: String,
connection_string: ConnectionString,
pool: r2d2::Pool<redis::Client>,
strict_deserialization: bool,
_phantom: PhantomData<fn() -> (K, V)>,
}
impl<K, V> std::fmt::Debug for RedisCache<K, V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedisCache")
.field("namespace", &self.namespace)
.field("prefix", &self.prefix)
.field("ttl", &*self.ttl.lock())
.field("refresh", &self.refresh.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl<K, V> Clone for RedisCache<K, V> {
fn clone(&self) -> Self {
Self {
ttl: Mutex::new(*self.ttl.lock()),
refresh: AtomicBool::new(self.refresh.load(Ordering::Relaxed)),
namespace: self.namespace.clone(),
prefix: self.prefix.clone(),
connection_string: self.connection_string.clone(),
pool: self.pool.clone(),
strict_deserialization: self.strict_deserialization,
_phantom: PhantomData,
}
}
}
impl<K, V> RedisCache<K, V>
where
K: Display,
V: Serialize + DeserializeOwned,
{
#[must_use]
pub fn builder(prefix: impl Into<String>) -> RedisCacheBuilder<K, V> {
RedisCacheBuilder::new().prefix(prefix.into())
}
fn generate_key(&self, key: &K) -> String {
generate_redis_key(&self.namespace, &self.prefix, &key.to_string())
}
fn clear_match_pattern(&self) -> String {
clear_match_pattern(&self.namespace, &self.prefix)
}
#[must_use]
pub fn connection_string(&self) -> ConnectionString {
self.connection_string.clone()
}
}
#[non_exhaustive]
#[derive(Error)]
pub enum RedisCacheError {
#[error("redis error: {source}")]
Redis {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("redis pool error: {source}")]
Pool {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("error deserializing cached value: {source}")]
CacheDeserialization {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
cached_value: Vec<u8>,
},
#[error("error serializing cached value: {source}")]
CacheSerialization {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
impl std::fmt::Debug for RedisCacheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Redis { source } => f.debug_struct("Redis").field("source", source).finish(),
Self::Pool { source } => f.debug_struct("Pool").field("source", source).finish(),
Self::CacheDeserialization {
source,
cached_value,
} => f
.debug_struct("CacheDeserialization")
.field("source", source)
.field(
"cached_value",
&format_args!("<{} bytes redacted>", cached_value.len()),
)
.finish(),
Self::CacheSerialization { source } => f
.debug_struct("CacheSerialization")
.field("source", source)
.finish(),
}
}
}
impl RedisCacheError {
pub(crate) fn redis(e: redis::RedisError) -> Self {
Self::Redis {
source: Box::new(e),
}
}
pub(crate) fn pool_err(e: r2d2::Error) -> Self {
Self::Pool {
source: Box::new(e),
}
}
pub(crate) fn serialization(e: rmp_serde::encode::Error) -> Self {
Self::CacheSerialization {
source: Box::new(e),
}
}
pub(crate) fn deserialization(e: rmp_serde::decode::Error, cached_value: Vec<u8>) -> Self {
Self::CacheDeserialization {
source: Box::new(e),
cached_value,
}
}
#[must_use]
pub fn is_deserialization(&self) -> bool {
matches!(self, Self::CacheDeserialization { .. })
}
}
const REDIS_VALUE_VERSION: Option<u64> = Some(1);
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedRedisValue<V> {
value: V,
version: Option<u64>,
}
impl<V> CachedRedisValue<V> {
fn new(value: V) -> Self {
Self {
value,
version: REDIS_VALUE_VERSION,
}
}
}
#[derive(serde::Serialize)]
struct CachedRedisValueRef<'a, V> {
value: &'a V,
version: Option<u64>,
}
impl<'a, V> CachedRedisValueRef<'a, V> {
fn new(value: &'a V) -> Self {
Self {
value,
version: REDIS_VALUE_VERSION,
}
}
}
fn deserialize_cached_redis_value<V: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<CachedRedisValue<V>, RedisCacheError> {
match rmp_serde::from_slice::<CachedRedisValue<V>>(bytes) {
Ok(v) => Ok(v),
Err(msgpack_err) => {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes)
&& json.get("version") == Some(&serde_json::json!(REDIS_VALUE_VERSION))
&& let Ok(v) = serde_json::from_value::<CachedRedisValue<V>>(json)
{
return Ok(v);
}
Err(RedisCacheError::deserialization(
msgpack_err,
bytes.to_vec(),
))
}
}
}
impl<K, V> ConcurrentCacheBase for RedisCache<K, V> {
type Error = RedisCacheError;
}
impl<K, V> ConcurrentCacheTtl for RedisCache<K, V> {
fn ttl(&self) -> Option<Duration> {
let ttl = *self.ttl.lock();
if ttl.is_zero() { None } else { Some(ttl) }
}
fn set_ttl(&self, ttl: Duration) -> Option<Duration> {
let mut guard = self.ttl.lock();
let old = *guard;
*guard = ttl;
if old.is_zero() { None } else { Some(old) }
}
fn unset_ttl(&self) -> Option<Duration> {
let mut guard = self.ttl.lock();
let old = *guard;
*guard = Duration::ZERO;
if old.is_zero() { None } else { Some(old) }
}
}
impl<K, V> ConcurrentCacheRefreshOnHit for RedisCache<K, V> {
fn refresh_on_hit(&self) -> bool {
self.refresh.load(Ordering::Relaxed)
}
fn set_refresh_on_hit(&self, refresh: bool) -> bool {
self.refresh.swap(refresh, Ordering::Relaxed)
}
}
impl<K, V> ConcurrentCached<K, V> for RedisCache<K, V>
where
K: Display + Clone,
V: Serialize + DeserializeOwned,
{
fn cache_get(&self, key: &K) -> Result<Option<V>, RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let mut pipe = redis::pipe();
let key_str = self.generate_key(key);
pipe.get(&key_str);
if self.refresh.load(Ordering::Relaxed) {
let ttl = *self.ttl.lock();
if !ttl.is_zero() {
pipe.pexpire(&key_str, ttl_millis_i64(ttl)?).ignore();
}
}
let res: (Option<Vec<u8>>,) = pipe.query(&mut *conn).map_err(RedisCacheError::redis)?;
match res.0 {
None => Ok(None),
Some(bytes) => match deserialize_cached_redis_value(&bytes) {
Ok(v) => Ok(Some(v.value)),
Err(e) if !self.strict_deserialization => {
let _: i64 = SELF_HEAL_CONDITIONAL_DEL
.key(&key_str)
.arg(&bytes)
.invoke(&mut *conn)
.map_err(RedisCacheError::redis)?;
let _ = e;
Ok(None)
}
Err(e) => Err(e),
},
}
}
fn cache_set(&self, key: K, val: V) -> Result<Option<V>, RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let mut pipe = redis::pipe();
let key_str = self.generate_key(&key);
let ttl = *self.ttl.lock();
let val = CachedRedisValue::new(val);
let serialized = rmp_serde::to_vec(&val).map_err(RedisCacheError::serialization)?;
pipe.get(&key_str);
if ttl.is_zero() {
pipe.set::<String, Vec<u8>>(key_str, serialized).ignore();
} else {
pipe.pset_ex::<String, Vec<u8>>(key_str, serialized, ttl_millis(ttl)?)
.ignore();
}
let res: (Option<Vec<u8>>,) = pipe.query(&mut *conn).map_err(RedisCacheError::redis)?;
Ok(res.0.and_then(|bytes| {
deserialize_cached_redis_value::<V>(&bytes)
.ok()
.map(|v| v.value)
}))
}
fn cache_remove(&self, key: &K) -> Result<Option<V>, RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let mut pipe = redis::pipe();
let key_str = self.generate_key(key);
pipe.get(&key_str);
pipe.del::<String>(key_str).ignore();
let res: (Option<Vec<u8>>,) = pipe.query(&mut *conn).map_err(RedisCacheError::redis)?;
match res.0 {
None => Ok(None),
Some(bytes) => match deserialize_cached_redis_value(&bytes) {
Ok(v) => Ok(Some(v.value)),
Err(_) if !self.strict_deserialization => Ok(None),
Err(e) => Err(e),
},
}
}
fn cache_remove_entry(&self, key: &K) -> Result<Option<(K, V)>, Self::Error> {
self.cache_remove(key)
.map(|opt| opt.map(|v| (key.clone(), v)))
}
fn cache_delete(&self, key: &K) -> Result<bool, RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let key_str = self.generate_key(key);
let removed: usize = redis::cmd("DEL")
.arg(key_str)
.query(&mut *conn)
.map_err(RedisCacheError::redis)?;
Ok(removed > 0)
}
fn cache_clear(&self) -> Result<(), RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let pattern = self.clear_match_pattern();
let mut cursor: u64 = 0;
loop {
let (next, keys): (u64, Vec<Vec<u8>>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query(&mut *conn)
.map_err(RedisCacheError::redis)?;
if !keys.is_empty() {
redis::cmd("DEL")
.arg(keys)
.query::<()>(&mut *conn)
.map_err(RedisCacheError::redis)?;
}
if next == 0 {
break;
}
cursor = next;
}
Ok(())
}
fn cache_reset(&self) -> Result<(), RedisCacheError> {
self.cache_clear()
}
fn cache_contains(&self, k: &K) -> Result<bool, Self::Error> {
self.cache_get(k).map(|v| v.is_some())
}
}
impl<K, V> crate::SerializeCached<K, V> for RedisCache<K, V>
where
K: Display + Clone,
V: Serialize + DeserializeOwned,
{
fn cache_set_ref(&self, key: &K, val: &V) -> Result<(), RedisCacheError> {
let mut conn = self.pool.get().map_err(RedisCacheError::pool_err)?;
let key_str = self.generate_key(key);
let ttl = *self.ttl.lock();
let val = CachedRedisValueRef::new(val);
let serialized = rmp_serde::to_vec(&val).map_err(RedisCacheError::serialization)?;
if ttl.is_zero() {
let _: () = redis::cmd("SET")
.arg(&key_str)
.arg(serialized)
.query(&mut *conn)
.map_err(RedisCacheError::redis)?;
} else {
let _: () = redis::cmd("PSETEX")
.arg(&key_str)
.arg(ttl_millis(ttl)?)
.arg(serialized)
.query(&mut *conn)
.map_err(RedisCacheError::redis)?;
}
Ok(())
}
}
#[cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
))]
mod async_redis {
use crate::time::Duration;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use super::{
CachedRedisValue, CachedRedisValueRef, ConnectionString, DEFAULT_NAMESPACE,
DeserializeOwned, Display, ENV_KEY, PhantomData, RedisCacheBuildError, RedisCacheError,
Serialize,
};
use crate::{
ConcurrentCacheBase, ConcurrentCacheRefreshOnHit, ConcurrentCacheTtl, ConcurrentCachedAsync,
};
#[cfg(feature = "redis_async_cache")]
use redis::IntoConnectionInfo;
#[derive(Clone)]
pub(crate) enum AsyncRedisConnection {
Multiplexed(redis::aio::MultiplexedConnection),
#[cfg(feature = "redis_connection_manager")]
Manager(redis::aio::ConnectionManager),
}
impl redis::aio::ConnectionLike for AsyncRedisConnection {
fn req_packed_command<'a>(
&'a mut self,
cmd: &'a redis::Cmd,
) -> redis::RedisFuture<'a, redis::Value> {
match self {
AsyncRedisConnection::Multiplexed(c) => c.req_packed_command(cmd),
#[cfg(feature = "redis_connection_manager")]
AsyncRedisConnection::Manager(c) => c.req_packed_command(cmd),
}
}
fn req_packed_commands<'a>(
&'a mut self,
cmd: &'a redis::Pipeline,
offset: usize,
count: usize,
) -> redis::RedisFuture<'a, Vec<redis::Value>> {
match self {
AsyncRedisConnection::Multiplexed(c) => c.req_packed_commands(cmd, offset, count),
#[cfg(feature = "redis_connection_manager")]
AsyncRedisConnection::Manager(c) => c.req_packed_commands(cmd, offset, count),
}
}
fn get_db(&self) -> i64 {
match self {
AsyncRedisConnection::Multiplexed(c) => c.get_db(),
#[cfg(feature = "redis_connection_manager")]
AsyncRedisConnection::Manager(c) => c.get_db(),
}
}
}
#[cfg_attr(
docsrs,
doc(cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
)))
)]
pub struct AsyncRedisCacheBuilder<K, V> {
ttl: Option<Duration>,
refresh: bool,
namespace: String,
prefix: Option<String>,
connection_string: Option<String>,
strict_deserialization: bool,
#[cfg(feature = "redis_async_cache")]
client_side_caching: bool,
#[cfg(feature = "redis_connection_manager")]
connection_manager: bool,
_phantom: PhantomData<fn() -> (K, V)>,
}
impl<K, V> Default for AsyncRedisCacheBuilder<K, V>
where
K: Display,
V: Serialize + DeserializeOwned,
{
fn default() -> Self {
Self::new()
}
}
impl<K, V> AsyncRedisCacheBuilder<K, V>
where
K: Display,
V: Serialize + DeserializeOwned,
{
#[must_use]
pub fn new() -> AsyncRedisCacheBuilder<K, V> {
Self {
ttl: None,
refresh: false,
namespace: DEFAULT_NAMESPACE.to_string(),
prefix: None,
connection_string: None,
strict_deserialization: false,
#[cfg(feature = "redis_async_cache")]
client_side_caching: false,
#[cfg(feature = "redis_connection_manager")]
connection_manager: false,
_phantom: PhantomData,
}
}
#[must_use]
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
#[must_use]
pub fn ttl_secs(self, secs: u64) -> Self {
self.ttl(Duration::from_secs(secs))
}
#[must_use]
pub fn ttl_millis(self, millis: u64) -> Self {
self.ttl(Duration::from_millis(millis))
}
#[must_use]
pub fn refresh_on_hit(mut self, refresh: bool) -> Self {
self.refresh = refresh;
self
}
#[must_use]
pub fn namespace<S: AsRef<str>>(mut self, namespace: S) -> Self {
self.namespace = namespace.as_ref().to_string();
self
}
#[must_use]
pub fn prefix<S: AsRef<str>>(mut self, prefix: S) -> Self {
self.prefix = Some(prefix.as_ref().to_string());
self
}
#[must_use]
pub fn connection_string(mut self, cs: &str) -> Self {
self.connection_string = Some(cs.to_string());
self
}
#[cfg(feature = "redis_async_cache")]
#[cfg_attr(docsrs, doc(cfg(feature = "redis_async_cache")))]
#[must_use]
pub fn client_side_caching(mut self, enable: bool) -> Self {
self.client_side_caching = enable;
self
}
#[cfg(feature = "redis_connection_manager")]
#[cfg_attr(docsrs, doc(cfg(feature = "redis_connection_manager")))]
#[must_use]
pub fn connection_manager(mut self, yes: bool) -> Self {
self.connection_manager = yes;
self
}
#[must_use]
pub fn strict_deserialization(mut self, strict: bool) -> Self {
self.strict_deserialization = strict;
self
}
pub fn resolve_connection_string(&self) -> Result<ConnectionString, RedisCacheBuildError> {
match self.connection_string {
Some(ref s) => Ok(ConnectionString(s.to_string())),
None => std::env::var(ENV_KEY).map(ConnectionString).map_err(|e| {
RedisCacheBuildError::MissingConnectionString {
env_key: ENV_KEY.to_string(),
error: super::sanitize_var_error(e),
}
}),
}
}
#[cfg(feature = "redis_async_cache")]
fn url_pins_resp2(s: &str) -> bool {
if let Some(query) = s.split('?').nth(1) {
for pair in query.split('&') {
if let Some(val) = pair.strip_prefix("protocol=") {
return val == "resp2" || val == "2";
}
}
}
false
}
async fn create_connection(&self) -> Result<AsyncRedisConnection, RedisCacheBuildError> {
#[cfg(feature = "redis_connection_manager")]
if self.connection_manager {
return Ok(AsyncRedisConnection::Manager(
self.create_connection_manager().await?,
));
}
Ok(AsyncRedisConnection::Multiplexed(
self.create_multiplexed_connection().await?,
))
}
async fn create_multiplexed_connection(
&self,
) -> Result<redis::aio::MultiplexedConnection, RedisCacheBuildError> {
let s = self.resolve_connection_string()?;
#[cfg(feature = "redis_async_cache")]
if self.client_side_caching {
if Self::url_pins_resp2(s.reveal()) {
return Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching);
}
let mut connection_info = s.reveal().into_connection_info().map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to parse redis connection info (connection string redacted)",
)))
})?;
let mut config = redis::AsyncConnectionConfig::default();
let redis_settings = connection_info
.redis_settings()
.clone()
.set_protocol(redis::ProtocolVersion::RESP3);
connection_info = connection_info.set_redis_settings(redis_settings);
config = config.set_cache_config(redis::caching::CacheConfig::default());
let client = redis::Client::open(connection_info).map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to open redis client (connection string redacted)",
)))
})?;
let conn = client
.get_multiplexed_async_connection_with_config(&config)
.await
.map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::Io,
"failed to establish redis connection (connection string redacted)",
)))
})?;
return Ok(conn);
}
let client = redis::Client::open(s.reveal()).map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to open redis client (connection string redacted)",
)))
})?;
let conn = client
.get_multiplexed_async_connection()
.await
.map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::Io,
"failed to establish redis connection (connection string redacted)",
)))
})?;
Ok(conn)
}
#[cfg(feature = "redis_connection_manager")]
async fn create_connection_manager(
&self,
) -> Result<redis::aio::ConnectionManager, RedisCacheBuildError> {
let s = self.resolve_connection_string()?;
#[cfg(feature = "redis_async_cache")]
if self.client_side_caching {
if Self::url_pins_resp2(s.reveal()) {
return Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching);
}
let mut connection_info = s.reveal().into_connection_info().map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to parse redis connection info (connection string redacted)",
)))
})?;
let redis_settings = connection_info
.redis_settings()
.clone()
.set_protocol(redis::ProtocolVersion::RESP3);
connection_info = connection_info.set_redis_settings(redis_settings);
let config = redis::aio::ConnectionManagerConfig::default()
.set_cache_config(redis::caching::CacheConfig::default());
let client = redis::Client::open(connection_info).map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to open redis client (connection string redacted)",
)))
})?;
let conn = redis::aio::ConnectionManager::new_with_config(client, config)
.await
.map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::Io,
"failed to establish redis connection (connection string redacted)",
)))
})?;
return Ok(conn);
}
let client = redis::Client::open(s.reveal()).map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"failed to open redis client (connection string redacted)",
)))
})?;
let conn = redis::aio::ConnectionManager::new(client)
.await
.map_err(|_| {
RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::Io,
"failed to establish redis connection (connection string redacted)",
)))
})?;
Ok(conn)
}
pub async fn build(self) -> Result<AsyncRedisCache<K, V>, RedisCacheBuildError> {
if self.prefix.is_none() {
return Err(super::super::BuildError::MissingRequired("prefix").into());
}
let ttl = match self.ttl {
Some(ttl) => {
super::super::validate_ttl(ttl)?;
ttl
}
None => Duration::ZERO,
};
if self.prefix.as_deref().is_some_and(str::is_empty) {
return Err(super::super::BuildError::InvalidValue {
field: "prefix",
reason: "prefix must be non-empty: it is what scopes cache_clear to this \
cache; with an empty prefix cache_clear would delete every key \
under the namespace",
}
.into());
}
let connection_string = self.resolve_connection_string()?;
let connection = self.create_connection().await?;
Ok(AsyncRedisCache {
ttl: Mutex::new(ttl),
refresh: AtomicBool::new(self.refresh),
connection_string,
connection,
namespace: self.namespace,
prefix: self.prefix.unwrap_or_default(),
strict_deserialization: self.strict_deserialization,
_phantom: PhantomData,
})
}
}
#[cfg_attr(
docsrs,
doc(cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
)))
)]
pub struct AsyncRedisCache<K, V> {
pub(super) ttl: Mutex<Duration>,
pub(super) refresh: AtomicBool,
pub(super) namespace: String,
pub(super) prefix: String,
connection_string: ConnectionString,
connection: AsyncRedisConnection,
strict_deserialization: bool,
_phantom: PhantomData<fn() -> (K, V)>,
}
impl<K, V> std::fmt::Debug for AsyncRedisCache<K, V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AsyncRedisCache")
.field("namespace", &self.namespace)
.field("prefix", &self.prefix)
.field("ttl", &*self.ttl.lock())
.field("refresh", &self.refresh.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl<K, V> Clone for AsyncRedisCache<K, V> {
fn clone(&self) -> Self {
Self {
ttl: Mutex::new(*self.ttl.lock()),
refresh: AtomicBool::new(self.refresh.load(Ordering::Relaxed)),
namespace: self.namespace.clone(),
prefix: self.prefix.clone(),
connection_string: self.connection_string.clone(),
connection: self.connection.clone(),
strict_deserialization: self.strict_deserialization,
_phantom: PhantomData,
}
}
}
impl<K, V> AsyncRedisCache<K, V>
where
K: Display + Send + Sync,
V: Serialize + DeserializeOwned + Send,
{
#[must_use]
pub fn builder(prefix: impl Into<String>) -> AsyncRedisCacheBuilder<K, V> {
AsyncRedisCacheBuilder::new().prefix(prefix.into())
}
fn generate_key(&self, key: &K) -> String {
super::generate_redis_key(&self.namespace, &self.prefix, &key.to_string())
}
fn clear_match_pattern(&self) -> String {
super::clear_match_pattern(&self.namespace, &self.prefix)
}
#[must_use]
pub fn connection_string(&self) -> ConnectionString {
self.connection_string.clone()
}
}
impl<K, V> ConcurrentCacheBase for AsyncRedisCache<K, V> {
type Error = RedisCacheError;
}
impl<K, V> ConcurrentCacheTtl for AsyncRedisCache<K, V> {
fn ttl(&self) -> Option<Duration> {
let ttl = *self.ttl.lock();
if ttl.is_zero() { None } else { Some(ttl) }
}
fn set_ttl(&self, ttl: Duration) -> Option<Duration> {
let mut guard = self.ttl.lock();
let old = *guard;
*guard = ttl;
if old.is_zero() { None } else { Some(old) }
}
fn unset_ttl(&self) -> Option<Duration> {
let mut guard = self.ttl.lock();
let old = *guard;
*guard = Duration::ZERO;
if old.is_zero() { None } else { Some(old) }
}
}
impl<K, V> ConcurrentCacheRefreshOnHit for AsyncRedisCache<K, V> {
fn refresh_on_hit(&self) -> bool {
self.refresh.load(Ordering::Relaxed)
}
fn set_refresh_on_hit(&self, refresh: bool) -> bool {
self.refresh.swap(refresh, Ordering::Relaxed)
}
}
impl<K, V> ConcurrentCachedAsync<K, V> for AsyncRedisCache<K, V>
where
K: Display + Clone + Send + Sync,
V: Serialize + DeserializeOwned + Send,
{
async fn async_cache_get(&self, key: &K) -> Result<Option<V>, Self::Error> {
let mut conn = self.connection.clone();
let mut pipe = redis::pipe();
let key_str = self.generate_key(key);
pipe.get(&key_str);
if self.refresh.load(Ordering::Relaxed) {
let ttl = *self.ttl.lock();
if !ttl.is_zero() {
pipe.pexpire(&key_str, super::ttl_millis_i64(ttl)?).ignore();
}
}
let res: (Option<Vec<u8>>,) = pipe
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
match res.0 {
None => Ok(None),
Some(bytes) => match super::deserialize_cached_redis_value(&bytes) {
Ok(v) => Ok(Some(v.value)),
Err(e) if !self.strict_deserialization => {
let _: i64 = super::SELF_HEAL_CONDITIONAL_DEL
.key(&key_str)
.arg(&bytes)
.invoke_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
let _ = e;
Ok(None)
}
Err(e) => Err(e),
},
}
}
async fn async_cache_set(&self, key: K, val: V) -> Result<Option<V>, Self::Error> {
let mut conn = self.connection.clone();
let mut pipe = redis::pipe();
let key_str = self.generate_key(&key);
let ttl = *self.ttl.lock();
let val = CachedRedisValue::new(val);
let serialized = rmp_serde::to_vec(&val).map_err(RedisCacheError::serialization)?;
pipe.get(&key_str);
if ttl.is_zero() {
pipe.set::<String, Vec<u8>>(key_str, serialized).ignore();
} else {
pipe.pset_ex::<String, Vec<u8>>(key_str, serialized, super::ttl_millis(ttl)?)
.ignore();
}
let res: (Option<Vec<u8>>,) = pipe
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
Ok(res.0.and_then(|bytes| {
super::deserialize_cached_redis_value::<V>(&bytes)
.ok()
.map(|v| v.value)
}))
}
async fn async_cache_remove(&self, key: &K) -> Result<Option<V>, Self::Error> {
let mut conn = self.connection.clone();
let mut pipe = redis::pipe();
let key_str = self.generate_key(key);
pipe.get(&key_str);
pipe.del::<String>(key_str).ignore();
let res: (Option<Vec<u8>>,) = pipe
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
match res.0 {
None => Ok(None),
Some(bytes) => match super::deserialize_cached_redis_value(&bytes) {
Ok(v) => Ok(Some(v.value)),
Err(_) if !self.strict_deserialization => Ok(None),
Err(e) => Err(e),
},
}
}
async fn async_cache_remove_entry(&self, key: &K) -> Result<Option<(K, V)>, Self::Error> {
self.async_cache_remove(key)
.await
.map(|opt| opt.map(|v| (key.clone(), v)))
}
async fn async_cache_delete(&self, key: &K) -> Result<bool, Self::Error> {
let mut conn = self.connection.clone();
let key_str = self.generate_key(key);
let removed: usize = redis::cmd("DEL")
.arg(key_str)
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
Ok(removed > 0)
}
async fn async_cache_clear(&self) -> Result<(), Self::Error> {
let mut conn = self.connection.clone();
let pattern = self.clear_match_pattern();
let mut cursor: u64 = 0;
loop {
let (next, keys): (u64, Vec<Vec<u8>>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
if !keys.is_empty() {
redis::cmd("DEL")
.arg(keys)
.query_async::<()>(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
}
if next == 0 {
break;
}
cursor = next;
}
Ok(())
}
async fn async_cache_reset(&self) -> Result<(), Self::Error> {
self.async_cache_clear().await
}
async fn async_cache_contains(&self, k: &K) -> Result<bool, Self::Error>
where
Self: Sized + Sync,
K: Sync,
{
self.async_cache_get(k).await.map(|v| v.is_some())
}
}
impl<K, V> crate::SerializeCachedAsync<K, V> for AsyncRedisCache<K, V>
where
K: Display + Clone + Send + Sync,
V: Serialize + DeserializeOwned + Send,
{
fn async_cache_set_ref(
&self,
key: &K,
val: &V,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
let mut conn = self.connection.clone();
let key = self.generate_key(key);
let ttl = *self.ttl.lock();
let ttl_ms = if ttl.is_zero() {
Ok(None)
} else {
super::ttl_millis(ttl).map(Some)
};
let serialized = rmp_serde::to_vec(&CachedRedisValueRef::new(val))
.map_err(RedisCacheError::serialization);
async move {
let serialized: Vec<u8> = serialized?;
let ttl_ms = ttl_ms?;
match ttl_ms {
None => {
let _: () = redis::cmd("SET")
.arg(&key)
.arg(serialized)
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
}
Some(ttl_ms) => {
let _: () = redis::cmd("PSETEX")
.arg(&key)
.arg(ttl_ms)
.arg(serialized)
.query_async(&mut conn)
.await
.map_err(RedisCacheError::redis)?;
}
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::Duration;
use std::thread::sleep;
fn now_millis() -> u128 {
crate::time::SystemTime::now()
.duration_since(crate::time::UNIX_EPOCH)
.unwrap()
.as_millis()
}
fn raw_key(prefix: &str, key: &str) -> String {
super::super::generate_redis_key(super::super::DEFAULT_NAMESPACE, prefix, key)
}
#[tokio::test]
async fn async_empty_prefix_is_rejected() {
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("")
.ttl(Duration::from_secs(1))
.build()
.await;
assert!(
matches!(
result,
Err(RedisCacheBuildError::Build(
crate::stores::BuildError::InvalidValue {
field: "prefix",
..
}
))
),
"expected InvalidValue for empty prefix"
);
}
#[tokio::test]
async fn async_bad_url_with_password_does_not_leak_password() {
let secret = "async_super_secret_xyz";
let bad_url = format!("not-redis://:{secret}@nonexistent-host:9999");
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.build()
.await;
let err = result.expect_err("build must fail with a bad async URL");
let display = err.to_string();
let debug = format!("{err:?}");
assert!(
!display.contains(secret),
"Display must not expose the password; got: {display}"
);
assert!(
!debug.contains(secret),
"Debug must not expose the password; got: {debug}"
);
assert!(
!display.contains(&bad_url) && !debug.contains(&bad_url),
"neither Display nor Debug may echo the raw URL; got display={display}, debug={debug}"
);
assert!(
matches!(err, RedisCacheBuildError::Connection { .. }),
"expected Connection error, got: {err:?}"
);
}
#[cfg(feature = "redis_async_cache")]
#[tokio::test]
async fn client_side_caching_rejects_resp2_url() {
let url_with_resp2 = "redis://127.0.0.1:6399?protocol=resp2";
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(url_with_resp2)
.client_side_caching(true)
.build()
.await;
assert!(
matches!(
result,
Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching)
),
"expected Resp2DowngradeWithClientSideCaching, got: {result:?}"
);
}
#[cfg(feature = "redis_async_cache")]
#[tokio::test]
async fn client_side_caching_accepts_resp3_url() {
let url_with_resp3 = "redis://127.0.0.1:6399?protocol=resp3";
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(url_with_resp3)
.client_side_caching(true)
.build()
.await;
assert!(
!matches!(
result,
Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching)
),
"resp3 URL must not trigger the RESP2 guard; got: {result:?}"
);
}
#[cfg(feature = "redis_async_cache")]
#[tokio::test]
async fn client_side_caching_accepts_url_without_protocol_param() {
let url_plain = "redis://127.0.0.1:6399";
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(url_plain)
.client_side_caching(true)
.build()
.await;
assert!(
!matches!(
result,
Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching)
),
"plain URL must not trigger the RESP2 guard; got: {result:?}"
);
}
#[tokio::test]
async fn test_async_redis_cache() {
let c: AsyncRedisCache<u32, u32> =
AsyncRedisCache::builder(format!("{}:async-redis-cache-test", now_millis()))
.ttl(Duration::from_secs(2))
.build()
.await
.unwrap();
assert!(c.async_cache_get(&1).await.unwrap().is_none());
assert!(c.async_cache_set(1, 100).await.unwrap().is_none());
assert!(c.async_cache_get(&1).await.unwrap().is_some());
sleep(Duration::from_millis(2_500));
assert!(c.async_cache_get(&1).await.unwrap().is_none());
let old = ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(1)).unwrap();
assert_eq!(2, old.as_secs());
assert!(c.async_cache_set(1, 100).await.unwrap().is_none());
assert!(c.async_cache_get(&1).await.unwrap().is_some());
sleep(Duration::from_millis(1_600));
assert!(c.async_cache_get(&1).await.unwrap().is_none());
ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(10)).unwrap();
assert!(c.async_cache_set(1, 100).await.unwrap().is_none());
assert!(c.async_cache_set(2, 100).await.unwrap().is_none());
assert_eq!(c.async_cache_get(&1).await.unwrap().unwrap(), 100);
assert_eq!(c.async_cache_get(&1).await.unwrap().unwrap(), 100);
}
fn plant_raw(key: &str, bytes: &[u8]) {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let _: () = redis::cmd("SET")
.arg(key)
.arg(bytes)
.query(&mut conn)
.unwrap();
}
fn key_exists(key: &str) -> bool {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
redis::cmd("EXISTS").arg(key).query(&mut conn).unwrap()
}
fn delete_key(key: &str) {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let _: () = redis::cmd("DEL").arg(key).query(&mut conn).unwrap();
}
#[tokio::test]
async fn async_cache_get_self_heals_and_recomputes_to_hit() {
let prefix = format!("{}:async-selfheal", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
assert_eq!(
c.async_cache_get(&1).await.unwrap(),
None,
"async self-heal returns a miss"
);
assert!(
!key_exists(&key),
"async self-heal must delete the corrupt key"
);
assert_eq!(c.async_cache_set(1, 88).await.unwrap(), None);
assert_eq!(
c.async_cache_get(&1).await.unwrap(),
Some(88),
"the read after recompute is a HIT"
);
delete_key(&key);
}
#[tokio::test]
async fn async_cache_get_strict_mode_errors_and_keeps_corrupt_entry() {
let prefix = format!("{}:async-strict", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.strict_deserialization(true)
.build()
.await
.unwrap();
let err = c
.async_cache_get(&1)
.await
.expect_err("strict async must error on a corrupt entry");
assert!(
err.is_deserialization(),
"expected CacheDeserialization, got: {err:?}"
);
assert!(
key_exists(&key),
"strict mode must NOT delete the corrupt key"
);
delete_key(&key);
}
#[tokio::test]
async fn async_cache_set_displaced_corrupt_previous_returns_ok_none() {
let prefix = format!("{}:async-redis10-set", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
let result = c.async_cache_set(1, 42).await;
assert!(
result.is_ok(),
"async_cache_set must not error on corrupt displaced value; got: {result:?}"
);
assert!(
result.unwrap().is_none(),
"displaced corrupt value must yield Ok(None)"
);
assert_eq!(c.async_cache_get(&1).await.unwrap(), Some(42));
delete_key(&key);
}
#[tokio::test]
async fn async_cache_set_ref_over_corrupt_previous_returns_ok_unit() {
use crate::SerializeCachedAsync;
let prefix = format!("{}:async-redis10-setref", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
let val = 42u32;
let result = c.async_cache_set_ref(&1, &val).await;
assert!(
result.is_ok(),
"async_cache_set_ref must not error over a corrupt previous value; got: {result:?}"
);
assert_eq!(c.async_cache_get(&1).await.unwrap(), Some(42));
delete_key(&key);
}
#[tokio::test]
async fn async_connection_boxed_source_chain_does_not_leak_password() {
let secret = "async_boxed_chain_secret_qzx999";
let bad_url = format!("not-redis://:{secret}@nonexistent-host:9999");
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(&bad_url)
.build()
.await;
let err = result.expect_err("build must fail with a bad async URL");
let RedisCacheBuildError::Connection { source } = &err else {
panic!("expected Connection error, got: {err:?}");
};
assert!(
!format!("{source:?}").contains(secret),
"boxed source Debug must not expose the password"
);
assert!(
!source.to_string().contains(secret),
"boxed source Display must not expose the password"
);
let mut cause = source.source();
while let Some(c) = cause {
let rendered = format!("{c:?}{c}");
assert!(
!rendered.contains(secret),
"cause chain must not expose the password; got: {rendered}"
);
assert!(
!rendered.contains(&bad_url),
"cause chain must not echo the raw URL; got: {rendered}"
);
cause = c.source();
}
}
#[tokio::test]
async fn async_cache_remove_corrupt_default_mode_returns_ok_none() {
let prefix = format!("{}:async-remove-corrupt-default", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
let result = c.async_cache_remove(&1).await;
assert!(
result.is_ok(),
"async_cache_remove must not error in default mode on corrupt entry; got: {result:?}"
);
assert!(
result.unwrap().is_none(),
"async_cache_remove must return Ok(None) for corrupt entry in default mode"
);
assert!(
!key_exists(&key),
"async_cache_remove must delete the key even when bytes are corrupt"
);
}
#[tokio::test]
async fn async_cache_remove_corrupt_strict_mode_returns_error_and_key_is_gone() {
let prefix = format!("{}:async-remove-corrupt-strict", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.strict_deserialization(true)
.build()
.await
.unwrap();
let err = c
.async_cache_remove(&1)
.await
.expect_err("strict async_cache_remove must return Err for corrupt entry");
assert!(
err.is_deserialization(),
"expected CacheDeserialization error, got: {err:?}"
);
assert!(
!key_exists(&key),
"key must be deleted even when strict async_cache_remove errors"
);
}
#[cfg(all(feature = "redis_connection_manager", feature = "redis_async_cache"))]
#[tokio::test]
async fn connection_manager_client_side_caching_rejects_resp2_url() {
let url_with_resp2 = "redis://127.0.0.1:6399?protocol=resp2";
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(url_with_resp2)
.client_side_caching(true)
.connection_manager(true)
.build()
.await;
assert!(
matches!(
result,
Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching)
),
"expected Resp2DowngradeWithClientSideCaching on connection-manager path, got: {result:?}"
);
}
#[cfg(all(feature = "redis_connection_manager", feature = "redis_async_cache"))]
#[tokio::test]
async fn connection_manager_client_side_caching_accepts_resp3_url() {
let url_with_resp3 = "redis://127.0.0.1:6399?protocol=resp3";
let result = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("test")
.ttl(Duration::from_secs(1))
.connection_string(url_with_resp3)
.client_side_caching(true)
.connection_manager(true)
.build()
.await;
assert!(
!matches!(
result,
Err(RedisCacheBuildError::Resp2DowngradeWithClientSideCaching)
),
"resp3 URL must not trigger the RESP2 guard on the connection-manager path; got: {result:?}"
);
}
#[tokio::test]
async fn async_cache_remove_valid_value_returns_some_and_deletes_key() {
let prefix = format!("{}:async-remove-valid", now_millis());
let key = raw_key(&prefix, "1");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
assert!(c.async_cache_set(1, 100).await.unwrap().is_none());
assert_eq!(
c.async_cache_remove(&1).await.unwrap(),
Some(100),
"async_cache_remove must return the stored value"
);
assert!(!key_exists(&key), "async_cache_remove must delete the key");
assert_eq!(
c.async_cache_get(&1).await.unwrap(),
None,
"get after remove must be a miss"
);
}
#[tokio::test]
async fn async_cache_remove_missing_key_returns_ok_none() {
let prefix = format!("{}:async-remove-missing", now_millis());
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
let result = c.async_cache_remove(&12345).await;
assert!(
result.is_ok(),
"async_cache_remove on a missing key must not error; got: {result:?}"
);
assert_eq!(
result.unwrap(),
None,
"async_cache_remove on a missing key must return Ok(None)"
);
}
#[tokio::test]
async fn async_cache_remove_entry_valid_returns_key_and_value() {
let prefix = format!("{}:async-remove-entry-valid", now_millis());
let key = raw_key(&prefix, "7");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
assert!(c.async_cache_set(7, 700).await.unwrap().is_none());
assert_eq!(
c.async_cache_remove_entry(&7).await.unwrap(),
Some((7, 700)),
"async_cache_remove_entry must return the key and the stored value"
);
assert!(
!key_exists(&key),
"async_cache_remove_entry must delete the key"
);
}
#[tokio::test]
async fn async_cache_remove_entry_corrupt_default_mode_returns_ok_none() {
let prefix = format!("{}:async-remove-entry-corrupt-default", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.build()
.await
.unwrap();
let result = c.async_cache_remove_entry(&1).await;
assert!(
result.is_ok(),
"async_cache_remove_entry must not error in default mode on corrupt entry; got: {result:?}"
);
assert_eq!(
result.unwrap(),
None,
"async_cache_remove_entry must return Ok(None) for a corrupt entry in default mode"
);
assert!(
!key_exists(&key),
"async_cache_remove_entry must delete the key even when bytes are corrupt"
);
}
#[tokio::test]
async fn async_cache_remove_entry_corrupt_strict_mode_returns_error_and_key_is_gone() {
let prefix = format!("{}:async-remove-entry-corrupt-strict", now_millis());
let key = raw_key(&prefix, "1");
plant_raw(&key, b"\xff\xfe\xfd");
let c: AsyncRedisCache<u32, u32> = AsyncRedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.connection_string("redis://127.0.0.1:6399")
.strict_deserialization(true)
.build()
.await
.unwrap();
let err = c
.async_cache_remove_entry(&1)
.await
.expect_err("strict async_cache_remove_entry must return Err for corrupt entry");
assert!(
matches!(err, RedisCacheError::CacheDeserialization { .. }),
"expected the exact CacheDeserialization variant, got: {err:?}"
);
assert!(
!key_exists(&key),
"key must be deleted even when strict async_cache_remove_entry errors"
);
}
#[cfg(feature = "redis_async_cache")]
#[test]
fn url_pins_resp2_is_case_sensitive() {
assert!(
AsyncRedisCacheBuilder::<String, String>::url_pins_resp2(
"redis://127.0.0.1:6379?protocol=resp2"
),
"protocol=resp2 must be detected"
);
assert!(
AsyncRedisCacheBuilder::<String, String>::url_pins_resp2(
"redis://127.0.0.1:6379?protocol=2"
),
"protocol=2 must be detected"
);
assert!(
!AsyncRedisCacheBuilder::<String, String>::url_pins_resp2(
"redis://127.0.0.1:6379?protocol=RESP2"
),
"protocol=RESP2 (uppercase) must NOT be treated as pinning RESP2"
);
assert!(
!AsyncRedisCacheBuilder::<String, String>::url_pins_resp2(
"redis://127.0.0.1:6379?protocol=Resp2"
),
"protocol=Resp2 (mixed case) must NOT be treated as pinning RESP2"
);
assert!(
!AsyncRedisCacheBuilder::<String, String>::url_pins_resp2(
"redis://127.0.0.1:6379?protocol=resp3"
),
"protocol=resp3 must not be detected as RESP2"
);
assert!(
!AsyncRedisCacheBuilder::<String, String>::url_pins_resp2("redis://127.0.0.1:6379"),
"no protocol param must not be detected as RESP2"
);
}
}
#[cfg(test)]
mod async_builder_ttl_setter_tests {
use super::AsyncRedisCacheBuilder;
use crate::time::Duration;
#[test]
fn ttl_secs_and_ttl_millis_set_duration() {
let b = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_secs(7);
assert_eq!(b.ttl, Some(Duration::from_secs(7)));
let b = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_millis(250);
assert_eq!(b.ttl, Some(Duration::from_millis(250)));
}
#[test]
fn ttl_setters_override_last_writer_wins() {
let b = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_secs(10)
.ttl_millis(500);
assert_eq!(b.ttl, Some(Duration::from_millis(500)));
let b = AsyncRedisCacheBuilder::<String, String>::new()
.prefix("p")
.ttl_millis(500)
.ttl_secs(10);
assert_eq!(b.ttl, Some(Duration::from_secs(10)));
}
#[cfg(feature = "redis_connection_manager")]
#[test]
fn connection_manager_defaults_false_and_flips() {
let b = AsyncRedisCacheBuilder::<String, String>::new().prefix("p");
assert!(
!b.connection_manager,
"default must be multiplexed (connection_manager == false) so enabling \
the feature is additive and never swaps behavior on its own"
);
let b = b.connection_manager(true);
assert!(
b.connection_manager,
".connection_manager(true) must opt the cache into the manager"
);
let b = b.connection_manager(false);
assert!(
!b.connection_manager,
".connection_manager(false) must return to the multiplexed default"
);
}
}
#[cfg(test)]
mod async_connection_enum_tests {
use super::AsyncRedisConnection;
#[allow(dead_code)]
fn multiplexed_variant_exists(
c: redis::aio::MultiplexedConnection,
) -> AsyncRedisConnection {
AsyncRedisConnection::Multiplexed(c)
}
#[cfg(feature = "redis_connection_manager")]
#[allow(dead_code)]
fn manager_variant_exists(c: redis::aio::ConnectionManager) -> AsyncRedisConnection {
AsyncRedisConnection::Manager(c)
}
#[allow(dead_code)]
fn assert_bounds<T: Clone + redis::aio::ConnectionLike>() {}
#[allow(dead_code)]
fn check_connection_enum_bounds() {
assert_bounds::<AsyncRedisConnection>();
}
}
}
#[cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
))]
#[cfg_attr(
docsrs,
doc(cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
)))
)]
pub use async_redis::{AsyncRedisCache, AsyncRedisCacheBuilder};
#[cfg(test)]
mod error_source_tests {
use std::error::Error;
use super::{RedisCacheBuildError, RedisCacheError};
#[test]
fn missing_connection_string_has_source() {
let inner = std::env::VarError::NotPresent;
let err = RedisCacheBuildError::MissingConnectionString {
env_key: "TEST_KEY".to_string(),
error: inner,
};
let source = err
.source()
.expect("MissingConnectionString must expose its inner VarError as source()");
assert_eq!(
source.to_string(),
std::env::VarError::NotPresent.to_string(),
"source() must be the inner VarError"
);
assert!(
source.downcast_ref::<std::env::VarError>().is_some(),
"source() must downcast to std::env::VarError"
);
}
#[test]
fn missing_connection_string_display_is_clean() {
let err = RedisCacheBuildError::MissingConnectionString {
env_key: "CACHED_REDIS_CONNECTION_STRING".to_string(),
error: std::env::VarError::NotPresent,
};
let rendered = err.to_string();
assert!(
rendered.contains("CACHED_REDIS_CONNECTION_STRING"),
"Display must name the env var; got: {rendered}"
);
assert!(
rendered.contains(&std::env::VarError::NotPresent.to_string()),
"Display must include the VarError's human message; got: {rendered}"
);
assert!(
!rendered.contains("NotPresent"),
"Display must not leak the Debug variant name `NotPresent`; got: {rendered}"
);
assert!(
!rendered.contains("VarError"),
"Display must not leak the `VarError` type name; got: {rendered}"
);
}
#[test]
fn cache_deserialization_has_source() {
let bad_bytes: Vec<u8> = vec![0xc1]; let inner: rmp_serde::decode::Error = rmp_serde::from_slice::<u32>(&bad_bytes).unwrap_err();
let inner_display = inner.to_string();
let err = RedisCacheError::deserialization(inner, bad_bytes.clone());
let source = err
.source()
.expect("CacheDeserialization must expose its inner decode::Error as source()");
assert!(
source.downcast_ref::<rmp_serde::decode::Error>().is_some(),
"source() must downcast to rmp_serde::decode::Error"
);
let rendered = err.to_string();
assert!(
!rendered.is_empty(),
"Display must produce a non-empty string; got: {rendered}"
);
assert_eq!(
source.to_string(),
inner_display,
"source() display must match the original decode error"
);
if let RedisCacheError::CacheDeserialization { cached_value, .. } = &err {
assert_eq!(cached_value, &bad_bytes);
} else {
panic!("expected CacheDeserialization");
}
}
#[test]
fn cache_serialization_has_source() {
#[derive(Debug)]
struct Unserializable;
impl serde::Serialize for Unserializable {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("intentional failure"))
}
}
let inner: rmp_serde::encode::Error = rmp_serde::to_vec(&Unserializable).unwrap_err();
let inner_display = inner.to_string();
let err = RedisCacheError::serialization(inner);
let source = err
.source()
.expect("CacheSerialization must expose its inner encode::Error as source()");
assert!(
source.downcast_ref::<rmp_serde::encode::Error>().is_some(),
"source() must downcast to rmp_serde::encode::Error"
);
assert_eq!(
source.to_string(),
inner_display,
"source() display must match the original encode error"
);
}
#[test]
fn msgpack_round_trip_via_cached_redis_value() {
use super::CachedRedisValue;
let original: u64 = 42;
let wrapped = CachedRedisValue::new(original);
let bytes = rmp_serde::to_vec(&wrapped).expect("serialize must succeed");
assert!(!bytes.is_empty());
assert!(
std::str::from_utf8(&bytes).is_err() || !bytes.starts_with(b"{"),
"msgpack output should not look like JSON"
);
let recovered: CachedRedisValue<u64> =
rmp_serde::from_slice(&bytes).expect("deserialize must succeed");
assert_eq!(recovered.value, original);
assert_eq!(recovered.version, Some(1));
}
#[test]
fn msgpack_round_trip_string_value() {
use super::CachedRedisValue;
let original = "hello, msgpack!".to_string();
let wrapped = CachedRedisValue::new(original.clone());
let bytes = rmp_serde::to_vec(&wrapped).expect("serialize must succeed");
let recovered: CachedRedisValue<String> =
rmp_serde::from_slice(&bytes).expect("deserialize must succeed");
assert_eq!(recovered.value, original);
}
#[test]
fn cached_redis_value_is_a_positional_array_of_value_then_version() {
use super::{CachedRedisValue, CachedRedisValueRef};
let bytes =
rmp_serde::to_vec(&CachedRedisValue::new("hello".to_string())).expect("serialize");
assert_eq!(
bytes,
vec![0x92, 0xa5, b'h', b'e', b'l', b'l', b'o', 0x01],
"the frozen 3.x envelope is a positional array of `value` then `version`"
);
let (value, version): (String, Option<u64>) =
rmp_serde::from_slice(&bytes).expect("positional decode");
assert_eq!(value, "hello");
assert_eq!(version, super::REDIS_VALUE_VERSION);
let borrowed = "hello".to_string();
assert_eq!(
rmp_serde::to_vec(&CachedRedisValueRef::new(&borrowed)).expect("serialize borrowed"),
bytes,
"`cache_set_ref` must write the same positional layout as `cache_set`"
);
}
#[test]
fn deserialize_helper_reads_msgpack() {
use super::{CachedRedisValue, deserialize_cached_redis_value};
let bytes = rmp_serde::to_vec(&CachedRedisValue::new(7u64)).expect("serialize");
let recovered: CachedRedisValue<u64> =
deserialize_cached_redis_value(&bytes).expect("msgpack must deserialize");
assert_eq!(recovered.value, 7u64);
assert_eq!(recovered.version, Some(1));
}
#[test]
fn deserialize_helper_reads_legacy_json() {
use super::{CachedRedisValue, deserialize_cached_redis_value};
let json = serde_json::to_vec(&CachedRedisValue::new("legacy".to_string()))
.expect("json serialize");
assert!(json.starts_with(b"{"));
assert!(rmp_serde::from_slice::<CachedRedisValue<String>>(&json).is_err());
let recovered: CachedRedisValue<String> =
deserialize_cached_redis_value(&json).expect("legacy JSON must deserialize");
assert_eq!(recovered.value, "legacy");
assert_eq!(recovered.version, Some(1));
}
#[test]
fn deserialize_helper_rejects_json_without_version() {
use super::{RedisCacheError, deserialize_cached_redis_value};
let bytes = br#"{"value": 1}"#.to_vec();
match deserialize_cached_redis_value::<u64>(&bytes) {
Ok(_) => panic!("JSON without a version key must not be accepted"),
Err(RedisCacheError::CacheDeserialization { cached_value, .. }) => {
assert_eq!(cached_value, bytes, "raw bytes must be preserved");
}
Err(other) => panic!("expected CacheDeserialization, got: {other:?}"),
}
}
#[test]
fn deserialize_helper_corrupt_bytes_preserve_value() {
use super::{RedisCacheError, deserialize_cached_redis_value};
let bytes: Vec<u8> = vec![0xc1, 0x00, 0xff];
match deserialize_cached_redis_value::<u64>(&bytes) {
Ok(_) => panic!("corrupt bytes must not deserialize"),
Err(RedisCacheError::CacheDeserialization { cached_value, .. }) => {
assert_eq!(
cached_value, bytes,
"the original corrupt bytes must be preserved in the error"
);
}
Err(other) => panic!("expected CacheDeserialization, got: {other:?}"),
}
}
#[test]
fn redis_error_source_downcasts_to_redis_error() {
let re = redis::RedisError::from((redis::ErrorKind::InvalidClientConfig, "boom"));
let err = RedisCacheError::redis(re);
let source = err.source().expect("Redis variant must expose a source");
assert!(
source.downcast_ref::<redis::RedisError>().is_some(),
"source() must still downcast to redis::RedisError"
);
}
#[test]
fn build_connection_source_downcasts_to_redis_error() {
let err = RedisCacheBuildError::connection(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"sanitized synthetic error",
)));
let source = err
.source()
.expect("Connection variant must expose a source");
assert!(
source.downcast_ref::<redis::RedisError>().is_some(),
"Connection source() must downcast to redis::RedisError"
);
}
#[test]
fn is_deserialization_classifier_distinguishes_variants() {
let bad: Vec<u8> = vec![0xc1];
let deser =
RedisCacheError::deserialization(rmp_serde::from_slice::<u32>(&bad).unwrap_err(), bad);
assert!(
deser.is_deserialization(),
"decode error must classify true"
);
let redis_err = RedisCacheError::redis(redis::RedisError::from((
redis::ErrorKind::InvalidClientConfig,
"x",
)));
assert!(
!redis_err.is_deserialization(),
"redis error must classify false"
);
#[derive(Debug)]
struct Unserializable;
impl serde::Serialize for Unserializable {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("intentional failure"))
}
}
let ser = RedisCacheError::serialization(rmp_serde::to_vec(&Unserializable).unwrap_err());
assert!(
!ser.is_deserialization(),
"serialization error must classify false"
);
}
#[allow(dead_code)]
fn assert_clone<T: Clone>() {}
#[allow(dead_code)]
fn check_redis_cache_is_clone() {
assert_clone::<super::RedisCache<String, String>>();
}
#[cfg(any(
feature = "redis_smol",
feature = "redis_smol_native_tls",
feature = "redis_smol_rustls",
feature = "redis_tokio",
feature = "redis_tokio_native_tls",
feature = "redis_tokio_rustls",
))]
#[allow(dead_code)]
fn check_async_redis_cache_is_clone() {
assert_clone::<super::AsyncRedisCache<String, String>>();
}
}
#[cfg(test)]
mod connection_string_tests {
use super::ConnectionString;
#[test]
fn display_is_redacted() {
let cs = ConnectionString("redis://:secret@127.0.0.1:6379".to_string());
let displayed = cs.to_string();
assert_eq!(
displayed, "[REDACTED connection string]",
"Display must return the redacted placeholder, got: {displayed}"
);
assert!(
!displayed.contains("secret"),
"Display must not expose the password; got: {displayed}"
);
}
#[test]
fn debug_is_redacted() {
let cs = ConnectionString("redis://:secret@127.0.0.1:6379".to_string());
let debugged = format!("{cs:?}");
assert_eq!(
debugged, "[REDACTED connection string]",
"Debug must return the redacted placeholder, got: {debugged}"
);
assert!(
!debugged.contains("secret"),
"Debug must not expose the password; got: {debugged}"
);
}
#[test]
fn reveal_returns_raw() {
let raw = "redis://:secret@127.0.0.1:6379";
let cs = ConnectionString(raw.to_string());
assert_eq!(cs.reveal(), raw);
assert!(cs.reveal().contains("secret"));
}
#[test]
fn debug_and_display_redact_but_reveal_does_not() {
let cs = ConnectionString("redis://:s3cr3t@localhost:6379/0".to_string());
assert_eq!(cs.to_string(), "[REDACTED connection string]");
assert_eq!(format!("{cs:?}"), "[REDACTED connection string]");
assert!(!cs.to_string().contains("s3cr3t"));
assert!(!format!("{cs:?}").contains("s3cr3t"));
assert!(cs.reveal().contains("s3cr3t"));
}
#[test]
fn eq_and_hash_on_raw_url() {
use std::hash::{DefaultHasher, Hash, Hasher};
fn hash_of(cs: &ConnectionString) -> u64 {
let mut hasher = DefaultHasher::new();
cs.hash(&mut hasher);
hasher.finish()
}
let a = ConnectionString("redis://:secret@127.0.0.1:6379".to_string());
let b = ConnectionString("redis://:secret@127.0.0.1:6379".to_string());
let c = ConnectionString("redis://:other@127.0.0.1:6379".to_string());
assert_eq!(a, b, "same raw URL must compare equal");
assert_ne!(a, c, "different raw URLs must compare unequal");
assert_eq!(hash_of(&a), hash_of(&b), "Hash must be consistent with Eq");
}
}
#[cfg(test)]
mod tests {
use crate::time::Duration;
use std::thread::sleep;
use super::*;
fn now_millis() -> u128 {
crate::time::SystemTime::now()
.duration_since(crate::time::UNIX_EPOCH)
.unwrap()
.as_millis()
}
fn raw_key(prefix: &str, key: &str) -> String {
super::generate_redis_key(super::DEFAULT_NAMESPACE, prefix, key)
}
#[test]
fn redis_cache() {
let c: RedisCache<u32, u32> =
RedisCache::builder(format!("{}:redis-cache-test", now_millis()))
.ttl(Duration::from_secs(2))
.namespace("in-tests:")
.build()
.unwrap();
assert!(c.cache_get(&1).unwrap().is_none());
assert!(c.cache_set(1, 100).unwrap().is_none());
assert!(c.cache_get(&1).unwrap().is_some());
sleep(Duration::from_millis(2_500));
assert!(c.cache_get(&1).unwrap().is_none());
let old = ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(1)).unwrap();
assert_eq!(2, old.as_secs());
assert!(c.cache_set(1, 100).unwrap().is_none());
assert!(c.cache_get(&1).unwrap().is_some());
sleep(Duration::from_millis(1_600));
assert!(c.cache_get(&1).unwrap().is_none());
ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(10)).unwrap();
assert!(c.cache_set(1, 100).unwrap().is_none());
assert!(c.cache_set(2, 100).unwrap().is_none());
assert_eq!(c.cache_get(&1).unwrap().unwrap(), 100);
assert_eq!(c.cache_get(&1).unwrap().unwrap(), 100);
}
#[test]
fn remove() {
let c: RedisCache<u32, u32> =
RedisCache::builder(format!("{}:redis-cache-test-remove", now_millis()))
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
assert!(c.cache_set(1, 100).unwrap().is_none());
assert!(c.cache_set(2, 200).unwrap().is_none());
assert!(c.cache_set(3, 300).unwrap().is_none());
assert_eq!(100, c.cache_remove(&1).unwrap().unwrap());
}
#[test]
fn cache_get_self_heals_corrupted_entry_by_default() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:selfheal-default", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let result = c.cache_get(&1).unwrap();
assert!(
result.is_none(),
"expected Ok(None) after self-heal, got: {result:?}"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(!exists, "corrupt key must be deleted after self-heal");
}
#[test]
fn cache_get_strict_mode_returns_error_for_corrupted_entry() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:selfheal-strict", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.strict_deserialization(true)
.build()
.unwrap();
let err = c
.cache_get(&1)
.expect_err("strict mode must return Err for corrupt entry");
assert!(
err.is_deserialization(),
"expected CacheDeserialization, got: {err:?}"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(exists, "corrupt key must NOT be deleted in strict mode");
let _: () = redis::cmd("DEL").arg(&key).query(&mut conn).unwrap();
}
#[test]
fn cache_set_displaced_corrupt_previous_returns_ok_none() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:redis10-test", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let result = c.cache_set(1, 42);
assert!(
result.is_ok(),
"cache_set must not error on corrupt displaced value; got: {result:?}"
);
assert!(
result.unwrap().is_none(),
"displaced corrupt value must yield Ok(None)"
);
}
#[test]
fn cache_get_self_heal_then_recompute_produces_hit() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:selfheal-recompute", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
assert_eq!(c.cache_get(&1).unwrap(), None, "self-heal returns a miss");
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(!exists, "self-heal must delete the corrupt key");
assert_eq!(
c.cache_set(1, 77).unwrap(),
None,
"recompute writes over the healed miss"
);
assert_eq!(
c.cache_get(&1).unwrap(),
Some(77),
"the read after recompute is a HIT"
);
let _: () = redis::cmd("DEL").arg(&key).query(&mut conn).unwrap();
}
#[test]
fn cache_set_ref_over_corrupt_previous_returns_ok_unit() {
use crate::SerializeCached;
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:redis10-setref", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let val = 42u32;
let result = SerializeCached::cache_set_ref(&c, &1, &val);
assert!(
result.is_ok(),
"cache_set_ref must not error over a corrupt previous value; got: {result:?}"
);
assert_eq!(c.cache_get(&1).unwrap(), Some(42));
let _: () = redis::cmd("DEL").arg(&key).query(&mut conn).unwrap();
}
#[test]
fn cache_remove_corrupt_default_mode_returns_ok_none() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-corrupt-default", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let result = c.cache_remove(&1);
assert!(
result.is_ok(),
"cache_remove must not error in default mode on corrupt entry; got: {result:?}"
);
assert!(
result.unwrap().is_none(),
"cache_remove must return Ok(None) for corrupt entry in default mode"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(
!exists,
"cache_remove must delete the key even when bytes are corrupt"
);
}
#[test]
fn cache_remove_corrupt_strict_mode_returns_error_and_key_is_gone() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-corrupt-strict", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.strict_deserialization(true)
.build()
.unwrap();
let err = c
.cache_remove(&1)
.expect_err("strict cache_remove must return Err for corrupt entry");
assert!(
err.is_deserialization(),
"expected CacheDeserialization error, got: {err:?}"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(
!exists,
"key must be deleted even when strict cache_remove errors"
);
}
#[test]
fn cache_remove_valid_value_returns_some_and_deletes_key() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-valid", now_millis());
let key = raw_key(&prefix, "1");
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
assert!(c.cache_set(1, 100).unwrap().is_none());
assert_eq!(
c.cache_remove(&1).unwrap(),
Some(100),
"cache_remove must return the stored value"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(!exists, "cache_remove must delete the key");
assert_eq!(
c.cache_get(&1).unwrap(),
None,
"get after remove must be a miss"
);
}
#[test]
fn cache_remove_missing_key_returns_ok_none() {
let prefix = format!("{}:remove-missing", now_millis());
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let result = c.cache_remove(&12345);
assert!(
result.is_ok(),
"cache_remove on a missing key must not error; got: {result:?}"
);
assert_eq!(
result.unwrap(),
None,
"cache_remove on a missing key must return Ok(None)"
);
}
#[test]
fn cache_remove_entry_valid_returns_key_and_value() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-entry-valid", now_millis());
let key = raw_key(&prefix, "7");
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
assert!(c.cache_set(7, 700).unwrap().is_none());
assert_eq!(
c.cache_remove_entry(&7).unwrap(),
Some((7, 700)),
"cache_remove_entry must return the key and the stored value"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(!exists, "cache_remove_entry must delete the key");
}
#[test]
fn cache_remove_entry_corrupt_default_mode_returns_ok_none() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-entry-corrupt-default", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let result = c.cache_remove_entry(&1);
assert!(
result.is_ok(),
"cache_remove_entry must not error in default mode on corrupt entry; got: {result:?}"
);
assert_eq!(
result.unwrap(),
None,
"cache_remove_entry must return Ok(None) for a corrupt entry in default mode"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(
!exists,
"cache_remove_entry must delete the key even when bytes are corrupt"
);
}
#[test]
fn cache_remove_entry_corrupt_strict_mode_returns_error_and_key_is_gone() {
let mut conn = redis::Client::open("redis://127.0.0.1:6399")
.unwrap()
.get_connection()
.unwrap();
let prefix = format!("{}:remove-entry-corrupt-strict", now_millis());
let key = raw_key(&prefix, "1");
let _: () = redis::cmd("SET")
.arg(&key)
.arg(b"\xff\xfe\xfd".as_ref())
.query(&mut conn)
.unwrap();
let c: RedisCache<u32, u32> = RedisCache::builder(prefix)
.ttl(Duration::from_secs(3600))
.strict_deserialization(true)
.build()
.unwrap();
let err = c
.cache_remove_entry(&1)
.expect_err("strict cache_remove_entry must return Err for corrupt entry");
assert!(
matches!(err, RedisCacheError::CacheDeserialization { .. }),
"expected the exact CacheDeserialization variant, got: {err:?}"
);
let exists: bool = redis::cmd("EXISTS").arg(&key).query(&mut conn).unwrap();
assert!(
!exists,
"key must be deleted even when strict cache_remove_entry errors"
);
}
}