use std::sync::Arc;
use crate::error::CacheError;
use crate::tier::hot_rung::HotRungTier;
use crate::tier::tier_trait::CacheTier;
pub const ENV_THESIX_REDIS_URL: &str = "THESIX_REDIS_URL";
pub const ENV_THESIX_VALKEY_URL: &str = "THESIX_VALKEY_URL";
pub const ENV_REDIS_URL: &str = "REDIS_URL";
pub const ENV_VALKEY_URL: &str = "VALKEY_URL";
pub trait EnvSource: Send + Sync {
fn lookup(&self, name: &str) -> Option<String>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessEnv;
impl ProcessEnv {
#[must_use]
pub fn load_dotenv() -> Self {
#[cfg(feature = "dotenv")]
{
let _ = dotenvy::dotenv();
}
ProcessEnv
}
}
impl EnvSource for ProcessEnv {
fn lookup(&self, name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
}
#[derive(Debug, Clone, Default)]
pub struct MapEnv(std::collections::BTreeMap<String, String>);
impl MapEnv {
#[must_use]
pub fn new() -> Self {
MapEnv(std::collections::BTreeMap::new())
}
#[must_use]
pub fn with(mut self, name: &str, value: &str) -> Self {
self.0.insert(name.to_string(), value.to_string());
self
}
}
impl EnvSource for MapEnv {
fn lookup(&self, name: &str) -> Option<String> {
self.0
.get(name)
.cloned()
.filter(|value| !value.trim().is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedUrl {
pub normalized: String,
pub valkey: bool,
}
pub fn detect_redis_endpoint(env: &dyn EnvSource) -> Result<Option<DetectedUrl>, CacheError> {
let candidates: [(&str, bool); 4] = [
(ENV_THESIX_REDIS_URL, false),
(ENV_THESIX_VALKEY_URL, true),
(ENV_REDIS_URL, false),
(ENV_VALKEY_URL, true),
];
for (name, valkey_by_name) in candidates {
if let Some(raw) = env.lookup(name) {
return normalize_url(&raw, valkey_by_name).map(Some);
}
}
Ok(None)
}
fn normalize_url(raw: &str, valkey_by_name: bool) -> Result<DetectedUrl, CacheError> {
let (normalized, valkey_by_scheme) = if let Some(rest) = raw.strip_prefix("valkey://") {
(format!("redis://{rest}"), true)
} else if let Some(rest) = raw.strip_prefix("valkeys://") {
(format!("rediss://{rest}"), true)
} else if raw.starts_with("redis://") || raw.starts_with("rediss://") {
(raw.to_string(), false)
} else {
return Err(CacheError::ConfigurationError);
};
Ok(DetectedUrl {
normalized,
valkey: valkey_by_scheme || valkey_by_name,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum L3Binding {
NotConfigured,
Redis { url: String, valkey: bool },
RedisDegradedToMemory {
url: String,
valkey: bool,
reason: String,
},
}
pub struct Ladder<V> {
pub tiers: Vec<Arc<dyn CacheTier<V>>>,
pub hot_rungs: Vec<Arc<HotRungTier<V>>>,
pub rebinder: Option<crate::governor::Rebinder<V>>,
pub report: BindingReport,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BindingReport {
pub l3: Option<L3Binding>,
}
pub fn assemble_ladder<V>(env: &dyn EnvSource) -> Result<Ladder<V>, CacheError>
where
V: crate::tier::backends::ByteValue + std::hash::Hash,
{
let endpoint = detect_redis_endpoint(env)?;
let tiers: Vec<Arc<dyn CacheTier<V>>> = vec![
Arc::new(crate::tier::l0::L0Stub::new()),
Arc::new(crate::tier::l1::L1Stub::new()),
Arc::new(crate::tier::l2::L2Stub::new()),
Arc::new(crate::tier::l3::L3Stub::new()),
Arc::new(crate::tier::l4::L4Stub::new()),
Arc::new(crate::tier::l5::L5Stub::new()),
];
let (tiers, hot_rungs, rebinder, l3_binding) = match endpoint {
None => (tiers, Vec::new(), None, L3Binding::NotConfigured),
Some(ref detected) => {
let binding = bind_l3::<V>(detected, tiers)?;
let tiers = binding.tiers;
(tiers, binding.hot_rungs, binding.rebinder, binding.binding)
}
};
Ok(Ladder {
tiers,
hot_rungs,
rebinder,
report: BindingReport {
l3: Some(l3_binding),
},
})
}
struct L3BindingOutcome<V> {
tiers: Vec<Arc<dyn CacheTier<V>>>,
hot_rungs: Vec<Arc<HotRungTier<V>>>,
rebinder: Option<crate::governor::Rebinder<V>>,
binding: L3Binding,
}
#[cfg(feature = "redis")]
fn bind_l3<V>(
detected: &DetectedUrl,
tiers: Vec<Arc<dyn CacheTier<V>>>,
) -> Result<L3BindingOutcome<V>, CacheError>
where
V: crate::tier::backends::ByteValue + std::hash::Hash,
{
let mut tiers = tiers;
let mut hot_rungs = Vec::new();
if let Ok(backend) = crate::tier::backends::L3RedisBackend::<V>::connect(&detected.normalized) {
let hot = Arc::new(HotRungTier::new(
crate::tier::TierId::L3,
Arc::new(backend) as Arc<dyn CacheTier<V>>,
)?);
tiers[3] = Arc::clone(&hot) as Arc<dyn CacheTier<V>>;
hot_rungs.push(hot);
Ok(L3BindingOutcome {
tiers,
hot_rungs,
rebinder: Some(redis_rebinder(detected.clone())),
binding: L3Binding::Redis {
url: detected.normalized.clone(),
valkey: detected.valkey,
},
})
} else {
let hot = Arc::new(HotRungTier::new(
crate::tier::TierId::L3,
Arc::clone(&tiers[3]) as Arc<dyn CacheTier<V>>,
)?);
hot_rungs.push(hot);
Ok(L3BindingOutcome {
tiers,
hot_rungs,
rebinder: Some(redis_rebinder(detected.clone())),
binding: L3Binding::RedisDegradedToMemory {
url: detected.normalized.clone(),
valkey: detected.valkey,
reason: "the redis server did not accept a connection".to_string(),
},
})
}
}
#[cfg(not(feature = "redis"))]
fn bind_l3<V>(
detected: &DetectedUrl,
tiers: Vec<Arc<dyn CacheTier<V>>>,
) -> Result<L3BindingOutcome<V>, CacheError> {
Ok(L3BindingOutcome {
tiers,
hot_rungs: Vec::new(),
rebinder: None,
binding: L3Binding::RedisDegradedToMemory {
url: detected.normalized.clone(),
valkey: detected.valkey,
reason: "the redis feature is not compiled in".to_string(),
},
})
}
#[cfg(feature = "redis")]
fn redis_rebinder<V>(endpoint: DetectedUrl) -> crate::governor::Rebinder<V>
where
V: crate::tier::backends::ByteValue + std::hash::Hash,
{
Arc::new(move |rung| {
if rung != crate::tier::TierId::L3 {
return Ok(None);
}
match crate::tier::backends::L3RedisBackend::<V>::connect(&endpoint.normalized) {
Ok(backend) => Ok(Some(Arc::new(backend) as Arc<dyn CacheTier<V>>)),
Err(e) => Err(e),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn empty() -> MapEnv {
MapEnv::new()
}
#[test]
fn a_valkey_url_normalizes_to_what_the_client_parses() {
testkit::proves!("autonomy.bootstrap.valkey_scheme_is_normalized");
let env = empty().with(ENV_THESIX_VALKEY_URL, "valkey://valkey.local:6379/0");
let detected = detect_redis_endpoint(&env)
.expect("detect")
.expect("present");
assert_eq!(detected.normalized, "redis://valkey.local:6379/0");
assert!(detected.valkey);
let tls = empty().with(ENV_VALKEY_URL, "valkeys://valkey.local:6380");
let detected = detect_redis_endpoint(&tls)
.expect("detect")
.expect("present");
assert_eq!(detected.normalized, "rediss://valkey.local:6380");
assert!(detected.valkey);
}
#[test]
fn a_redis_url_passes_through_unmarked() {
let env = empty().with(ENV_REDIS_URL, "redis://127.0.0.1:6379");
let detected = detect_redis_endpoint(&env)
.expect("detect")
.expect("present");
assert_eq!(detected.normalized, "redis://127.0.0.1:6379");
assert!(!detected.valkey);
}
#[test]
fn the_scoped_spelling_beats_the_well_known_one() {
let env = empty()
.with(ENV_REDIS_URL, "redis://stale.example:6379")
.with(ENV_THESIX_VALKEY_URL, "valkey://fresh.example:6379");
let detected = detect_redis_endpoint(&env)
.expect("detect")
.expect("present");
assert_eq!(
detected.normalized, "redis://fresh.example:6379",
"the six-scoped variable outranks the well-known one"
);
assert!(detected.valkey);
}
#[test]
fn a_missing_url_binds_nothing_and_is_not_an_error() {
testkit::proves!("autonomy.bootstrap.absent_url_binds_no_redis_backend");
let detected = detect_redis_endpoint(&empty()).expect("detect");
assert_eq!(detected, None);
}
#[test]
fn an_empty_value_counts_as_absent() {
let env = empty().with(ENV_REDIS_URL, " ");
let detected = detect_redis_endpoint(&env).expect("detect");
assert_eq!(detected, None, "an empty URL must not reach the parser");
}
#[test]
fn a_non_redis_url_is_a_configuration_error() {
testkit::proves!("autonomy.bootstrap.invalid_url_is_a_configuration_error");
let env = empty().with(ENV_REDIS_URL, "postgres://127.0.0.1/db");
assert_eq!(
detect_redis_endpoint(&env).unwrap_err(),
CacheError::ConfigurationError
);
}
#[test]
fn an_absent_endpoint_reports_not_configured_with_no_rebinder() {
testkit::proves!("autonomy.bootstrap.absent_url_binds_no_redis_backend");
let ladder = assemble_ladder::<Vec<u8>>(&empty()).expect("assemble");
assert_eq!(ladder.tiers.len(), 6);
assert!(ladder.hot_rungs.is_empty());
assert!(ladder.rebinder.is_none());
assert_eq!(ladder.report.l3, Some(L3Binding::NotConfigured));
assert_eq!(
ladder.tiers[3].backend(),
crate::tier::BackendKind::InMemoryFallback
);
}
#[cfg(feature = "redis")]
#[test]
fn an_unreachable_server_degrades_loudly_and_rebindably() {
testkit::proves!("autonomy.bootstrap.degraded_binding_is_reported_loudly");
let env = empty().with(ENV_REDIS_URL, "redis://127.0.0.1:1/");
let ladder = assemble_ladder::<Vec<u8>>(&env).expect("assemble");
let degraded = match ladder.report.l3 {
Some(L3Binding::RedisDegradedToMemory {
url,
valkey,
reason,
}) => Some((url, valkey, reason)),
_ => None,
};
let (url, valkey, reason) = degraded.expect("a loudly degraded binding");
assert_eq!(url, "redis://127.0.0.1:1/");
assert!(!valkey);
assert!(
!reason.contains("redis://"),
"the reason must not echo the URL: credentials live there"
);
assert_eq!(ladder.hot_rungs.len(), 1);
assert_eq!(ladder.hot_rungs[0].rung(), crate::tier::TierId::L3);
assert!(ladder.rebinder.is_some());
assert_eq!(
ladder.tiers[3].backend(),
crate::tier::BackendKind::InMemoryFallback
);
}
#[cfg(feature = "redis")]
#[tokio::test]
async fn the_rebinder_refuses_every_rung_that_is_not_its_own() {
let env = empty().with(ENV_REDIS_URL, "redis://127.0.0.1:1/");
let ladder = assemble_ladder::<Vec<u8>>(&env).expect("assemble");
let rebinder = ladder.rebinder.expect("degraded ladder carries a rebinder");
assert!(
rebinder(crate::tier::TierId::L4)
.expect("declines cleanly")
.is_none()
);
assert!(
rebinder(crate::tier::TierId::L6)
.expect("declines cleanly")
.is_none()
);
}
}