use crate::error::{OxCacheError, OxCacheResult};
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
feature = "aerospike"
))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_BACKEND_AEROSPIKE_PROGRAMMATIC;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
any(
not(feature = "redis"),
not(feature = "dragonfly"),
not(feature = "disk"),
not(feature = "aerospike")
)
))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_BACKEND_KIND_REQUIRES_FEATURE;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(test)
))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_BACKEND_MOCK_TEST_ONLY;
#[cfg(not(feature = "metrics"))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_METRICS_FEATURE;
#[cfg(all(
any(feature = "serialization", feature = "full"),
not(feature = "serde-bincode")
))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_SERIALIZATION_BINCODE_REQUIRES_FEATURE;
#[cfg(any(feature = "serialization", feature = "full"))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_SERIALIZATION_INVALID_FORMAT;
#[cfg(all(
any(feature = "serialization", feature = "full"),
not(feature = "postcard")
))]
use crate::i18n::messages::MSG_DETAIL_CONFIG_SERIALIZATION_POSTCARD_REQUIRES_FEATURE;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
feature = "disk"
))]
use crate::i18n::messages::MSG_DETAIL_DISK_OPEN_CREATE_FAILED;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(feature = "disk")
))]
use crate::i18n::messages::MSG_PANIC_CONFIG_VALIDATE_DISK_FEATURE;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(feature = "dragonfly")
))]
use crate::i18n::messages::MSG_PANIC_CONFIG_VALIDATE_DRAGONFLY_FEATURE;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(all(test, feature = "memory"))
))]
use crate::i18n::messages::MSG_PANIC_CONFIG_VALIDATE_MOCK_TEST_MEMORY;
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(feature = "redis")
))]
use crate::i18n::messages::MSG_PANIC_CONFIG_VALIDATE_REDIS_FEATURE;
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_BACKEND_CHAIN_NEEDS_BUILDER, MSG_DETAIL_CONFIG_BACKEND_UNKNOWN_KIND,
MSG_DETAIL_CONFIG_BACKEND_VALKEY_NO_IMPL,
};
#[cfg(not(any(feature = "memory", feature = "redis", feature = "disk")))]
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_BACKEND_FEATURES, MSG_DETAIL_CONFIG_ENV_BACKEND_FEATURES,
};
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_BACKEND_INVALID_VALUE, MSG_DETAIL_CONFIG_BACKEND_NOT_CONFIG_BUILDABLE,
};
#[cfg(all(
any(feature = "memory", feature = "redis", feature = "disk"),
not(feature = "memory")
))]
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_BACKEND_REQUIRES_FEATURE, MSG_PANIC_CONFIG_VALIDATE_DASHMAP_MEMORY,
MSG_PANIC_CONFIG_VALIDATE_MOKA_MEMORY,
};
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_CAPACITY_EXCEEDS_USIZE, MSG_DETAIL_CONFIG_CAPACITY_ZERO,
MSG_DETAIL_CONFIG_CB_THRESHOLD_ZERO, MSG_DETAIL_CONFIG_ENV_INVALID_BOOL,
MSG_DETAIL_CONFIG_ENV_INVALID_VALUE, MSG_DETAIL_CONFIG_ENV_NOT_UNICODE,
MSG_DETAIL_CONFIG_POOL_SIZE_ZERO, MSG_DETAIL_CONFIG_SERVICE_NAME_EMPTY,
MSG_DETAIL_CONFIG_TTL_ZERO, t,
};
#[cfg(not(any(feature = "serialization", feature = "full")))]
use crate::i18n::messages::{
MSG_DETAIL_CONFIG_ENV_SERIALIZATION_FEATURE, MSG_DETAIL_CONFIG_SERIALIZATION_FEATURE,
};
use std::time::Duration;
pub const ENV_PREFIX: &str = "OXCACHE_";
pub(crate) const KEY_CAPACITY: &str = "OXCACHE_CAPACITY";
pub(crate) const KEY_TTL_MS: &str = "OXCACHE_TTL_MS";
pub(crate) const KEY_TTI_MS: &str = "OXCACHE_TTI_MS";
pub(crate) const KEY_NULL_CACHE_TTL_MS: &str = "OXCACHE_NULL_CACHE_TTL_MS";
pub(crate) const KEY_TTL_JITTER_FACTOR: &str = "OXCACHE_TTL_JITTER_FACTOR";
pub(crate) const KEY_SYNC_MODE: &str = "OXCACHE_SYNC_MODE";
pub(crate) const KEY_BACKEND: &str = "OXCACHE_BACKEND";
pub(crate) const KEY_METRICS: &str = "OXCACHE_METRICS";
pub(crate) const KEY_SERIALIZATION_FORMAT: &str = "OXCACHE_SERIALIZATION_FORMAT";
pub(crate) const KEY_REDIS_URL: &str = "OXCACHE_REDIS_URL";
pub(crate) const KEY_DISK_PATH: &str = "OXCACHE_DISK_PATH";
pub(crate) const KEY_CONNECTION_POOL_SIZE: &str = "OXCACHE_CONNECTION_POOL_SIZE";
pub(crate) const KEY_CB_FAILURE_THRESHOLD: &str = "OXCACHE_CIRCUIT_BREAKER_FAILURE_THRESHOLD";
pub(crate) const KEY_CB_RESET_TIMEOUT_MS: &str = "OXCACHE_CIRCUIT_BREAKER_RESET_TIMEOUT_MS";
pub(crate) const KEY_SERVICE_NAME: &str = "OXCACHE_SERVICE_NAME";
#[derive(Clone, Default, PartialEq)]
pub struct CacheConfig {
pub capacity: Option<u64>,
pub ttl: Option<Duration>,
pub tti: Option<Duration>,
pub null_cache_ttl: Option<Duration>,
pub ttl_jitter_factor: Option<f64>,
pub sync_mode: Option<bool>,
pub backend: Option<String>,
pub metrics_enabled: Option<bool>,
pub serialization_format: Option<String>,
pub redis_url: Option<String>,
pub disk_path: Option<String>,
pub connection_pool_size: Option<usize>,
pub circuit_breaker_failure_threshold: Option<u32>,
pub circuit_breaker_reset_timeout: Option<Duration>,
pub service_name: Option<String>,
}
impl std::fmt::Debug for CacheConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CacheConfig")
.field("capacity", &self.capacity)
.field("ttl", &self.ttl)
.field("tti", &self.tti)
.field("null_cache_ttl", &self.null_cache_ttl)
.field("ttl_jitter_factor", &self.ttl_jitter_factor)
.field("sync_mode", &self.sync_mode)
.field("backend", &self.backend)
.field("metrics_enabled", &self.metrics_enabled)
.field("serialization_format", &self.serialization_format)
.field("redis_url", &redact_url(self.redis_url.as_deref()))
.field("disk_path", &self.disk_path)
.field("connection_pool_size", &self.connection_pool_size)
.field(
"circuit_breaker_failure_threshold",
&self.circuit_breaker_failure_threshold,
)
.field(
"circuit_breaker_reset_timeout",
&self.circuit_breaker_reset_timeout,
)
.field("service_name", &self.service_name)
.finish()
}
}
fn redact_url(url: Option<&str>) -> Option<&'static str> {
url.map(|_| "***")
}
impl CacheConfig {
pub fn builder() -> CacheConfigBuilder {
CacheConfigBuilder::default()
}
pub fn try_from_env() -> OxCacheResult<Self> {
let mut config = Self::default();
if let Some(raw) = env_value(KEY_CAPACITY)? {
config.capacity = Some(env_parse(KEY_CAPACITY, &raw)?);
}
if let Some(raw) = env_value(KEY_TTL_MS)? {
config.ttl = Some(Duration::from_millis(env_parse(KEY_TTL_MS, &raw)?));
}
if let Some(raw) = env_value(KEY_TTI_MS)? {
config.tti = Some(Duration::from_millis(env_parse(KEY_TTI_MS, &raw)?));
}
if let Some(raw) = env_value(KEY_NULL_CACHE_TTL_MS)? {
config.null_cache_ttl = Some(Duration::from_millis(env_parse(
KEY_NULL_CACHE_TTL_MS,
&raw,
)?));
}
if let Some(raw) = env_value(KEY_TTL_JITTER_FACTOR)? {
config.ttl_jitter_factor = Some(env_parse(KEY_TTL_JITTER_FACTOR, &raw)?);
}
if let Some(raw) = env_value(KEY_SYNC_MODE)? {
config.sync_mode = Some(parse_bool_value(KEY_SYNC_MODE, &raw)?);
}
if let Some(raw) = env_value(KEY_BACKEND)? {
#[cfg(not(any(feature = "memory", feature = "redis", feature = "disk")))]
{
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_ENV_BACKEND_FEATURES,
&[
("key", KEY_BACKEND.to_string()),
("raw", format!("{raw:?}")),
],
)));
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
{
parse_backend_kind(KEY_BACKEND, &raw)?;
config.backend = Some(raw.to_ascii_lowercase());
}
}
if let Some(raw) = env_value(KEY_METRICS)? {
config.metrics_enabled = Some(parse_bool_value(KEY_METRICS, &raw)?);
}
#[cfg(not(any(feature = "serialization", feature = "full")))]
if let Some(raw) = env_value(KEY_SERIALIZATION_FORMAT)? {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_ENV_SERIALIZATION_FEATURE,
&[
("key", KEY_SERIALIZATION_FORMAT.to_string()),
("raw", format!("{raw:?}")),
],
)));
}
#[cfg(any(feature = "serialization", feature = "full"))]
if let Some(raw) = env_value(KEY_SERIALIZATION_FORMAT)? {
parse_serialization_format(KEY_SERIALIZATION_FORMAT, &raw)?;
config.serialization_format = Some(raw.to_ascii_lowercase());
}
if let Some(raw) = env_value(KEY_REDIS_URL)? {
config.redis_url = Some(raw);
}
if let Some(raw) = env_value(KEY_DISK_PATH)? {
config.disk_path = Some(raw);
}
if let Some(raw) = env_value(KEY_CONNECTION_POOL_SIZE)? {
config.connection_pool_size = Some(env_parse(KEY_CONNECTION_POOL_SIZE, &raw)?);
}
if let Some(raw) = env_value(KEY_CB_FAILURE_THRESHOLD)? {
config.circuit_breaker_failure_threshold =
Some(env_parse(KEY_CB_FAILURE_THRESHOLD, &raw)?);
}
if let Some(raw) = env_value(KEY_CB_RESET_TIMEOUT_MS)? {
config.circuit_breaker_reset_timeout = Some(Duration::from_millis(env_parse(
KEY_CB_RESET_TIMEOUT_MS,
&raw,
)?));
}
if let Some(raw) = env_value(KEY_SERVICE_NAME)? {
config.service_name = Some(raw);
}
Ok(config)
}
pub fn validate(&self) -> OxCacheResult<()> {
#[cfg(not(any(feature = "memory", feature = "redis", feature = "disk")))]
{
if let Some(raw) = self.backend.as_deref() {
let _ = raw;
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_FEATURES,
&[("raw", format!("{raw:?}"))],
)));
}
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
let parsed_backend = match self.backend.as_deref() {
None => None,
Some(raw) => Some(parse_backend_kind("backend", raw)?),
};
#[cfg(any(feature = "serialization", feature = "full"))]
{
if let Some(raw) = self.serialization_format.as_deref() {
parse_serialization_format("serialization_format", raw)?;
}
}
if let Some(capacity) = self.capacity {
if capacity == 0 {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_CAPACITY_ZERO,
&[],
)));
}
if capacity > usize::MAX as u64 {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_CAPACITY_EXCEEDS_USIZE,
&[
("capacity", capacity.to_string()),
("max", usize::MAX.to_string()),
],
)));
}
}
for (name, ttl) in [
("ttl", self.ttl),
("tti", self.tti),
("null_cache_ttl", self.null_cache_ttl),
] {
if ttl == Some(Duration::ZERO) {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_TTL_ZERO,
&[("name", name.to_string())],
)));
}
}
#[cfg(not(feature = "metrics"))]
if self.metrics_enabled.is_some() {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_METRICS_FEATURE,
&[],
)));
}
#[cfg(not(any(feature = "serialization", feature = "full")))]
if self.serialization_format.is_some() {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_SERIALIZATION_FEATURE,
&[],
)));
}
if self.circuit_breaker_failure_threshold == Some(0) {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_CB_THRESHOLD_ZERO,
&[],
)));
}
if self.service_name.as_deref() == Some("") {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_SERVICE_NAME_EMPTY,
&[],
)));
}
if self.connection_pool_size == Some(0) {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_POOL_SIZE_ZERO,
&[],
)));
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
{
use crate::backend::BackendKind;
match parsed_backend {
None => {}
Some(BackendKind::Moka) | Some(BackendKind::DashMap) => {
#[cfg(not(feature = "memory"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_REQUIRES_FEATURE,
&[("feature", "memory".to_string())],
)));
}
Some(BackendKind::Mock) => {
#[cfg(not(test))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_MOCK_TEST_ONLY,
&[],
)));
#[cfg(test)]
{
#[cfg(not(feature = "memory"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_REQUIRES_FEATURE,
&[("feature", "memory".to_string())],
)));
}
}
Some(BackendKind::Redis) => {
#[cfg(not(feature = "redis"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_KIND_REQUIRES_FEATURE,
&[("kind", "redis".to_string())],
)));
#[cfg(feature = "redis")]
self.require_non_empty("redis_url", self.redis_url.as_deref())?;
}
Some(BackendKind::Dragonfly) => {
#[cfg(not(feature = "dragonfly"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_KIND_REQUIRES_FEATURE,
&[("kind", "dragonfly".to_string())],
)));
#[cfg(feature = "dragonfly")]
self.require_non_empty("redis_url", self.redis_url.as_deref())?;
}
Some(BackendKind::Disk) => {
#[cfg(not(feature = "disk"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_KIND_REQUIRES_FEATURE,
&[("kind", "disk".to_string())],
)));
#[cfg(feature = "disk")]
self.require_non_empty("disk_path", self.disk_path.as_deref())?;
}
Some(BackendKind::Aerospike) => {
#[cfg(not(feature = "aerospike"))]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_KIND_REQUIRES_FEATURE,
&[("kind", "aerospike".to_string())],
)));
#[cfg(feature = "aerospike")]
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_AEROSPIKE_PROGRAMMATIC,
&[],
)));
}
Some(BackendKind::Valkey) => {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_VALKEY_NO_IMPL,
&[],
)));
}
Some(BackendKind::Chain) => {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_CHAIN_NEEDS_BUILDER,
&[],
)));
}
Some(kind @ BackendKind::Unknown) => {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_UNKNOWN_KIND,
&[("kind", format!("{kind:?}"))],
)));
}
}
}
Ok(())
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
pub async fn build_backend(
&self,
) -> OxCacheResult<Option<std::sync::Arc<dyn crate::backend::CacheBackend>>> {
Ok(match self.build_backend_slot().await? {
None => None,
Some(crate::cache::builder::cache_builder::BackendSlot::Async(backend)) => {
Some(backend)
}
Some(crate::cache::builder::cache_builder::BackendSlot::Sync(sync_backend)) => Some(
std::sync::Arc::new(crate::backend::SyncBackendAdapter::new(sync_backend)),
),
#[cfg(feature = "memory")]
Some(crate::cache::builder::cache_builder::BackendSlot::Dual {
async_face, ..
}) => Some(async_face),
})
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
pub(crate) async fn build_backend_slot(
&self,
) -> OxCacheResult<Option<crate::cache::builder::cache_builder::BackendSlot>> {
use crate::cache::builder::cache_builder::BackendSlot;
self.validate()?;
let backend = match self.backend.as_deref() {
None => return Ok(None),
Some(raw) => parse_backend_kind("backend", raw)?,
};
use crate::backend::BackendKind;
let slot = match backend {
BackendKind::Moka => {
#[cfg(not(feature = "memory"))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_MOKA_MEMORY, &[]));
#[cfg(feature = "memory")]
{
let mut builder = crate::backend::MokaMemoryBackend::builder()
.capacity(self.capacity.unwrap_or(10_000));
if let Some(ttl) = self.ttl {
builder = builder.ttl(ttl);
}
if let Some(tti) = self.tti {
builder = builder.time_to_idle(tti);
}
let moka: std::sync::Arc<crate::backend::MokaMemoryBackend> =
std::sync::Arc::new(builder.build());
BackendSlot::Dual {
async_face: moka.clone(),
sync_face: moka,
}
}
}
BackendKind::DashMap => {
#[cfg(not(feature = "memory"))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_DASHMAP_MEMORY, &[]));
#[cfg(feature = "memory")]
{
let mut builder = crate::backend::DashMapMemoryBackend::builder();
if let Some(capacity) = self.capacity {
builder = builder.capacity(capacity as usize);
}
if let Some(ttl) = self.ttl {
builder = builder.default_ttl(ttl);
}
let dashmap: std::sync::Arc<crate::backend::DashMapMemoryBackend> =
std::sync::Arc::new(builder.build());
BackendSlot::Dual {
async_face: dashmap.clone(),
sync_face: dashmap,
}
}
}
BackendKind::Mock => {
#[cfg(all(test, feature = "memory"))]
{
BackendSlot::Async(std::sync::Arc::new(
crate::backend::memory::MockBackend::new("cache-config-mock", 100, false),
))
}
#[cfg(not(all(test, feature = "memory")))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_MOCK_TEST_MEMORY, &[]))
}
BackendKind::Redis => {
#[cfg(not(feature = "redis"))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_REDIS_FEATURE, &[]));
#[cfg(feature = "redis")]
{
let url = self.redis_url.as_deref().unwrap_or_default();
let mut builder = crate::backend::RedisBackend::builder();
builder = builder.connection_string(url);
if let Some(pool) = self.connection_pool_size {
builder = builder.pool_size(pool);
}
if let Some(threshold) = self.circuit_breaker_failure_threshold {
builder = builder.circuit_breaker_threshold(threshold);
}
if let Some(timeout) = self.circuit_breaker_reset_timeout {
builder = builder.circuit_breaker_reset_timeout(timeout);
}
BackendSlot::Async(std::sync::Arc::new(builder.build().await?))
}
}
BackendKind::Dragonfly => {
#[cfg(not(feature = "dragonfly"))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_DRAGONFLY_FEATURE, &[]));
#[cfg(feature = "dragonfly")]
{
let url = self.redis_url.as_deref().unwrap_or_default();
let pool = self.connection_pool_size.unwrap_or(8);
BackendSlot::Async(std::sync::Arc::new(
crate::backend::DragonflyBackend::new(url, pool).await?,
))
}
}
BackendKind::Disk => {
#[cfg(not(feature = "disk"))]
unreachable!("{}", t(MSG_PANIC_CONFIG_VALIDATE_DISK_FEATURE, &[]));
#[cfg(feature = "disk")]
{
let path = self.disk_path.as_deref().unwrap_or_default();
let disk = match crate::backend::disk::RedbDiskBackend::open(path) {
Ok(disk) => disk,
Err(open_err) => crate::backend::disk::RedbDiskBackend::create(path)
.map_err(|create_err| {
OxCacheError::Operation(t(
MSG_DETAIL_DISK_OPEN_CREATE_FAILED,
&[
("open_err", open_err.to_string()),
("create_err", create_err.to_string()),
("path", path.to_string()),
],
))
})?,
};
let disk = match self.ttl {
Some(ttl) => disk.with_default_ttl(ttl),
None => disk,
};
BackendSlot::Async(std::sync::Arc::new(disk))
}
}
other => {
return Err(OxCacheError::NotSupported(t(
MSG_DETAIL_CONFIG_BACKEND_NOT_CONFIG_BUILDABLE,
&[("kind", format!("{other:?}"))],
)));
}
};
Ok(Some(slot))
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
pub async fn apply_to_cache_builder<K, V>(
&self,
mut builder: crate::cache::CacheBuilder<K, V>,
) -> OxCacheResult<crate::cache::CacheBuilder<K, V>>
where
K: crate::traits::CacheKey,
V: serde::Serialize + for<'de> serde::Deserialize<'de>,
{
self.validate()?;
if let Some(capacity) = self.capacity {
builder = builder.capacity(capacity);
}
if let Some(ttl) = self.ttl {
builder = builder.ttl(ttl);
}
if let Some(tti) = self.tti {
builder = builder.tti(tti);
}
if let Some(null_cache_ttl) = self.null_cache_ttl {
builder = builder.null_cache_ttl(null_cache_ttl);
}
if let Some(factor) = self.ttl_jitter_factor {
builder = builder.ttl_jitter(factor);
}
if let Some(sync_mode) = self.sync_mode {
builder = builder.sync_mode(sync_mode);
}
#[cfg(feature = "metrics")]
if let Some(service) = self.service_name.clone() {
builder = builder.service_name(service);
}
#[cfg(feature = "metrics")]
if self.metrics_enabled == Some(false) {
builder = builder.metrics(std::sync::Arc::new(crate::infra::NoOpMetricsRecorder));
}
#[cfg(any(feature = "serialization", feature = "full"))]
if let Some(raw) = self.serialization_format.as_deref() {
let format = parse_serialization_format("serialization_format", raw)?;
builder = builder.serialization_format(format);
}
match self.build_backend_slot().await? {
None => {}
Some(slot) => {
builder = builder.backend_slot(slot);
}
}
Ok(builder)
}
}
#[derive(Debug, Default)]
pub struct CacheConfigBuilder {
config: CacheConfig,
}
impl CacheConfigBuilder {
pub fn capacity(mut self, capacity: u64) -> Self {
self.config.capacity = Some(capacity);
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.config.ttl = Some(ttl);
self
}
pub fn tti(mut self, tti: Duration) -> Self {
self.config.tti = Some(tti);
self
}
pub fn null_cache_ttl(mut self, ttl: Duration) -> Self {
self.config.null_cache_ttl = Some(ttl);
self
}
pub fn ttl_jitter_factor(mut self, factor: f64) -> Self {
self.config.ttl_jitter_factor = Some(factor);
self
}
pub fn sync_mode(mut self, enabled: bool) -> Self {
self.config.sync_mode = Some(enabled);
self
}
pub fn backend(mut self, backend: impl Into<String>) -> Self {
self.config.backend = Some(backend.into());
self
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
pub fn backend_kind(mut self, backend: crate::backend::BackendKind) -> Self {
use crate::backend::BackendKind;
let raw = match backend {
BackendKind::Moka => "moka",
BackendKind::DashMap => "dashmap",
BackendKind::Redis => "redis",
BackendKind::Valkey => "valkey",
BackendKind::Dragonfly => "dragonfly",
BackendKind::Aerospike => "aerospike",
BackendKind::Chain => "chain",
BackendKind::Mock => "mock",
BackendKind::Disk => "disk",
BackendKind::Unknown => "unknown",
};
self.config.backend = Some(raw.to_string());
self
}
pub fn metrics_enabled(mut self, enabled: bool) -> Self {
self.config.metrics_enabled = Some(enabled);
self
}
pub fn serialization_format(mut self, format: impl Into<String>) -> Self {
self.config.serialization_format = Some(format.into());
self
}
pub fn redis_url(mut self, url: impl Into<String>) -> Self {
self.config.redis_url = Some(url.into());
self
}
pub fn disk_path(mut self, path: impl Into<String>) -> Self {
self.config.disk_path = Some(path.into());
self
}
pub fn connection_pool_size(mut self, size: usize) -> Self {
self.config.connection_pool_size = Some(size);
self
}
pub fn circuit_breaker_failure_threshold(mut self, threshold: u32) -> Self {
self.config.circuit_breaker_failure_threshold = Some(threshold);
self
}
pub fn circuit_breaker_reset_timeout(mut self, timeout: Duration) -> Self {
self.config.circuit_breaker_reset_timeout = Some(timeout);
self
}
pub fn service_name(mut self, service: impl Into<String>) -> Self {
self.config.service_name = Some(service.into());
self
}
pub fn build(self) -> CacheConfig {
self.config
}
}
fn env_value(var: &str) -> OxCacheResult<Option<String>> {
match std::env::var(var) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(raw)) => Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_ENV_NOT_UNICODE,
&[("key", var.to_string()), ("raw", format!("{raw:?}"))],
))),
}
}
fn invalid_value<E: std::fmt::Display>(var: &'static str, raw: &str) -> impl Fn(E) -> OxCacheError {
let raw = raw.to_string();
move |err| {
OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_ENV_INVALID_VALUE,
&[
("key", var.to_string()),
("raw", format!("{raw:?}")),
("err", err.to_string()),
],
))
}
}
fn env_parse<T: std::str::FromStr>(var: &'static str, raw: &str) -> OxCacheResult<T>
where
T::Err: std::fmt::Display,
{
raw.parse::<T>().map_err(invalid_value(var, raw))
}
fn parse_bool_value(var: &str, raw: &str) -> OxCacheResult<bool> {
match raw.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_ENV_INVALID_BOOL,
&[("key", var.to_string()), ("raw", format!("{raw:?}"))],
))),
}
}
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
pub(crate) fn parse_backend_kind(
var: &str,
raw: &str,
) -> OxCacheResult<crate::backend::BackendKind> {
let kind = match raw.to_ascii_lowercase().as_str() {
"moka" => crate::backend::BackendKind::Moka,
"dashmap" => crate::backend::BackendKind::DashMap,
"redis" => crate::backend::BackendKind::Redis,
"valkey" => crate::backend::BackendKind::Valkey,
"dragonfly" => crate::backend::BackendKind::Dragonfly,
"aerospike" => crate::backend::BackendKind::Aerospike,
"chain" => crate::backend::BackendKind::Chain,
"mock" => crate::backend::BackendKind::Mock,
"disk" => crate::backend::BackendKind::Disk,
_ => {
return Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_BACKEND_INVALID_VALUE,
&[("key", var.to_string()), ("raw", format!("{raw:?}"))],
)));
}
};
Ok(kind)
}
#[cfg(any(feature = "serialization", feature = "full"))]
pub(crate) fn parse_serialization_format(
var: &str,
raw: &str,
) -> OxCacheResult<crate::infra::serialization::SerializationFormat> {
use crate::infra::serialization::SerializationFormat;
match raw.to_ascii_lowercase().as_str() {
"json" => Ok(SerializationFormat::Json),
#[cfg(feature = "serde-bincode")]
"bincode" => Ok(SerializationFormat::Bincode),
#[cfg(not(feature = "serde-bincode"))]
"bincode" => Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_SERIALIZATION_BINCODE_REQUIRES_FEATURE,
&[("field", var.to_string())],
))),
#[cfg(feature = "postcard")]
"postcard" => Ok(SerializationFormat::Postcard),
#[cfg(not(feature = "postcard"))]
"postcard" => Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_SERIALIZATION_POSTCARD_REQUIRES_FEATURE,
&[("field", var.to_string())],
))),
_ => Err(OxCacheError::InvalidInput(t(
MSG_DETAIL_CONFIG_SERIALIZATION_INVALID_FORMAT,
&[("field", var.to_string()), ("raw", format!("{raw:?}"))],
))),
}
}
impl CacheConfig {
#[cfg(any(feature = "redis", feature = "disk"))]
fn require_non_empty(&self, field: &str, value: Option<&str>) -> OxCacheResult<()> {
match value {
Some(v) if !v.trim().is_empty() => Ok(()),
Some(_) => Err(OxCacheError::InvalidInput(format!(
"{field} must not be blank for the configured backend"
))),
None => Err(OxCacheError::InvalidInput(format!(
"{field} is required for the configured backend"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
const ALL_ENV_KEYS: &[&str] = &[
KEY_CAPACITY,
KEY_TTL_MS,
KEY_TTI_MS,
KEY_NULL_CACHE_TTL_MS,
KEY_TTL_JITTER_FACTOR,
KEY_SYNC_MODE,
KEY_BACKEND,
KEY_METRICS,
KEY_SERIALIZATION_FORMAT,
KEY_REDIS_URL,
KEY_DISK_PATH,
KEY_CONNECTION_POOL_SIZE,
KEY_CB_FAILURE_THRESHOLD,
KEY_CB_RESET_TIMEOUT_MS,
KEY_SERVICE_NAME,
];
#[test]
#[serial]
fn try_from_env_parses_remaining_keys() {
clear_all_env_keys();
set_env(KEY_DISK_PATH, "/tmp/ox-env-disk.redb");
set_env(KEY_REDIS_URL, "redis://127.0.0.1:6379");
set_env(KEY_CONNECTION_POOL_SIZE, "4");
set_env(KEY_CB_FAILURE_THRESHOLD, "7");
set_env(KEY_CB_RESET_TIMEOUT_MS, "2500");
let cfg = CacheConfig::try_from_env().expect("parse env");
assert_eq!(cfg.disk_path.as_deref(), Some("/tmp/ox-env-disk.redb"));
assert_eq!(cfg.redis_url.as_deref(), Some("redis://127.0.0.1:6379"));
assert_eq!(cfg.connection_pool_size, Some(4));
assert_eq!(cfg.circuit_breaker_failure_threshold, Some(7));
assert_eq!(
cfg.circuit_breaker_reset_timeout,
Some(std::time::Duration::from_millis(2500))
);
clear_all_env_keys();
}
#[test]
fn validate_rejects_capacity_beyond_usize() {
#[cfg(target_pointer_width = "32")]
{
let err = CacheConfig::builder()
.capacity(u64::MAX)
.build()
.validate()
.unwrap_err();
assert!(!err.to_string().is_empty());
}
#[cfg(target_pointer_width = "64")]
{
CacheConfig::builder()
.capacity(u64::MAX)
.build()
.validate()
.expect("u64::MAX == usize::MAX on 64-bit is a legal capacity");
}
}
#[cfg(feature = "dragonfly")]
#[tokio::test]
#[serial]
async fn build_backend_dragonfly_with_fake_endpoint() {
if !crate::test_support::ensure_server(6380) {
return;
}
let config = CacheConfig::builder()
.backend("dragonfly")
.redis_url("redis://127.0.0.1:6380")
.connection_pool_size(2)
.build();
let backend = config.build_backend().await.expect("build dragonfly");
assert!(backend.is_some());
}
#[test]
#[serial]
fn env_invalid_numeric_values_rejected() {
clear_all_env_keys();
set_env(KEY_CONNECTION_POOL_SIZE, "not-a-number");
let err = CacheConfig::try_from_env().unwrap_err();
assert!(err.to_string().contains("CONNECTION_POOL_SIZE") || !err.to_string().is_empty());
clear_all_env_keys();
set_env(KEY_CB_FAILURE_THRESHOLD, "-3");
let err = CacheConfig::try_from_env().unwrap_err();
assert!(!err.to_string().is_empty());
clear_all_env_keys();
}
#[test]
fn env_keys_share_prefix() {
for key in ALL_ENV_KEYS {
assert!(key.starts_with(ENV_PREFIX), "{key} lacks {ENV_PREFIX}");
}
}
#[allow(unsafe_code)]
fn set_env(key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
#[allow(unsafe_code)]
fn remove_env(key: &str) {
unsafe { std::env::remove_var(key) };
}
fn clear_all_env_keys() {
for key in ALL_ENV_KEYS {
remove_env(key);
}
}
#[test]
fn default_is_all_none() {
let config = CacheConfig::default();
assert_eq!(
config,
CacheConfig {
capacity: None,
ttl: None,
tti: None,
null_cache_ttl: None,
ttl_jitter_factor: None,
sync_mode: None,
backend: None,
metrics_enabled: None,
serialization_format: None,
redis_url: None,
disk_path: None,
connection_pool_size: None,
circuit_breaker_failure_threshold: None,
circuit_breaker_reset_timeout: None,
service_name: None,
}
);
}
#[test]
#[serial]
fn env_unset_yields_all_none_behavior_unchanged() {
clear_all_env_keys();
let config = CacheConfig::try_from_env().unwrap();
assert_eq!(config, CacheConfig::default());
}
#[test]
#[serial]
fn env_full_parse() {
clear_all_env_keys();
set_env("OXCACHE_CAPACITY", "2048");
set_env("OXCACHE_TTL_MS", "30000");
set_env("OXCACHE_TTI_MS", "15000");
set_env("OXCACHE_NULL_CACHE_TTL_MS", "500");
set_env("OXCACHE_TTL_JITTER_FACTOR", "0.25");
set_env("OXCACHE_SYNC_MODE", "true");
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
set_env("OXCACHE_BACKEND", "redis");
set_env("OXCACHE_METRICS", "false");
set_env(KEY_CONNECTION_POOL_SIZE, "16");
set_env(KEY_REDIS_URL, "redis://127.0.0.1:6379");
set_env("OXCACHE_CIRCUIT_BREAKER_FAILURE_THRESHOLD", "7");
set_env("OXCACHE_CIRCUIT_BREAKER_RESET_TIMEOUT_MS", "45000");
set_env("OXCACHE_SERVICE_NAME", "r9-env-svc");
#[cfg(any(feature = "serialization", feature = "full"))]
set_env("OXCACHE_SERIALIZATION_FORMAT", "json");
let config = CacheConfig::try_from_env().unwrap();
assert_eq!(config.capacity, Some(2048));
assert_eq!(config.ttl, Some(Duration::from_millis(30_000)));
assert_eq!(config.tti, Some(Duration::from_millis(15_000)));
assert_eq!(config.null_cache_ttl, Some(Duration::from_millis(500)));
assert_eq!(config.ttl_jitter_factor, Some(0.25));
assert_eq!(config.sync_mode, Some(true));
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
assert_eq!(config.backend, Some("redis".to_string()));
assert_eq!(config.metrics_enabled, Some(false));
assert_eq!(config.redis_url, Some("redis://127.0.0.1:6379".to_string()));
assert_eq!(config.circuit_breaker_failure_threshold, Some(7));
assert_eq!(
config.circuit_breaker_reset_timeout,
Some(Duration::from_millis(45_000))
);
assert_eq!(config.service_name.as_deref(), Some("r9-env-svc"));
#[cfg(any(feature = "serialization", feature = "full"))]
assert_eq!(config.serialization_format, Some("json".to_string()));
}
#[test]
#[serial]
fn env_invalid_values_fail_loudly() {
let cases: &[(&str, &str, &str)] = &[
("OXCACHE_CAPACITY", "abc", "capacity"),
("OXCACHE_TTL_MS", "-5", "ttl"),
("OXCACHE_TTL_JITTER_FACTOR", "fast", "jitter"),
("OXCACHE_SYNC_MODE", "maybe", "sync"),
("OXCACHE_BACKEND", "memcache", "backend"),
(
"OXCACHE_CIRCUIT_BREAKER_FAILURE_THRESHOLD",
"3.5",
"threshold",
),
("OXCACHE_CIRCUIT_BREAKER_RESET_TIMEOUT_MS", "soon", "reset"),
];
for (key, value, label) in cases {
clear_all_env_keys();
set_env(key, value);
let err = CacheConfig::try_from_env().expect_err(label);
let msg = match err {
OxCacheError::InvalidInput(m) => m,
other => panic!("{label}: expected InvalidInput, got {other:?}"),
};
assert!(msg.contains(key), "{label}: message lacks var name: {msg}");
assert!(
msg.contains(value),
"{label}: message lacks raw value: {msg}"
);
}
}
#[test]
#[serial]
fn env_bool_variants() {
for (raw, expected) in [
("1", true),
("TRUE", true),
("yes", true),
("On", true),
("0", false),
("false", false),
("No", false),
("off", false),
] {
clear_all_env_keys();
set_env("OXCACHE_SYNC_MODE", raw);
let config = CacheConfig::try_from_env().unwrap();
assert_eq!(config.sync_mode, Some(expected), "raw = {raw}");
}
}
#[test]
#[serial]
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
fn env_backend_case_insensitive() {
clear_all_env_keys();
set_env("OXCACHE_BACKEND", "DASHMAP");
let config = CacheConfig::try_from_env().unwrap();
assert_eq!(config.backend, Some("dashmap".to_string()));
}
#[test]
#[serial]
#[cfg(not(any(feature = "serialization", feature = "full")))]
fn env_serialization_format_requires_feature() {
clear_all_env_keys();
set_env(KEY_SERIALIZATION_FORMAT, "json");
let err = CacheConfig::try_from_env().unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("serialization")));
}
#[test]
#[serial]
#[cfg(not(any(feature = "memory", feature = "redis", feature = "disk")))]
fn env_backend_requires_backend_feature() {
clear_all_env_keys();
set_env(KEY_BACKEND, "moka");
let err = CacheConfig::try_from_env().unwrap_err();
assert!(
matches!(&err, OxCacheError::InvalidInput(m) if m.contains("feature")),
"{err:?}"
);
}
#[test]
fn validate_rejects_zero_capacity() {
let err = CacheConfig::builder()
.capacity(0)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("capacity")));
}
#[test]
fn validate_rejects_zero_ttl() {
let err = CacheConfig::builder()
.ttl(Duration::ZERO)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("ttl")));
let err = CacheConfig::builder()
.tti(Duration::ZERO)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("tti")));
let err = CacheConfig::builder()
.null_cache_ttl(Duration::ZERO)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("null_cache_ttl")));
}
#[test]
fn validate_rejects_zero_circuit_breaker_threshold() {
let err = CacheConfig::builder()
.circuit_breaker_failure_threshold(0)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("circuit")));
}
#[test]
fn validate_rejects_zero_pool_size() {
let err = CacheConfig::builder()
.connection_pool_size(0)
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("connection_pool_size")));
}
#[test]
fn validate_redis_backend_requires_url() {
#[cfg(not(feature = "redis"))]
{
let err = CacheConfig::builder()
.backend("redis")
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("feature")));
}
#[cfg(feature = "redis")]
let err = CacheConfig::builder()
.backend("redis")
.build()
.validate()
.unwrap_err();
#[cfg(feature = "redis")]
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("redis_url")));
#[cfg(feature = "redis")]
{
let err = CacheConfig::builder()
.backend("redis")
.redis_url(" ")
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("redis_url")));
}
}
#[test]
fn validate_disk_backend_requires_path() {
#[cfg(feature = "disk")]
{
let err = CacheConfig::builder()
.backend("disk")
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("disk_path")));
}
#[cfg(not(feature = "disk"))]
{
let err = CacheConfig::builder()
.backend("disk")
.disk_path("/tmp/oxcache.redb")
.build()
.validate()
.unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("feature")));
}
}
#[test]
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
fn validate_sync_mode_combines_with_backend() {
for raw in ["moka", "dashmap"] {
CacheConfig::builder()
.sync_mode(true)
.backend(raw)
.build()
.validate()
.unwrap_or_else(|e| panic!("{raw}: {e}"));
}
#[cfg(feature = "redis")]
CacheConfig::builder()
.sync_mode(true)
.backend("redis")
.redis_url("redis://127.0.0.1:6379")
.build()
.validate()
.unwrap();
CacheConfig::builder()
.sync_mode(true)
.build()
.validate()
.unwrap();
}
#[test]
fn validate_rejects_empty_service_name() {
let err = CacheConfig::builder()
.service_name("")
.build()
.validate()
.unwrap_err();
assert!(
matches!(&err, OxCacheError::InvalidInput(m) if m.contains("service_name")),
"{err:?}"
);
CacheConfig::builder()
.service_name("orders")
.build()
.validate()
.unwrap();
}
#[test]
#[cfg(feature = "memory")]
fn apply_sync_mode_without_backend_builds_sync_cache() {
use crate::cache::CacheBuilder;
let config = CacheConfig::builder().capacity(32).sync_mode(true).build();
let builder = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
config
.apply_to_cache_builder(CacheBuilder::<String, String>::default())
.await
.unwrap()
});
let cache = builder.build_sync().unwrap();
cache.set_sync(&"k".to_string(), &"v".to_string()).unwrap();
assert_eq!(
cache.get_sync(&"k".to_string()).unwrap(),
Some("v".to_string())
);
}
#[test]
#[cfg(feature = "memory")]
fn apply_sync_mode_with_backend_uses_native_sync_surface() {
use crate::cache::CacheBuilder;
let config = CacheConfig::builder()
.capacity(32)
.sync_mode(true)
.backend("moka")
.build();
let builder = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.build()
.unwrap()
.block_on(async {
config
.apply_to_cache_builder(CacheBuilder::<String, String>::default())
.await
.unwrap()
});
let cache = builder.build_sync().unwrap();
cache
.set_sync(&"cfg-native".to_string(), &"v1".to_string())
.unwrap();
assert_eq!(
cache.get_sync(&"cfg-native".to_string()).unwrap(),
Some("v1".to_string())
);
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
cache
.set(&"cfg-native".to_string(), &"v2".to_string())
.await
.unwrap();
assert_eq!(
cache.get(&"cfg-native".to_string()).await.unwrap(),
Some("v2".to_string())
);
});
}
#[test]
#[cfg(all(feature = "metrics", feature = "memory"))]
fn apply_metrics_disabled_builds_functional_cache() {
use crate::cache::CacheBuilder;
let config = CacheConfig::builder()
.metrics_enabled(false)
.sync_mode(true)
.build();
let builder = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
config
.apply_to_cache_builder(CacheBuilder::<String, String>::default())
.await
.unwrap()
});
let debug = format!("{builder:?}");
assert!(debug.contains("metrics_injected: true"), "{debug}");
let cache = builder.build_sync().unwrap();
cache.set_sync(&"m".to_string(), &"n".to_string()).unwrap();
assert_eq!(
cache.get_sync(&"m".to_string()).unwrap(),
Some("n".to_string())
);
assert!(
!format!("{:?}", CacheBuilder::<String, String>::default())
.contains("metrics_injected: true")
);
}
#[test]
fn validate_rejects_unbuildable_backends() {
for raw in ["valkey", "chain", "unknown"] {
let config = CacheConfig::builder().backend(raw).build();
let err = config.validate().unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(_)), "raw = {raw}");
}
}
#[test]
#[cfg(feature = "memory")]
fn validate_accepts_memory_backends_without_extra_config() {
for raw in ["moka", "dashmap", "mock"] {
let config = CacheConfig::builder().backend(raw).build();
config.validate().unwrap_or_else(|e| panic!("{raw}: {e}"));
}
}
#[tokio::test]
#[cfg(feature = "memory")]
async fn build_backend_moka_applies_capacity_and_ttl() {
use std::sync::Arc;
let config = CacheConfig::builder()
.capacity(123)
.ttl(Duration::from_millis(45_000))
.backend_kind(crate::backend::BackendKind::Moka)
.build();
config.validate().unwrap();
let backend = config.build_backend().await.unwrap().unwrap();
let key: Arc<str> = Arc::from("config:probe");
backend
.set(key.clone(), Arc::new(b"v".to_vec()), None)
.await
.unwrap();
assert_eq!(backend.get(&key).await.unwrap().as_deref(), Some(&b"v"[..]));
}
#[test]
#[cfg(feature = "memory")]
fn validate_feature_gated_backend_rejected_when_missing() {
#[cfg(not(feature = "redis"))]
{
let config = CacheConfig::builder()
.backend("redis")
.redis_url("redis://127.0.0.1:6379")
.build();
let err = config.validate().unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("feature")));
}
#[cfg(feature = "redis")]
{
CacheConfig::builder()
.backend("redis")
.redis_url("redis://127.0.0.1:6379")
.build()
.validate()
.unwrap();
}
}
#[tokio::test]
#[cfg(feature = "memory")]
async fn apply_to_cache_builder_lands_all_fields() {
use crate::cache::CacheBuilder;
use std::marker::PhantomData;
let config = CacheConfig::builder()
.capacity(4096)
.ttl(Duration::from_millis(60_000))
.ttl_jitter_factor(0.2)
.null_cache_ttl(Duration::from_millis(300))
.sync_mode(false)
.build();
let builder = CacheBuilder::<String, String>::default();
let builder = config.apply_to_cache_builder(builder).await.unwrap();
let debug = format!("{:?}", builder);
assert!(debug.contains("capacity: Some(4096)"), "{debug}");
assert!(
debug.contains("ttl: Some(60s)"),
"ttl debug mismatch: {debug}"
);
assert!(debug.contains("ttl_jitter_factor: 0.2"), "{debug}");
assert!(debug.contains("null_cache_ttl: Some(300ms)"), "{debug}");
let _ = PhantomData::<String>;
}
#[tokio::test]
#[cfg(feature = "memory")]
async fn apply_with_backend_injects_backend_arc() {
use crate::cache::CacheBuilder;
let config = CacheConfig::builder().capacity(64).backend("moka").build();
let builder = CacheBuilder::<String, String>::default();
let builder = config.apply_to_cache_builder(builder).await.unwrap();
let debug = format!("{:?}", builder);
assert!(debug.contains("backends_count: 1"), "{debug}");
}
#[tokio::test]
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
async fn build_backend_unset_returns_none() {
let config = CacheConfig::default();
config.validate().unwrap();
assert!(config.build_backend().await.unwrap().is_none());
}
#[tokio::test]
#[cfg(any(feature = "memory", feature = "redis", feature = "disk"))]
async fn build_backend_chain_rejected() {
let config = CacheConfig::builder().backend("chain").build();
match config.build_backend().await {
Ok(_) => panic!("Chain must not build a backend"),
Err(OxCacheError::InvalidInput(msg)) => assert!(
msg.to_ascii_lowercase().contains("chain"),
"message should name the backend: {msg}"
),
Err(other) => panic!("expected InvalidInput from validate, got {other:?}"),
}
}
#[test]
fn debug_redacts_redis_url() {
let config = CacheConfig::builder()
.redis_url("redis://admin:s3cret@host:6379") .build();
let debug = format!("{config:?}");
assert!(!debug.contains("s3cret"), "credentials leaked: {debug}");
assert!(
debug.contains("\"***\""),
"redaction marker missing: {debug}"
);
let absent = format!("{:?}", CacheConfig::default());
assert!(absent.contains("redis_url: None"), "{absent}");
}
#[test]
fn validate_capacity_beyond_usize_rejected() {
let config = CacheConfig::builder().capacity(u64::MAX).build();
if u64::MAX > usize::MAX as u64 {
assert!(config.validate().is_err());
} else {
config.validate().unwrap();
}
}
#[test]
fn validate_metrics_gating_matches_feature() {
let config = CacheConfig::builder().metrics_enabled(false).build();
#[cfg(not(feature = "metrics"))]
{
let err = config.validate().unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("metrics")));
}
#[cfg(feature = "metrics")]
config.validate().unwrap();
}
#[test]
#[cfg(not(any(feature = "serialization", feature = "full")))]
fn validate_serialization_gating() {
let config = CacheConfig::builder().serialization_format("json").build();
let err = config.validate().unwrap_err();
assert!(matches!(err, OxCacheError::InvalidInput(m) if m.contains("serialization")));
}
#[test]
fn builder_chain_sets_all_fields() {
let config = CacheConfig::builder()
.capacity(100)
.ttl(Duration::from_secs(1))
.tti(Duration::from_secs(2))
.null_cache_ttl(Duration::from_secs(3))
.ttl_jitter_factor(0.5)
.sync_mode(true)
.backend("moka")
.metrics_enabled(true)
.redis_url("redis://localhost")
.disk_path("/tmp/oxcache.redb")
.circuit_breaker_failure_threshold(9)
.circuit_breaker_reset_timeout(Duration::from_secs(10))
.build();
assert_eq!(config.capacity, Some(100));
assert_eq!(config.ttl, Some(Duration::from_secs(1)));
assert_eq!(config.tti, Some(Duration::from_secs(2)));
assert_eq!(config.null_cache_ttl, Some(Duration::from_secs(3)));
assert_eq!(config.ttl_jitter_factor, Some(0.5));
assert_eq!(config.sync_mode, Some(true));
assert_eq!(config.backend, Some("moka".to_string()));
assert_eq!(config.metrics_enabled, Some(true));
assert_eq!(config.redis_url, Some("redis://localhost".to_string()));
assert_eq!(config.disk_path, Some("/tmp/oxcache.redb".to_string()));
assert_eq!(config.circuit_breaker_failure_threshold, Some(9));
assert_eq!(
config.circuit_breaker_reset_timeout,
Some(Duration::from_secs(10))
);
}
#[test]
#[cfg(all(
any(feature = "serialization", feature = "full"),
not(feature = "serde-bincode")
))]
fn parse_serialization_format_rejects_bincode_without_feature() {
let err = parse_serialization_format("serialization_format", "bincode").unwrap_err();
assert!(
matches!(err, OxCacheError::InvalidInput(ref m) if m.contains("bincode")),
"bincode without the serde-bincode feature must be rejected: {err:?}"
);
}
#[test]
#[cfg(all(
any(feature = "serialization", feature = "full"),
not(feature = "postcard")
))]
fn parse_serialization_format_rejects_postcard_without_feature() {
let err = parse_serialization_format("serialization_format", "postcard").unwrap_err();
assert!(
matches!(err, OxCacheError::InvalidInput(ref m) if m.contains("postcard")),
"postcard without the postcard feature must be rejected: {err:?}"
);
}
}