use async_trait::async_trait;
use dashmap::DashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use super::driver::FeatureDriver;
use super::resolvers::{calculate_hash_bucket, parse_variants, resolve_variant};
static DB_FEATURE_CACHE_EPOCH: AtomicU64 = AtomicU64::new(0);
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const MAX_CACHED_FLAGS: usize = 4_096;
type FlagRow = (bool, Option<u32>, Option<String>);
struct DbCacheValue {
flag: Option<FlagRow>,
expires_at: Instant,
epoch: u64,
}
enum Lookup {
Unavailable,
Found(FlagRow),
Missing,
Failed,
}
#[non_exhaustive]
pub struct DbFeatureDriver {
cache: DashMap<String, DbCacheValue>,
ttl: Duration,
}
impl DbFeatureDriver {
pub fn new() -> Self {
Self {
cache: DashMap::new(),
ttl: Duration::from_secs(5),
}
}
#[cfg_attr(mutants, mutants::skip)]
pub fn with_ttl(ttl: Duration) -> Self {
Self {
cache: DashMap::new(),
ttl,
}
}
pub fn invalidate_process_cache() {
DB_FEATURE_CACHE_EPOCH.fetch_add(1, Ordering::AcqRel);
}
#[cfg_attr(mutants, mutants::skip)]
async fn fetch_flag_from_db(&self, flag: &str) -> Lookup {
use sqlx::Row;
let Some(pool) = crate::db::safe_pool() else {
return Lookup::Unavailable;
};
let sql = if crate::db::safe_driver() == Some("postgres") {
"SELECT enabled, rollout_percentage, variants FROM rullst_feature_flags WHERE name = $1"
} else {
"SELECT enabled, rollout_percentage, variants FROM rullst_feature_flags WHERE name = ?"
};
let query = sqlx::query(sql).bind(flag).fetch_optional(pool);
let row = match tokio::time::timeout(LOOKUP_TIMEOUT, query).await {
Ok(Ok(Some(row))) => row,
Ok(Ok(None)) => return Lookup::Missing,
Ok(Err(_)) | Err(_) => return Lookup::Failed,
};
let enabled = row
.try_get::<i32, _>("enabled")
.map(|v| v != 0)
.or_else(|_| row.try_get::<bool, _>("enabled"))
.unwrap_or(false);
let rollout_percentage = row
.try_get::<i32, _>("rollout_percentage")
.map(|v| Some(v as u32))
.unwrap_or(None);
let variants = row.try_get::<String, _>("variants").ok();
Lookup::Found((enabled, rollout_percentage, variants))
}
#[cfg_attr(mutants, mutants::skip)]
async fn resolve_flag(&self, flag: &str) -> Option<FlagRow> {
self.resolve_with(flag, || self.fetch_flag_from_db(flag))
.await
}
async fn resolve_with<F, Fut>(&self, flag: &str, lookup: F) -> Option<FlagRow>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Lookup>,
{
let epoch = DB_FEATURE_CACHE_EPOCH.load(Ordering::Acquire);
let previous = self.cache.get(flag).map(|entry| {
let fresh = Instant::now() < entry.expires_at && entry.epoch == epoch;
(entry.flag.clone(), fresh)
});
if let Some((cached, true)) = previous {
return cached;
}
let resolved = match lookup().await {
Lookup::Unavailable => return None,
Lookup::Found(row) => Some(row),
Lookup::Missing => None,
Lookup::Failed => previous.and_then(|(cached, _)| cached),
};
self.remember(flag, resolved.clone(), epoch);
resolved
}
fn remember(&self, flag: &str, value: Option<FlagRow>, epoch: u64) {
let now = Instant::now();
if !self.cache.contains_key(flag) && self.cache.len() >= MAX_CACHED_FLAGS {
self.cache
.retain(|_, entry| now < entry.expires_at && entry.epoch == epoch);
if self.cache.len() >= MAX_CACHED_FLAGS {
return;
}
}
self.cache.insert(
flag.to_string(),
DbCacheValue {
flag: value,
expires_at: now + self.ttl,
epoch,
},
);
}
#[cfg_attr(mutants, mutants::skip)]
fn evaluate(
&self,
enabled: bool,
rollout: Option<u32>,
variants: Option<String>,
flag: &str,
identifier: Option<&str>,
) -> Option<String> {
if !enabled {
return Some("disabled".to_string());
}
if let Some(vars_str) = variants {
let vars = parse_variants(&vars_str);
if !vars.is_empty()
&& let Some(ident) = identifier
{
let bucket = calculate_hash_bucket(flag, ident);
return resolve_variant(&vars, bucket);
}
}
if let Some(pct) = rollout {
if let Some(ident) = identifier {
let bucket = calculate_hash_bucket(flag, ident);
return Some(if bucket < pct {
"enabled".to_string()
} else {
"disabled".to_string()
});
}
return Some("disabled".to_string());
}
Some(if enabled {
"enabled".to_string()
} else {
"disabled".to_string()
})
}
}
impl Default for DbFeatureDriver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl FeatureDriver for DbFeatureDriver {
#[cfg_attr(mutants, mutants::skip)]
async fn enabled(&self, flag: &str) -> Option<bool> {
let (enabled, rollout, variants) = self.resolve_flag(flag).await?;
let evaluated = self.evaluate(enabled, rollout, variants, flag, None)?;
Some(evaluated == "enabled")
}
#[cfg_attr(mutants, mutants::skip)]
async fn enabled_for(&self, flag: &str, identifier: &str) -> Option<bool> {
let (enabled, rollout, variants) = self.resolve_flag(flag).await?;
let evaluated = self.evaluate(enabled, rollout, variants, flag, Some(identifier))?;
Some(evaluated == "enabled")
}
#[cfg_attr(mutants, mutants::skip)]
async fn variant(&self, flag: &str, identifier: &str) -> Option<String> {
let (enabled, rollout, variants) = self.resolve_flag(flag).await?;
self.evaluate(enabled, rollout, variants, flag, Some(identifier))
}
}
#[cfg(test)]
#[path = "db_tests.rs"]
mod tests;