#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagChangeRecord {
pub key: String,
pub mutation: String,
pub actor: Option<String>,
pub timestamp_secs: u64,
}
impl FlagChangeRecord {
fn now(key: &str, mutation: impl Into<String>, actor: Option<&str>) -> Self {
let timestamp_secs = crate::time::clock_unix_secs(&crate::time::AmbientClock);
Self {
key: key.to_owned(),
mutation: mutation.into(),
actor: actor.map(str::to_owned),
timestamp_secs,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlagConfig {
pub key: String,
pub description: Option<String>,
pub enabled: bool,
pub rollout_pct: u8,
pub actor_allowlist: Vec<String>,
pub group_allowlist: Vec<String>,
}
impl FlagConfig {
#[must_use]
pub fn new(key: impl Into<String>) -> Self {
Self {
key: key.into(),
description: None,
enabled: false,
rollout_pct: 0,
actor_allowlist: Vec::new(),
group_allowlist: Vec::new(),
}
}
}
pub type GroupResolver = Arc<dyn Fn(&str, &str) -> bool + Send + Sync + 'static>;
#[derive(Debug, thiserror::Error)]
pub enum FlagStoreError {
#[error("flag store backend error: {0}")]
Backend(String),
}
pub trait FlagStore: Send + Sync + 'static {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError>;
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError>;
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError>;
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError>;
fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError>;
fn preload(&self) -> Result<(), FlagStoreError> {
Ok(())
}
}
impl FlagStore for Box<dyn FlagStore> {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
(**self).get(key)
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
(**self).list()
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).enable(key, actor)
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).disable(key, actor)
}
fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).set_rollout(key, pct, actor)
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
(**self).allow_actor(key, actor_id, actor)
}
fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).add_group(key, group, actor)
}
fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
(**self).history(key, limit)
}
fn preload(&self) -> Result<(), FlagStoreError> {
(**self).preload()
}
}
impl<T: FlagStore + ?Sized> FlagStore for Arc<T> {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
(**self).get(key)
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
(**self).list()
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).enable(key, actor)
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).disable(key, actor)
}
fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).set_rollout(key, pct, actor)
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
(**self).allow_actor(key, actor_id, actor)
}
fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
(**self).add_group(key, group, actor)
}
fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
(**self).history(key, limit)
}
fn preload(&self) -> Result<(), FlagStoreError> {
(**self).preload()
}
}
#[derive(Debug, Default)]
pub struct InMemoryFlagStore {
flags: RwLock<HashMap<String, FlagConfig>>,
history: RwLock<HashMap<String, Vec<FlagChangeRecord>>>,
}
impl InMemoryFlagStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn upsert(&self, key: &str, f: impl FnOnce(&mut FlagConfig)) {
let mut flags = self.flags.write().unwrap();
let flag = flags
.entry(key.to_owned())
.or_insert_with(|| FlagConfig::new(key));
f(flag);
drop(flags);
}
fn record(&self, record: FlagChangeRecord) {
self.history
.write()
.unwrap()
.entry(record.key.clone())
.or_default()
.push(record);
}
}
impl FlagStore for InMemoryFlagStore {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
Ok(self.flags.read().unwrap().get(key).cloned())
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
let mut flags: Vec<FlagConfig> = self.flags.read().unwrap().values().cloned().collect();
flags.sort_by(|a, b| a.key.cmp(&b.key));
Ok(flags)
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.upsert(key, |f| {
f.enabled = true;
f.rollout_pct = 100;
});
self.record(FlagChangeRecord::now(key, "enabled", actor));
Ok(())
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.upsert(key, |f| {
f.enabled = false;
});
self.record(FlagChangeRecord::now(key, "disabled", actor));
Ok(())
}
fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
let pct = pct.min(100);
self.upsert(key, |f| {
f.enabled = true;
f.rollout_pct = pct;
});
self.record(FlagChangeRecord::now(key, format!("rollout={pct}"), actor));
Ok(())
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.upsert(key, |f| {
if !f.enabled {
f.rollout_pct = 0;
}
f.enabled = true;
if !f.actor_allowlist.contains(&actor_id.to_owned()) {
f.actor_allowlist.push(actor_id.to_owned());
}
});
self.record(FlagChangeRecord::now(
key,
format!("allowed_actor={actor_id}"),
actor,
));
Ok(())
}
fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.upsert(key, |f| {
if !f.enabled {
f.rollout_pct = 0;
}
f.enabled = true;
if !f.group_allowlist.contains(&group.to_owned()) {
f.group_allowlist.push(group.to_owned());
}
});
self.record(FlagChangeRecord::now(
key,
format!("added_group={group}"),
actor,
));
Ok(())
}
fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
Ok(self
.history
.read()
.unwrap()
.get(key)
.map(|records| records.iter().rev().take(limit).cloned().collect())
.unwrap_or_default())
}
}
#[cfg(feature = "postgres")]
pub mod pg {
use super::{FlagChangeRecord, FlagConfig, FlagStore, FlagStoreError};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use std::collections::HashMap;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use std::time::{Duration, Instant};
const FLAG_COLUMNS: &str =
"key, description, enabled, rollout_pct, actor_allowlist, group_allowlist";
const CONNECT_TIMEOUT_SECS: u64 = 5;
pub(crate) const STATEMENT_TIMEOUT_MS: u64 = 5_000;
const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
pub(crate) fn with_connect_timeout(url: &str) -> String {
if has_connect_timeout(url) {
url.to_owned()
} else if url.starts_with("postgres://") || url.starts_with("postgresql://") {
let separator = if url.contains('?') { '&' } else { '?' };
format!("{url}{separator}connect_timeout={CONNECT_TIMEOUT_SECS}")
} else {
format!("{url} connect_timeout={CONNECT_TIMEOUT_SECS}")
}
}
fn has_connect_timeout(url: &str) -> bool {
if url.starts_with("postgres://") || url.starts_with("postgresql://") {
let query = url.split_once('?').map_or("", |(_, query)| query);
let query = query.split_once('#').map_or(query, |(query, _)| query);
return query
.split('&')
.any(|pair| pair.split('=').next() == Some("connect_timeout"));
}
conninfo_keys(url)
.iter()
.any(|key| key == "connect_timeout")
}
fn conninfo_keys(conninfo: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut chars = conninfo.chars().peekable();
loop {
while chars.next_if(|c| c.is_whitespace()).is_some() {}
let mut key = String::new();
while let Some(c) = chars.next_if(|c| *c != '=' && !c.is_whitespace()) {
key.push(c);
}
if key.is_empty() {
return keys;
}
while chars.next_if(|c| c.is_whitespace()).is_some() {}
if chars.next_if_eq(&'=').is_none() {
return keys;
}
keys.push(key);
while chars.next_if(|c| c.is_whitespace()).is_some() {}
if chars.next_if_eq(&'\'').is_some() {
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
}
'\'' => break,
_ => {}
}
}
} else {
while let Some(c) = chars.next_if(|c| !c.is_whitespace()) {
if c == '\\' {
chars.next();
}
}
}
}
}
pub(crate) fn retry_delay(base: Duration, failures: u32) -> Duration {
if failures == 0 {
return base;
}
base.saturating_mul(2_u32.saturating_pow(failures.min(16)))
.min(base.max(MAX_RETRY_DELAY))
}
#[derive(Debug, Default)]
struct Snapshot {
flags: HashMap<String, FlagConfig>,
loaded: bool,
checked_at: Option<Instant>,
}
struct Shared {
database_url: String,
connect_url: String,
cache_ttl: Duration,
snapshot: RwLock<Snapshot>,
generation: AtomicU64,
refreshing: AtomicBool,
refresh_conn: Mutex<Option<diesel::PgConnection>>,
write_lock: Mutex<()>,
refresh_errors: AtomicU64,
failures: AtomicU32,
#[cfg(test)]
connect_threads: Mutex<Vec<std::thread::ThreadId>>,
}
impl std::fmt::Debug for Shared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PgFlagStore")
.field("cache_ttl", &self.cache_ttl)
.field("refresh_errors", &self.refresh_errors)
.finish_non_exhaustive()
}
}
struct RefreshSlot(Arc<Shared>);
impl Drop for RefreshSlot {
fn drop(&mut self) {
self.0.refreshing.store(false, Ordering::Release);
}
}
impl Shared {
fn connect(&self) -> Result<diesel::PgConnection, FlagStoreError> {
#[cfg(test)]
self.connect_threads
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(std::thread::current().id());
crate::db_url::require_postgres_target(&self.database_url, "PgFlagStore")
.map_err(FlagStoreError::Backend)?;
let mut conn = diesel::PgConnection::establish(&self.connect_url).map_err(|e| {
FlagStoreError::Backend(crate::db_url::redact_driver_error(
&e.to_string(),
&self.database_url,
))
})?;
conn.batch_execute(&format!("SET statement_timeout = {STATEMENT_TIMEOUT_MS}"))
.map_err(|e| FlagStoreError::Backend(e.to_string()))?;
Ok(conn)
}
fn is_stale(&self) -> bool {
let wait = retry_delay(self.cache_ttl, self.failures.load(Ordering::Relaxed));
let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
snapshot.checked_at.is_none_or(|checked| {
checked
.checked_add(wait)
.is_some_and(|due| crate::time::system_instant() >= due)
})
}
fn read(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
match snapshot.flags.get(key) {
Some(flag) => Ok(Some(flag.clone())),
None if snapshot.loaded => Ok(None),
None => Err(FlagStoreError::Backend(
"flag snapshot not loaded yet; call PgFlagStore::refresh at startup".to_owned(),
)),
}
}
fn begin_refresh(self: &Arc<Self>) -> Option<RefreshSlot> {
self.refreshing
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.ok()
.map(|_| RefreshSlot(Arc::clone(self)))
}
fn reload(&self) -> Result<(), FlagStoreError> {
self.load().map(drop)
}
fn load(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
let mut conn = self
.refresh_conn
.lock()
.unwrap_or_else(PoisonError::into_inner);
let started = self.generation.load(Ordering::Acquire);
self.snapshot
.write()
.unwrap_or_else(PoisonError::into_inner)
.checked_at = Some(crate::time::system_instant());
let loaded = conn
.take()
.map_or_else(|| self.connect(), Ok)
.and_then(|mut open| {
let flags = load_all(&mut open)?;
*conn = Some(open);
Ok(flags)
});
if let Ok(flags) = &loaded {
self.install(flags.clone(), started);
}
drop(conn);
match loaded {
Ok(flags) => {
if self.failures.swap(0, Ordering::AcqRel) > 0 {
tracing::info!("feature flag refresh recovered");
}
Ok(flags)
}
Err(error) => {
self.refresh_errors.fetch_add(1, Ordering::Relaxed);
if self.failures.fetch_add(1, Ordering::AcqRel) == 0 {
tracing::warn!(
%error,
"feature flag refresh failed; serving the last-known snapshot"
);
} else {
tracing::debug!(%error, "feature flag refresh failed again");
}
Err(error)
}
}
}
fn install(&self, flags: Vec<FlagConfig>, started: u64) -> bool {
let mut snapshot = self
.snapshot
.write()
.unwrap_or_else(PoisonError::into_inner);
if self.generation.load(Ordering::Acquire) != started {
snapshot.checked_at = None;
return false;
}
snapshot.flags = flags.into_iter().map(|f| (f.key.clone(), f)).collect();
snapshot.loaded = true;
true
}
fn apply_local(&self, flag: FlagConfig) {
let mut snapshot = self
.snapshot
.write()
.unwrap_or_else(PoisonError::into_inner);
self.generation.fetch_add(1, Ordering::AcqRel);
snapshot.flags.insert(flag.key.clone(), flag);
snapshot.checked_at = None;
}
}
fn load_all(conn: &mut diesel::PgConnection) -> Result<Vec<FlagConfig>, FlagStoreError> {
diesel::sql_query(format!(
"SELECT {FLAG_COLUMNS} FROM autumn_feature_flags ORDER BY key"
))
.load::<FlagRow>(conn)
.map(|rows| rows.into_iter().map(FlagRow::into_config).collect())
.map_err(|e| FlagStoreError::Backend(e.to_string()))
}
#[derive(Debug)]
pub struct PgFlagStore {
shared: Arc<Shared>,
}
impl Clone for PgFlagStore {
fn clone(&self) -> Self {
Self::with_cache_ttl(self.shared.database_url.clone(), self.shared.cache_ttl)
}
}
impl PgFlagStore {
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(1);
#[must_use]
pub fn new(database_url: impl Into<String>) -> Self {
Self::with_cache_ttl(database_url, Self::DEFAULT_CACHE_TTL)
}
#[must_use]
pub fn with_cache_ttl(database_url: impl Into<String>, cache_ttl: Duration) -> Self {
let database_url = database_url.into();
Self {
shared: Arc::new(Shared {
connect_url: with_connect_timeout(&database_url),
database_url,
cache_ttl,
snapshot: RwLock::new(Snapshot::default()),
generation: AtomicU64::new(0),
refreshing: AtomicBool::new(false),
refresh_conn: Mutex::new(None),
write_lock: Mutex::new(()),
refresh_errors: AtomicU64::new(0),
failures: AtomicU32::new(0),
#[cfg(test)]
connect_threads: Mutex::new(Vec::new()),
}),
}
}
#[must_use]
pub fn from_database_config(config: &crate::config::DatabaseConfig) -> Option<Self> {
config.effective_primary_postgres_url().map(Self::new)
}
pub fn refresh(&self) -> Result<(), FlagStoreError> {
self.shared.reload()
}
#[must_use]
pub fn refresh_errors(&self) -> u64 {
self.shared.refresh_errors.load(Ordering::Relaxed)
}
fn upsert_flag(
conn: &mut diesel::PgConnection,
key: &str,
) -> Result<(), diesel::result::Error> {
diesel::sql_query(
"INSERT INTO autumn_feature_flags (key) VALUES ($1) \
ON CONFLICT (key) DO NOTHING",
)
.bind::<diesel::sql_types::Text, _>(key)
.execute(conn)?;
Ok(())
}
fn notify(conn: &mut diesel::PgConnection, key: &str) -> Result<(), diesel::result::Error> {
diesel::sql_query("SELECT pg_notify('autumn_flags', $1)")
.bind::<diesel::sql_types::Text, _>(key)
.execute(conn)?;
Ok(())
}
fn write(
&self,
key: &str,
mutation: &str,
actor: Option<&str>,
update: impl FnOnce(&mut diesel::PgConnection) -> QueryResult<FlagRow>,
) -> Result<(), FlagStoreError> {
let _ordered = self
.shared
.write_lock
.lock()
.unwrap_or_else(PoisonError::into_inner);
let mut conn = self.shared.connect()?;
let row = conn
.transaction::<FlagRow, diesel::result::Error, _>(|conn| {
Self::upsert_flag(conn, key)?;
let row = update(conn)?;
diesel::sql_query(
"INSERT INTO feature_flag_changes (key, mutation, actor) \
VALUES ($1, $2, $3)",
)
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::Text, _>(mutation)
.bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
actor.map(str::to_owned),
)
.execute(conn)?;
Self::notify(conn, key)?;
Ok(row)
})
.map_err(|e| FlagStoreError::Backend(e.to_string()))?;
self.shared.apply_local(row.into_config());
Ok(())
}
#[allow(
clippy::must_use_candidate,
reason = "the thread runs detached; callers drop the handle"
)]
pub fn spawn_poll_listener(
store: Arc<Self>,
poll_interval: Duration,
) -> std::thread::JoinHandle<()> {
let shared = Arc::downgrade(&store.shared);
drop(store);
std::thread::spawn(move || {
while let Some(shared) = shared.upgrade() {
if let Some(slot) = shared.begin_refresh() {
let reload = std::panic::AssertUnwindSafe(|| slot.0.reload());
if std::panic::catch_unwind(reload).is_err() {
tracing::warn!("feature flag refresh panicked");
}
}
let wait = retry_delay(poll_interval, shared.failures.load(Ordering::Relaxed));
drop(shared);
std::thread::sleep(wait);
}
})
}
#[cfg(test)]
fn install_for_test(&self, flags: Vec<FlagConfig>) {
assert!(self.install(flags, self.write_generation()));
self.shared
.snapshot
.write()
.unwrap_or_else(PoisonError::into_inner)
.checked_at = Some(crate::time::system_instant());
}
#[cfg(test)]
fn connect_threads_for_test(&self) -> Vec<std::thread::ThreadId> {
self.shared
.connect_threads
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
#[cfg(test)]
fn refreshing_for_test(&self) -> bool {
self.shared.refreshing.load(Ordering::Acquire)
}
#[cfg(test)]
fn write_generation(&self) -> u64 {
self.shared.generation.load(Ordering::Acquire)
}
#[cfg(test)]
fn apply_local(&self, flag: FlagConfig) {
self.shared.apply_local(flag);
}
#[cfg(test)]
fn install(&self, flags: Vec<FlagConfig>, generation: u64) -> bool {
self.shared.install(flags, generation)
}
}
#[derive(diesel::QueryableByName)]
struct FlagRow {
#[diesel(sql_type = diesel::sql_types::Text)]
key: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
description: Option<String>,
#[diesel(sql_type = diesel::sql_types::Bool)]
enabled: bool,
#[diesel(sql_type = diesel::sql_types::SmallInt)]
rollout_pct: i16,
#[diesel(sql_type = diesel::sql_types::Text)]
actor_allowlist: String,
#[diesel(sql_type = diesel::sql_types::Text)]
group_allowlist: String,
}
impl FlagRow {
fn into_config(self) -> FlagConfig {
let actor_allowlist: Vec<String> =
serde_json::from_str(&self.actor_allowlist).unwrap_or_default();
let group_allowlist: Vec<String> =
serde_json::from_str(&self.group_allowlist).unwrap_or_default();
FlagConfig {
key: self.key,
description: self.description,
enabled: self.enabled,
rollout_pct: u8::try_from(self.rollout_pct.clamp(0, 100)).unwrap_or(0),
actor_allowlist,
group_allowlist,
}
}
}
#[derive(diesel::QueryableByName)]
struct HistoryRow {
#[diesel(sql_type = diesel::sql_types::Text)]
key: String,
#[diesel(sql_type = diesel::sql_types::Text)]
mutation: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
actor: Option<String>,
#[diesel(sql_type = diesel::sql_types::BigInt)]
timestamp_secs: i64,
}
impl FlagStore for PgFlagStore {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
if self.shared.is_stale()
&& let Some(slot) = self.shared.begin_refresh()
{
match tokio::runtime::Handle::try_current() {
Ok(_) => {
drop(crate::time::spawn_blocking(move || {
let _ = slot.0.reload();
}));
}
Err(_) => {
let _ = slot.0.reload();
}
}
}
self.shared.read(key)
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
self.shared.load()
}
fn preload(&self) -> Result<(), FlagStoreError> {
self.refresh()
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.write(key, "enabled", actor, |conn| {
diesel::sql_query(format!(
"UPDATE autumn_feature_flags \
SET enabled = true, rollout_pct = 100, updated_at = NOW() \
WHERE key = $1 RETURNING {FLAG_COLUMNS}"
))
.bind::<diesel::sql_types::Text, _>(key)
.get_result(conn)
})
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.write(key, "disabled", actor, |conn| {
diesel::sql_query(format!(
"UPDATE autumn_feature_flags SET enabled = false, updated_at = NOW() \
WHERE key = $1 RETURNING {FLAG_COLUMNS}"
))
.bind::<diesel::sql_types::Text, _>(key)
.get_result(conn)
})
}
fn set_rollout(
&self,
key: &str,
pct: u8,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
let pct = i16::from(pct.min(100));
self.write(key, &format!("rollout={pct}"), actor, |conn| {
diesel::sql_query(format!(
"UPDATE autumn_feature_flags \
SET enabled = true, rollout_pct = $2, updated_at = NOW() \
WHERE key = $1 RETURNING {FLAG_COLUMNS}"
))
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::SmallInt, _>(pct)
.get_result(conn)
})
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.write(key, &format!("allowed_actor={actor_id}"), actor, |conn| {
diesel::sql_query(format!(
"UPDATE autumn_feature_flags \
SET enabled = true, \
rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
actor_allowlist = (
SELECT json_agg(DISTINCT elem) \
FROM (
SELECT jsonb_array_elements_text(actor_allowlist::jsonb) AS elem \
UNION SELECT $2
) t \
)::text, \
updated_at = NOW() \
WHERE key = $1 RETURNING {FLAG_COLUMNS}"
))
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::Text, _>(actor_id)
.get_result(conn)
})
}
fn add_group(
&self,
key: &str,
group: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.write(key, &format!("added_group={group}"), actor, |conn| {
diesel::sql_query(format!(
"UPDATE autumn_feature_flags \
SET enabled = true, \
rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
group_allowlist = (
SELECT json_agg(DISTINCT elem) \
FROM (
SELECT jsonb_array_elements_text(group_allowlist::jsonb) AS elem \
UNION SELECT $2
) t \
)::text, \
updated_at = NOW() \
WHERE key = $1 RETURNING {FLAG_COLUMNS}"
))
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::Text, _>(group)
.get_result(conn)
})
}
fn history(
&self,
key: &str,
limit: usize,
) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
let mut conn = self.shared.connect()?;
diesel::sql_query(
"SELECT key, mutation, actor, \
EXTRACT(EPOCH FROM changed_at)::bigint AS timestamp_secs \
FROM feature_flag_changes \
WHERE key = $1 \
ORDER BY changed_at DESC LIMIT $2",
)
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::BigInt, _>(limit)
.load::<HistoryRow>(&mut conn)
.map(|rows| {
rows.into_iter()
.map(|r| FlagChangeRecord {
key: r.key,
mutation: r.mutation,
actor: r.actor,
timestamp_secs: u64::try_from(r.timestamp_secs).unwrap_or(0),
})
.collect()
})
.map_err(|e| FlagStoreError::Backend(e.to_string()))
}
}
#[cfg(test)]
mod pg_tests {
use super::*;
const DEAD_URL: &str = "postgres://autumn@127.0.0.1:1/autumn?connect_timeout=2";
fn enabled(key: &str) -> FlagConfig {
let mut flag = FlagConfig::new(key);
flag.enabled = true;
flag.rollout_pct = 100;
flag
}
fn current_thread_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
async fn settle(store: &PgFlagStore) {
for _ in 0..1000 {
if !store.refreshing_for_test() {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("background refresh did not finish");
}
#[test]
fn pg_get_serves_snapshot_when_refresh_fails() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
store.install_for_test(vec![enabled("beta")]);
assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
assert_eq!(store.get("absent").unwrap(), None);
assert_eq!(store.refresh_errors(), 2);
}
#[test]
fn pg_get_within_ttl_does_not_connect() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
store.install_for_test(vec![enabled("beta")]);
assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
assert!(store.connect_threads_for_test().is_empty());
}
#[test]
fn pg_get_on_current_thread_runtime_never_connects_on_the_worker() {
let store = std::sync::Arc::new(PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO));
store.install_for_test(vec![enabled("beta")]);
let svc = crate::feature_flags::FeatureFlagService::new(store.clone());
current_thread_runtime().block_on(async {
let worker = std::thread::current().id();
assert!(svc.is_enabled("beta", Some("user:1")));
settle(&store).await;
let threads = store.connect_threads_for_test();
assert!(!threads.is_empty(), "the stale snapshot is refreshed");
assert!(
!threads.contains(&worker),
"flag evaluation must not connect on the Tokio worker"
);
});
assert!(store.refresh_errors() >= 1);
}
#[test]
fn pg_cold_get_on_runtime_errors_and_loads_in_background() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
current_thread_runtime().block_on(async {
let worker = std::thread::current().id();
assert!(store.get("beta").is_err(), "no snapshot yet");
settle(&store).await;
let threads = store.connect_threads_for_test();
assert!(!threads.is_empty());
assert!(!threads.contains(&worker));
});
}
#[test]
fn pg_refresh_older_than_a_local_write_is_discarded() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
store.install_for_test(vec![]);
let started = store.write_generation();
store.apply_local(enabled("beta"));
assert!(!store.install(vec![FlagConfig::new("beta")], started));
assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
assert!(store.install(vec![FlagConfig::new("beta")], store.write_generation()));
assert_eq!(store.get("beta").unwrap(), Some(FlagConfig::new("beta")));
}
#[test]
fn pg_refresh_reports_backend_error() {
let store = PgFlagStore::new(DEAD_URL);
assert!(store.refresh().is_err());
assert_eq!(store.refresh_errors(), 1);
assert!(store.preload().is_err());
}
#[test]
fn pg_poll_listener_stops_when_the_store_drops() {
let store = std::sync::Arc::new(PgFlagStore::new(DEAD_URL));
let handle = PgFlagStore::spawn_poll_listener(
std::sync::Arc::clone(&store),
Duration::from_millis(10),
);
drop(store);
for _ in 0..1000 {
if handle.is_finished() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("poll listener outlived its store");
}
#[test]
fn connect_url_gets_a_connect_timeout() {
assert_eq!(
with_connect_timeout("postgres://h/db"),
"postgres://h/db?connect_timeout=5"
);
assert_eq!(
with_connect_timeout("postgresql://h/db?sslmode=require"),
"postgresql://h/db?sslmode=require&connect_timeout=5"
);
assert_eq!(
with_connect_timeout("host=db dbname=app"),
"host=db dbname=app connect_timeout=5"
);
assert_eq!(DEAD_URL, with_connect_timeout(DEAD_URL));
assert_eq!(
with_connect_timeout("host=db connect_timeout = 3"),
"host=db connect_timeout = 3"
);
assert_eq!(
with_connect_timeout("postgres://u:connect_timeout@h/db"),
"postgres://u:connect_timeout@h/db?connect_timeout=5"
);
assert_eq!(
with_connect_timeout("postgres://h/db?application_name=connect_timeout"),
"postgres://h/db?application_name=connect_timeout&connect_timeout=5"
);
assert_eq!(
with_connect_timeout("host=db application_name=connect_timeout"),
"host=db application_name=connect_timeout connect_timeout=5"
);
assert_eq!(
with_connect_timeout("host=db application_name='foo connect_timeout=999'"),
"host=db application_name='foo connect_timeout=999' connect_timeout=5"
);
assert_eq!(
with_connect_timeout(r"host=db password='it\'s connect_timeout=1'"),
r"host=db password='it\'s connect_timeout=1' connect_timeout=5"
);
assert_eq!(
with_connect_timeout("host=db connect_timeout='7'"),
"host=db connect_timeout='7'"
);
}
#[test]
fn retry_delay_doubles_up_to_the_cap() {
let second = Duration::from_secs(1);
assert_eq!(retry_delay(second, 0), second);
assert_eq!(retry_delay(second, 1), Duration::from_secs(2));
assert_eq!(retry_delay(second, 3), Duration::from_secs(8));
assert_eq!(retry_delay(second, 40), MAX_RETRY_DELAY);
assert_eq!(retry_delay(Duration::ZERO, 5), Duration::ZERO);
let minute = Duration::from_secs(60);
assert_eq!(retry_delay(minute, 4), minute);
}
#[test]
fn pg_store_debug_hides_the_database_password() {
let store = PgFlagStore::new("postgres://app:s3cret@db/app");
assert!(!format!("{store:?}").contains("s3cret"));
}
#[test]
fn pg_list_serves_nothing_new_when_the_database_fails() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
store.install_for_test(vec![enabled("beta")]);
assert!(store.list().is_err());
assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
}
#[test]
fn pg_store_exposes_database_url() {
let store = PgFlagStore::new("postgres://localhost/myapp");
assert_eq!(store.shared.database_url, "postgres://localhost/myapp");
}
#[test]
fn pg_store_default_cache_ttl_is_one_second() {
let store = PgFlagStore::new("postgres://localhost/myapp");
assert_eq!(store.shared.cache_ttl, PgFlagStore::DEFAULT_CACHE_TTL);
}
#[test]
fn pg_store_with_cache_ttl_sets_custom_ttl() {
let ttl = Duration::from_secs(30);
let store = PgFlagStore::with_cache_ttl("postgres://localhost/myapp", ttl);
assert_eq!(store.shared.cache_ttl, ttl);
}
#[test]
fn pg_store_clone_has_independent_snapshot() {
let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
store.install_for_test(vec![enabled("cached")]);
let cloned = store.clone();
assert!(cloned.get("cached").is_err(), "a clone starts empty");
assert_eq!(store.get("cached").unwrap(), Some(enabled("cached")));
}
}
}
fn fnv1a_64(parts: &[&[u8]]) -> u64 {
const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
const FNV_PRIME: u64 = 1_099_511_628_211;
let mut hash = FNV_OFFSET;
for &byte in parts.iter().copied().flatten() {
hash ^= u64(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
#[allow(clippy::cast_lossless)]
const fn u64(v: u8) -> u64 {
v as u64
}
#[must_use]
pub fn rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
let hash = fnv1a_64(&[flag_key.as_bytes(), b":".as_slice(), actor_id.as_bytes()]);
u8::try_from(hash % 100).unwrap_or(0)
}
#[derive(Clone)]
pub struct FeatureFlagService {
store: Arc<dyn FlagStore>,
group_resolver: Option<GroupResolver>,
defaults: Arc<HashMap<String, bool>>,
health: Arc<StoreHealth>,
}
#[derive(Default)]
struct StoreHealth {
known: RwLock<Known>,
errors: AtomicU64,
failing: AtomicBool,
writes: AtomicU64,
write_order: Mutex<()>,
}
#[derive(Default)]
struct Known {
values: HashMap<String, FlagConfig>,
last_write: HashMap<String, u64>,
}
impl Known {
fn written_after(&self, key: &str, started: u64) -> bool {
self.last_write
.get(key)
.is_some_and(|write| *write > started)
}
}
impl StoreHealth {
fn read_started(&self) -> u64 {
self.writes.load(Ordering::Acquire)
}
fn begin_write(&self, key: &str) -> u64 {
let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
let write = self.writes.fetch_add(1, Ordering::AcqRel) + 1;
known.last_write.insert(key.to_owned(), write);
write
}
fn remember(&self, key: &str, flag: Option<&FlagConfig>, started: u64) {
if self.failing.load(Ordering::Relaxed) && self.failing.swap(false, Ordering::AcqRel) {
tracing::info!("feature flag store recovered");
}
let known = self.known.read().unwrap_or_else(PoisonError::into_inner);
if known.values.get(key) == flag {
return;
}
drop(known);
let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
if known.written_after(key, started) {
return;
}
match flag {
Some(flag) => {
known.values.insert(key.to_owned(), flag.clone());
}
None => {
known.values.remove(key);
}
}
}
fn forget(&self, key: &str, disabled: bool, write: u64) {
let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
if known.written_after(key, write) {
return;
}
if disabled {
known
.values
.entry(key.to_owned())
.or_insert_with(|| FlagConfig::new(key))
.enabled = false;
} else {
known.values.remove(key);
}
}
fn recall(&self, key: &str, error: &FlagStoreError) -> Option<FlagConfig> {
self.errors.fetch_add(1, Ordering::Relaxed);
if self.failing.swap(true, Ordering::AcqRel) {
tracing::debug!(flag = key, %error, "feature flag store read failed again");
} else {
tracing::warn!(
flag = key,
%error,
"feature flag store read failed; serving last-known values or declared defaults"
);
}
self.known
.read()
.unwrap_or_else(PoisonError::into_inner)
.values
.get(key)
.cloned()
}
}
impl std::fmt::Debug for FeatureFlagService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FeatureFlagService").finish_non_exhaustive()
}
}
impl FeatureFlagService {
#[must_use]
pub fn new(store: Arc<dyn FlagStore>) -> Self {
Self {
store,
group_resolver: None,
defaults: Arc::default(),
health: Arc::default(),
}
}
#[must_use]
pub fn with_default(mut self, flag_key: impl Into<String>, default: bool) -> Self {
Arc::make_mut(&mut self.defaults).insert(flag_key.into(), default);
self
}
#[must_use]
pub fn store_errors(&self) -> u64 {
self.health.errors.load(Ordering::Relaxed)
}
pub fn preload(&self) -> Result<(), FlagStoreError> {
self.store.preload()
}
#[must_use]
pub fn with_group_resolver(mut self, resolver: GroupResolver) -> Self {
self.group_resolver = Some(resolver);
self
}
#[must_use]
pub fn is_enabled(&self, flag_key: &str, actor_id: Option<&str>) -> bool {
let started = self.health.read_started();
let flag = match self.store.get(flag_key) {
Ok(flag) => {
self.health.remember(flag_key, flag.as_ref(), started);
flag
}
Err(error) => self.health.recall(flag_key, &error),
};
flag.map_or_else(
|| self.defaults.get(flag_key).copied().unwrap_or(false),
|flag| self.evaluate(&flag, actor_id),
)
}
fn evaluate(&self, flag: &FlagConfig, actor_id: Option<&str>) -> bool {
if !flag.enabled {
return false;
}
if flag.rollout_pct >= 100 {
return true;
}
if let Some(actor) = actor_id
&& flag.actor_allowlist.iter().any(|a| a.as_str() == actor)
{
return true;
}
if let (Some(actor), Some(resolver)) = (actor_id, &self.group_resolver) {
for group in &flag.group_allowlist {
if resolver(actor, group) {
return true;
}
}
}
if flag.rollout_pct > 0
&& let Some(actor) = actor_id
{
let bucket = rollout_bucket(&flag.key, actor);
return bucket < flag.rollout_pct;
}
false
}
fn write(
&self,
flag_key: &str,
disabled: bool,
op: impl FnOnce(&dyn FlagStore) -> Result<(), FlagStoreError>,
) -> Result<(), FlagStoreError> {
let _ordered = self
.health
.write_order
.lock()
.unwrap_or_else(PoisonError::into_inner);
op(self.store.as_ref())?;
let started = self.health.begin_write(flag_key);
match self.store.get(flag_key) {
Ok(flag) => self.health.remember(flag_key, flag.as_ref(), started),
Err(_) => self.health.forget(flag_key, disabled, started),
}
Ok(())
}
pub fn enable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.write(flag_key, false, |store| store.enable(flag_key, actor))
}
pub fn disable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.write(flag_key, true, |store| store.disable(flag_key, actor))
}
pub fn set_rollout(
&self,
flag_key: &str,
pct: u8,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.write(flag_key, false, |store| {
store.set_rollout(flag_key, pct, actor)
})
}
pub fn allow_actor(
&self,
flag_key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.write(flag_key, false, |store| {
store.allow_actor(flag_key, actor_id, actor)
})
}
pub fn add_group(
&self,
flag_key: &str,
group: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.write(flag_key, false, |store| {
store.add_group(flag_key, group, actor)
})
}
pub fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
self.store.list()
}
pub fn history(
&self,
flag_key: &str,
limit: usize,
) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
self.store.history(flag_key, limit)
}
}
pub(crate) const PRELOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
pub(crate) fn preload_blocking(service: &FeatureFlagService, wait: std::time::Duration) {
let service = service.clone();
preload_with(
"feature flag",
"flags use declared defaults",
wait,
move || service.preload(),
);
}
pub(crate) fn preload_with<E: std::fmt::Display + Send + 'static>(
what: &'static str,
fallback: &'static str,
wait: std::time::Duration,
load: impl FnOnce() -> Result<(), E> + Send + 'static,
) {
let (done, loaded) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new()
.name(format!("autumn-{}-preload", what.replace(' ', "-")))
.spawn(move || {
let _ = done.send(load());
});
if let Err(error) = spawned {
tracing::warn!(%error, "{what} preload thread did not start");
return;
}
match loaded.recv_timeout(wait) {
Ok(Ok(())) => {}
Ok(Err(error)) => {
tracing::warn!(%error, "{what} preload failed; {fallback}");
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
tracing::warn!(
?wait,
"{what} preload is slow; the app continues: {fallback}"
);
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
tracing::warn!("{what} preload panicked");
}
}
}
pub struct Flags {
service: FeatureFlagService,
actor_id: Option<String>,
}
impl Flags {
#[must_use]
pub fn enabled(&self, flag_key: &str) -> bool {
self.service.is_enabled(flag_key, self.actor_id.as_deref())
}
#[must_use]
pub const fn service(&self) -> &FeatureFlagService {
&self.service
}
}
impl axum::extract::FromRequestParts<crate::AppState> for Flags {
type Rejection = crate::AutumnError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &crate::AppState,
) -> Result<Self, Self::Rejection> {
let service = state
.extension::<FeatureFlagService>()
.map(|arc| (*arc).clone())
.ok_or_else(|| {
crate::AutumnError::internal_server_error_msg(
"feature flag service not registered; \
install a FlagStore via AppBuilder::with_flag_store()",
)
})?;
let actor_id = if let Some(session) = parts.extensions.get::<crate::session::Session>() {
session.get(state.auth_session_key()).await
} else {
None
};
Ok(Self { service, actor_id })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_svc() -> FeatureFlagService {
FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
}
#[test]
fn unknown_flag_returns_false() {
let svc = make_svc();
assert!(!svc.is_enabled("nonexistent", Some("user:1")));
}
#[test]
fn globally_enabled_flag_returns_true_for_any_actor() {
let svc = make_svc();
svc.enable("my_flag", None).unwrap();
assert!(svc.is_enabled("my_flag", Some("user:1")));
assert!(svc.is_enabled("my_flag", Some("user:99")));
assert!(svc.is_enabled("my_flag", None));
}
#[test]
fn globally_disabled_flag_returns_false_for_any_actor() {
let svc = make_svc();
svc.enable("my_flag", None).unwrap();
svc.disable("my_flag", None).unwrap();
assert!(!svc.is_enabled("my_flag", Some("user:1")));
assert!(!svc.is_enabled("my_flag", None));
}
#[test]
fn actor_allowlist_enables_specific_actor() {
let svc = make_svc();
svc.allow_actor("beta_feature", "user:42", None).unwrap();
assert!(svc.is_enabled("beta_feature", Some("user:42")));
assert!(!svc.is_enabled("beta_feature", Some("user:1")));
}
#[test]
fn group_resolver_enables_group_members() {
let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new())).with_group_resolver(
Arc::new(|actor_id: &str, group: &str| {
group == "staff" && actor_id.starts_with("staff:")
}),
);
svc.add_group("internal_feature", "staff", None).unwrap();
assert!(svc.is_enabled("internal_feature", Some("staff:alice")));
assert!(!svc.is_enabled("internal_feature", Some("user:bob")));
}
#[test]
fn percent_rollout_at_0_disables_for_all_actors() {
let svc = make_svc();
svc.set_rollout("gradual", 0, None).unwrap();
for i in 0..50_u32 {
let actor = format!("user:{i}");
assert!(
!svc.is_enabled("gradual", Some(&actor)),
"expected disabled for {actor} at 0% rollout"
);
}
}
#[test]
fn percent_rollout_at_100_enables_for_all_actors() {
let svc = make_svc();
svc.set_rollout("gradual", 100, None).unwrap();
for i in 0..50_u32 {
let actor = format!("user:{i}");
assert!(
svc.is_enabled("gradual", Some(&actor)),
"expected enabled for {actor} at 100% rollout"
);
}
}
#[test]
fn percent_rollout_at_50_enables_roughly_half() {
let svc = make_svc();
svc.set_rollout("rollout_flag", 50, None).unwrap();
let enabled_count = (0..200_u32)
.filter(|i| svc.is_enabled("rollout_flag", Some(&format!("user:{i}"))))
.count();
assert!(
(80..=120).contains(&enabled_count),
"expected ~100 enabled actors, got {enabled_count}"
);
}
#[test]
fn rollout_bucket_is_stable_across_calls() {
let b1 = rollout_bucket("my_flag", "user:1");
let b2 = rollout_bucket("my_flag", "user:1");
assert_eq!(b1, b2, "bucket must be deterministic");
}
#[test]
fn rollout_bucket_differs_for_different_actors() {
let buckets: std::collections::HashSet<u8> = (0..50_u32)
.map(|i| rollout_bucket("flag", &format!("user:{i}")))
.collect();
assert!(
buckets.len() > 10,
"expected diverse buckets, got {}: {buckets:?}",
buckets.len()
);
}
#[test]
fn rollout_bucket_in_range_0_to_99() {
for i in 0..1000_u32 {
let b = rollout_bucket("flag", &format!("actor:{i}"));
assert!(b < 100, "bucket out of range: {b}");
}
}
fn joined_fnv1a_64(data: &[u8]) -> u64 {
const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
const FNV_PRIME: u64 = 1_099_511_628_211;
let mut hash = FNV_OFFSET;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
fn joined_rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
let key = format!("{flag_key}:{actor_id}");
let hash = joined_fnv1a_64(key.as_bytes());
u8::try_from(hash % 100).unwrap_or(0)
}
#[test]
fn rollout_bucket_is_fnv1a_of_the_joined_key() {
let edge_cases = [
("my_flag", "user:1"),
("", ""),
("f", ""),
("", "a"),
(":", ":"),
("a:b", "c:d"),
("ab", "c:d"),
("a:b:", ":c"),
("ünï", "çødé"),
("flag🚩", "用户:42"),
("tab\tflag", "line\nactor"),
];
for (flag, actor) in edge_cases {
assert_eq!(
rollout_bucket(flag, actor),
joined_rollout_bucket(flag, actor),
"{flag:?}/{actor:?}"
);
}
for flag in ["flag", "beta", "dark_mode", "checkout:v2"] {
for i in 0..1_000_u32 {
let actor = format!("actor:{i}");
assert_eq!(
rollout_bucket(flag, &actor),
joined_rollout_bucket(flag, &actor),
"{flag:?}/{actor:?}"
);
}
}
}
#[test]
fn fnv1a_64_of_parts_equals_fnv1a_64_of_their_concatenation() {
for key in ["", ":", "my_flag:user:1", "ünï:çødé", "a::b"] {
let whole = joined_fnv1a_64(key.as_bytes());
let bytes = key.as_bytes();
for i in 0..=bytes.len() {
for j in i..=bytes.len() {
let (head, rest) = bytes.split_at(i);
let (middle, tail) = rest.split_at(j - i);
assert_eq!(fnv1a_64(&[head, middle, tail]), whole, "{key:?} {i} {j}");
}
}
assert_eq!(fnv1a_64(&[bytes]), whole, "{key:?} in one part");
}
assert_eq!(fnv1a_64(&[]), joined_fnv1a_64(&[]), "no parts at all");
}
#[test]
fn rollout_bucket_known_fixtures() {
assert_eq!(rollout_bucket("my_flag", "user:1"), 62);
assert_eq!(rollout_bucket("beta", "user:42"), 28);
assert_eq!(rollout_bucket("dark_mode", "user:7"), 77);
assert_eq!(rollout_bucket("", ""), 89);
assert_eq!(rollout_bucket("a:b", "c:d"), 53);
assert_eq!(rollout_bucket("ünï", "çødé"), 63);
}
#[test]
fn percent_rollout_same_actor_same_flag_always_same_result() {
let svc = make_svc();
svc.set_rollout("stable_flag", 42, None).unwrap();
let first = svc.is_enabled("stable_flag", Some("user:123"));
for _ in 0..10 {
assert_eq!(
svc.is_enabled("stable_flag", Some("user:123")),
first,
"rollout result must not flip between calls"
);
}
}
#[test]
fn in_memory_store_returns_none_for_unknown_flag() {
let store = InMemoryFlagStore::new();
assert!(store.get("unknown").unwrap().is_none());
}
#[test]
fn in_memory_store_list_is_sorted() {
let store = InMemoryFlagStore::new();
store.enable("zebra", None).unwrap();
store.enable("alpha", None).unwrap();
store.enable("mango", None).unwrap();
let keys: Vec<String> = store.list().unwrap().into_iter().map(|f| f.key).collect();
assert_eq!(keys, vec!["alpha", "mango", "zebra"]);
}
#[test]
fn in_memory_store_enable_creates_flag_if_absent() {
let store = InMemoryFlagStore::new();
store.enable("new_flag", None).unwrap();
let flag = store.get("new_flag").unwrap().unwrap();
assert!(flag.enabled);
}
#[test]
fn in_memory_store_disable_sets_enabled_false() {
let store = InMemoryFlagStore::new();
store.enable("f", None).unwrap();
store.disable("f", None).unwrap();
assert!(!store.get("f").unwrap().unwrap().enabled);
}
#[test]
fn in_memory_store_allow_actor_does_not_duplicate() {
let store = InMemoryFlagStore::new();
store.allow_actor("f", "user:1", None).unwrap();
store.allow_actor("f", "user:1", None).unwrap();
let flag = store.get("f").unwrap().unwrap();
assert_eq!(flag.actor_allowlist.len(), 1);
}
#[test]
fn in_memory_store_add_group_does_not_duplicate() {
let store = InMemoryFlagStore::new();
store.add_group("f", "staff", None).unwrap();
store.add_group("f", "staff", None).unwrap();
let flag = store.get("f").unwrap().unwrap();
assert_eq!(flag.group_allowlist.len(), 1);
}
#[test]
fn mutations_are_recorded_in_history() {
let svc = make_svc();
svc.enable("tracked_flag", Some("ops@example.com")).unwrap();
svc.disable("tracked_flag", Some("ops@example.com"))
.unwrap();
let history = svc.history("tracked_flag", 10).unwrap();
assert_eq!(history.len(), 2, "two mutations should be recorded");
assert_eq!(history[0].mutation, "disabled");
assert_eq!(history[0].actor.as_deref(), Some("ops@example.com"));
assert_eq!(history[1].mutation, "enabled");
}
#[test]
fn history_respects_limit() {
let svc = make_svc();
for _ in 0..5 {
svc.enable("limited_flag", None).unwrap();
}
let history = svc.history("limited_flag", 3).unwrap();
assert_eq!(history.len(), 3);
}
#[test]
fn history_empty_for_unknown_flag() {
let svc = make_svc();
let history = svc.history("ghost_flag", 10).unwrap();
assert!(history.is_empty());
}
#[test]
fn rollout_mutation_recorded_with_pct() {
let svc = make_svc();
svc.set_rollout("roll", 25, Some("cli")).unwrap();
let history = svc.history("roll", 1).unwrap();
assert_eq!(history[0].mutation, "rollout=25");
assert_eq!(history[0].actor.as_deref(), Some("cli"));
}
#[test]
fn allow_actor_mutation_recorded() {
let svc = make_svc();
svc.allow_actor("f", "user:7", Some("cli")).unwrap();
let h = svc.history("f", 1).unwrap();
assert_eq!(h[0].mutation, "allowed_actor=user:7");
}
#[test]
fn flag_config_new_defaults_to_disabled() {
let f = FlagConfig::new("my_flag");
assert_eq!(f.key, "my_flag");
assert!(!f.enabled);
assert_eq!(f.rollout_pct, 0);
assert!(f.actor_allowlist.is_empty());
assert!(f.group_allowlist.is_empty());
}
#[test]
fn set_rollout_clamps_to_100() {
let store = InMemoryFlagStore::new();
store.set_rollout("f", 200, None).unwrap();
assert_eq!(store.get("f").unwrap().unwrap().rollout_pct, 100);
}
#[test]
fn disable_kills_flag_even_when_rollout_is_100_percent() {
let svc = make_svc();
svc.set_rollout("roll_flag", 100, None).unwrap();
svc.disable("roll_flag", None).unwrap();
for i in 0..20_u32 {
assert!(
!svc.is_enabled("roll_flag", Some(&format!("user:{i}"))),
"disable() must override rollout for actor user:{i}"
);
}
assert!(!svc.is_enabled("roll_flag", None));
}
#[test]
fn disable_kills_flag_even_when_actor_is_in_allowlist() {
let svc = make_svc();
svc.allow_actor("guarded", "user:42", None).unwrap();
svc.disable("guarded", None).unwrap();
assert!(
!svc.is_enabled("guarded", Some("user:42")),
"disable() must override actor allowlist"
);
}
#[test]
fn enable_after_disable_restores_rollout_config() {
let svc = make_svc();
svc.set_rollout("roll_flag", 50, None).unwrap();
svc.disable("roll_flag", None).unwrap();
svc.enable("roll_flag", None).unwrap();
assert!(svc.is_enabled("roll_flag", None));
assert!(svc.is_enabled("roll_flag", Some("user:1")));
}
#[test]
fn allow_actor_after_kill_switch_does_not_restore_global_rollout() {
let svc = make_svc();
svc.enable("targeted", None).unwrap(); svc.disable("targeted", None).unwrap(); svc.allow_actor("targeted", "user:42", None).unwrap();
assert!(
svc.is_enabled("targeted", Some("user:42")),
"allowlisted actor must see the flag"
);
for i in [1_u32, 5, 10, 99] {
let actor = format!("user:{i}");
assert!(
!svc.is_enabled("targeted", Some(&actor)),
"non-allowlisted actor {actor} must NOT see the flag after allowlist-only re-enable"
);
}
}
#[test]
fn allow_actor_on_active_rollout_preserves_rollout_pct() {
let svc = make_svc();
svc.set_rollout("staged", 50, None).unwrap(); svc.allow_actor("staged", "user:42", None).unwrap();
let store = InMemoryFlagStore::new();
store.set_rollout("staged", 50, None).unwrap();
store.allow_actor("staged", "user:42", None).unwrap();
let flag = store.get("staged").unwrap().unwrap();
assert_eq!(
flag.rollout_pct, 50,
"rollout_pct must be preserved when flag was already enabled"
);
assert!(flag.actor_allowlist.contains(&"user:42".to_owned()));
}
#[test]
fn arc_flag_store_delegates_get() {
let store = Arc::new(InMemoryFlagStore::new());
store.enable("arc_flag", None).unwrap();
let arc_store: Arc<dyn FlagStore> = store;
let flag = arc_store.get("arc_flag").unwrap().unwrap();
assert!(flag.enabled);
}
#[test]
fn arc_flag_store_delegates_list() {
let store = Arc::new(InMemoryFlagStore::new());
store.enable("f1", None).unwrap();
store.enable("f2", None).unwrap();
let arc_store: Arc<dyn FlagStore> = store;
let flags = arc_store.list().unwrap();
assert_eq!(flags.len(), 2);
}
#[test]
fn arc_flag_store_delegates_enable_and_disable() {
let store = Arc::new(InMemoryFlagStore::new());
let arc_store: Arc<dyn FlagStore> = store;
arc_store.enable("f", None).unwrap();
assert!(arc_store.get("f").unwrap().unwrap().enabled);
arc_store.disable("f", None).unwrap();
assert!(!arc_store.get("f").unwrap().unwrap().enabled);
}
#[test]
fn arc_flag_store_delegates_set_rollout() {
let store = Arc::new(InMemoryFlagStore::new());
let arc_store: Arc<dyn FlagStore> = store;
arc_store.set_rollout("f", 42, None).unwrap();
let flag = arc_store.get("f").unwrap().unwrap();
assert_eq!(flag.rollout_pct, 42);
}
#[test]
fn arc_flag_store_delegates_allow_actor() {
let store = Arc::new(InMemoryFlagStore::new());
let arc_store: Arc<dyn FlagStore> = store;
arc_store.allow_actor("f", "user:1", None).unwrap();
let flag = arc_store.get("f").unwrap().unwrap();
assert!(flag.actor_allowlist.contains(&"user:1".to_owned()));
}
#[test]
fn arc_flag_store_delegates_add_group() {
let store = Arc::new(InMemoryFlagStore::new());
let arc_store: Arc<dyn FlagStore> = store;
arc_store.add_group("f", "beta_testers", None).unwrap();
let flag = arc_store.get("f").unwrap().unwrap();
assert!(flag.group_allowlist.contains(&"beta_testers".to_owned()));
}
#[test]
fn arc_flag_store_delegates_history() {
let store = Arc::new(InMemoryFlagStore::new());
let arc_store: Arc<dyn FlagStore> = store;
arc_store.enable("f", Some("cli")).unwrap();
let history = arc_store.history("f", 10).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].mutation, "enabled");
}
#[test]
fn box_flag_store_delegates_all_operations() {
let store = InMemoryFlagStore::new();
let boxed: Box<dyn FlagStore> = Box::new(store);
boxed.enable("f", None).unwrap();
assert!(boxed.get("f").unwrap().unwrap().enabled);
boxed.set_rollout("g", 25, Some("cli")).unwrap();
assert_eq!(boxed.get("g").unwrap().unwrap().rollout_pct, 25);
boxed.allow_actor("h", "user:1", None).unwrap();
boxed.add_group("h", "staff", None).unwrap();
let flags = boxed.list().unwrap();
assert_eq!(flags.len(), 3);
let hist = boxed.history("f", 5).unwrap();
assert_eq!(hist[0].mutation, "enabled");
boxed.disable("f", None).unwrap();
assert!(!boxed.get("f").unwrap().unwrap().enabled);
}
#[test]
fn flag_store_error_displays_message() {
let err = FlagStoreError::Backend("connection refused".to_owned());
assert_eq!(
err.to_string(),
"flag store backend error: connection refused"
);
}
#[test]
fn flag_config_clone_is_equal_to_original() {
let mut f = FlagConfig::new("cloned");
f.enabled = true;
f.rollout_pct = 50;
f.actor_allowlist = vec!["user:1".to_owned()];
let g = f.clone();
assert_eq!(f, g);
}
#[test]
fn rollout_with_no_actor_returns_false() {
let svc = make_svc();
svc.set_rollout("gradual", 99, None).unwrap();
assert!(
!svc.is_enabled("gradual", None),
"percent rollout must not fire for anonymous (None) actor"
);
}
#[test]
fn group_resolver_with_no_actor_does_not_panic() {
let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
.with_group_resolver(Arc::new(|_: &str, _: &str| true));
svc.add_group("f", "everyone", None).unwrap();
assert!(!svc.is_enabled("f", None));
}
#[test]
fn add_group_mutation_format() {
let store = InMemoryFlagStore::new();
store.add_group("f", "beta_testers", Some("cli")).unwrap();
let hist = store.history("f", 1).unwrap();
assert_eq!(hist[0].mutation, "added_group=beta_testers");
assert_eq!(hist[0].actor.as_deref(), Some("cli"));
}
#[test]
fn service_list_returns_all_flags() {
let svc = make_svc();
svc.enable("a", None).unwrap();
svc.disable("b", None).unwrap();
svc.set_rollout("c", 10, None).unwrap();
let flags = svc.list().unwrap();
assert_eq!(flags.len(), 3);
assert_eq!(flags[0].key, "a");
assert_eq!(flags[1].key, "b");
assert_eq!(flags[2].key, "c");
}
#[test]
fn service_debug_does_not_panic() {
let svc = make_svc();
let _ = format!("{svc:?}");
}
#[test]
fn flags_enabled_delegates_to_service() {
let svc = make_svc();
svc.enable("active", None).unwrap();
assert!(svc.is_enabled("active", Some("any_user")));
assert!(!svc.is_enabled("missing", Some("any_user")));
}
#[tokio::test]
async fn from_request_parts_respects_custom_auth_session_key() {
use axum::extract::FromRequestParts;
use std::collections::HashMap;
let svc = make_svc();
let state = crate::AppState::for_test().with_auth_session_key("custom_user_id");
state.insert_extension(svc);
let mut data = HashMap::new();
data.insert("custom_user_id".to_owned(), "user:123".to_owned());
data.insert("user_id".to_owned(), "user:999".to_owned()); let session = crate::session::Session::new_for_test("session_id".to_owned(), data);
let mut req = axum::http::Request::builder().body(()).unwrap();
req.extensions_mut().insert(session);
let mut parts = req.into_parts().0;
let flags = Flags::from_request_parts(&mut parts, &state).await.unwrap();
assert_eq!(flags.actor_id.as_deref(), Some("user:123"));
}
#[derive(Default)]
struct ScriptedStore {
inner: InMemoryFlagStore,
failing: std::sync::atomic::AtomicBool,
hidden: std::sync::atomic::AtomicBool,
preloads: std::sync::atomic::AtomicUsize,
preload_delay_ms: std::sync::atomic::AtomicU64,
}
impl ScriptedStore {
fn fail(&self, on: bool) {
self.failing.store(on, std::sync::atomic::Ordering::SeqCst);
}
fn hide(&self, on: bool) {
self.hidden.store(on, std::sync::atomic::Ordering::SeqCst);
}
}
impl FlagStore for ScriptedStore {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
if self.failing.load(std::sync::atomic::Ordering::SeqCst) {
return Err(FlagStoreError::Backend("brownout".to_owned()));
}
if self.hidden.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(None);
}
self.inner.get(key)
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
self.inner.list()
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.inner.enable(key, actor)
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.inner.disable(key, actor)
}
fn set_rollout(
&self,
key: &str,
pct: u8,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.set_rollout(key, pct, actor)
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.allow_actor(key, actor_id, actor)
}
fn add_group(
&self,
key: &str,
group: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.add_group(key, group, actor)
}
fn history(
&self,
key: &str,
limit: usize,
) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
self.inner.history(key, limit)
}
fn preload(&self) -> Result<(), FlagStoreError> {
let delay = self
.preload_delay_ms
.load(std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(delay));
self.preloads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
#[test]
fn failing_store_serves_last_known_value() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
store.enable("beta", None).unwrap();
assert!(svc.is_enabled("beta", Some("user:1")));
store.fail(true);
assert!(
svc.is_enabled("beta", Some("user:1")),
"a store error must not turn a known flag off"
);
assert_eq!(svc.store_errors(), 1);
}
#[test]
fn disable_through_the_service_replaces_the_last_known_value() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
svc.enable("kill", None).unwrap();
assert!(svc.is_enabled("kill", None));
store.fail(true);
svc.disable("kill", None).unwrap();
assert!(
!svc.is_enabled("kill", None),
"a successful disable must win over the old last-known value"
);
store.fail(false);
svc.enable("kill", None).unwrap();
svc.disable("kill", None).unwrap();
store.fail(true);
assert!(!svc.is_enabled("kill", None));
}
#[test]
fn read_started_before_a_write_does_not_replace_its_value() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
svc.enable("kill", None).unwrap();
let started = svc.health.read_started();
let stale = store.get("kill").unwrap();
svc.disable("kill", None).unwrap();
svc.health.remember("kill", stale.as_ref(), started);
store.fail(true);
assert!(
!svc.is_enabled("kill", None),
"a read that started before a write must not replace its value"
);
}
#[test]
fn a_write_to_one_flag_keeps_reads_of_other_flags() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
store.enable("b", None).unwrap();
let started = svc.health.read_started();
let read = store.get("b").unwrap();
svc.enable("a", None).unwrap(); svc.health.remember("b", read.as_ref(), started);
store.fail(true);
assert!(svc.is_enabled("b", None), "the read of `b` is still good");
}
#[test]
fn an_older_failed_read_back_does_not_undo_a_newer_write() {
let svc = make_svc();
let mut on = FlagConfig::new("k");
on.enabled = true;
on.rollout_pct = 100;
let disable = svc.health.begin_write("k");
let enable = svc.health.begin_write("k");
svc.health.remember("k", Some(&on), enable);
svc.health.forget("k", true, disable);
let error = FlagStoreError::Backend("down".to_owned());
assert_eq!(svc.health.recall("k", &error), Some(on));
}
struct PausingStore {
inner: ScriptedStore,
entered: std::sync::Mutex<std::sync::mpsc::Sender<()>>,
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
}
impl FlagStore for PausingStore {
fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
self.inner.get(key)
}
fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
self.inner.list()
}
fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.inner.enable(key, actor)?;
self.entered.lock().unwrap().send(()).unwrap();
self.release.lock().unwrap().recv().unwrap();
Ok(())
}
fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
self.inner.disable(key, actor)
}
fn set_rollout(
&self,
key: &str,
pct: u8,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.set_rollout(key, pct, actor)
}
fn allow_actor(
&self,
key: &str,
actor_id: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.allow_actor(key, actor_id, actor)
}
fn add_group(
&self,
key: &str,
group: &str,
actor: Option<&str>,
) -> Result<(), FlagStoreError> {
self.inner.add_group(key, group, actor)
}
fn history(
&self,
key: &str,
limit: usize,
) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
self.inner.history(key, limit)
}
}
#[test]
fn an_older_write_cannot_undo_a_later_disable() {
let (entered_tx, entered) = std::sync::mpsc::channel();
let (release, release_rx) = std::sync::mpsc::channel();
let store = Arc::new(PausingStore {
inner: ScriptedStore::default(),
entered: std::sync::Mutex::new(entered_tx),
release: std::sync::Mutex::new(release_rx),
});
let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
let older = {
let svc = svc.clone();
std::thread::spawn(move || svc.enable("kill", None).unwrap())
};
entered.recv().unwrap();
let later = {
let svc = svc.clone();
std::thread::spawn(move || svc.disable("kill", None).unwrap())
};
std::thread::sleep(std::time::Duration::from_millis(200));
store.inner.fail(true);
release.send(()).unwrap();
older.join().unwrap();
later.join().unwrap();
assert!(
!svc.is_enabled("kill", None),
"the later disable must hold during the outage"
);
}
#[test]
fn failing_store_keeps_a_kill_switch_off() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone()).with_default("payments", true);
store.enable("payments", None).unwrap();
store.disable("payments", None).unwrap();
assert!(!svc.is_enabled("payments", None));
store.fail(true);
assert!(
!svc.is_enabled("payments", None),
"the last-known value wins over the declared default"
);
}
#[test]
fn failing_store_without_last_known_value_serves_declared_default() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone()).with_default("fail_open", true);
store.fail(true);
assert!(svc.is_enabled("fail_open", None));
assert!(!svc.is_enabled("no_default", None));
assert_eq!(svc.store_errors(), 2);
}
#[test]
fn unknown_flag_serves_declared_default() {
let svc = make_svc().with_default("absent", true);
assert!(svc.is_enabled("absent", None));
assert!(!svc.is_enabled("other", None));
assert_eq!(svc.store_errors(), 0);
}
#[test]
fn flag_removed_from_store_forgets_last_known_value() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
store.enable("gone", None).unwrap();
assert!(svc.is_enabled("gone", None));
store.hide(true);
assert!(!svc.is_enabled("gone", None));
store.hide(false);
store.fail(true);
assert!(
!svc.is_enabled("gone", None),
"a flag the store reported absent has no last-known value"
);
}
#[test]
fn service_clones_share_last_known_values() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
store.enable("shared", None).unwrap();
assert!(svc.is_enabled("shared", None));
let per_request = svc.clone();
store.fail(true);
assert!(per_request.is_enabled("shared", None));
assert_eq!(svc.store_errors(), 1, "clones share the error count");
}
#[test]
fn service_preload_delegates_to_the_store() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
svc.preload().unwrap();
assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
make_svc().preload().unwrap();
}
#[tokio::test]
async fn test_app_preloads_the_flag_store_at_build() {
let store = Arc::new(ScriptedStore::default());
let _client = crate::test::TestApp::new()
.with_flag_store(Arc::clone(&store))
.build();
assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn preload_blocking_waits_for_the_load_up_to_the_limit() {
let store = Arc::new(ScriptedStore::default());
let svc = FeatureFlagService::new(store.clone());
preload_blocking(&svc, std::time::Duration::from_secs(10));
assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
store
.preload_delay_ms
.store(300, std::sync::atomic::Ordering::SeqCst);
preload_blocking(&svc, std::time::Duration::from_millis(10));
assert_eq!(
store.preloads.load(std::sync::atomic::Ordering::SeqCst),
1,
"the wait ended before the slow load"
);
}
#[test]
fn feature_flag_guide_describes_polling_not_listen() {
let guide = include_str!("../../docs/guide/feature-flags.md");
assert!(
!guide.contains("All replicas listening on that channel"),
"Autumn does not LISTEN; replicas poll"
);
assert!(guide.contains("spawn_poll_listener"));
assert!(guide.contains("last-known"));
}
#[cfg(feature = "db")]
#[test]
fn pg_flag_store_refuses_a_non_postgres_target() {
use crate::config::DatabaseConfig;
let sqlite = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
..Default::default()
};
assert!(
pg::PgFlagStore::from_database_config(&sqlite).is_none(),
"a SQLite target has no Postgres flag store"
);
let unclassifiable = DatabaseConfig {
primary_url: Some("/var/lib/app.db".to_owned()),
..Default::default()
};
assert!(
pg::PgFlagStore::from_database_config(&unclassifiable).is_none(),
"an unclassifiable target has no Postgres flag store"
);
for url in [
"postgres://localhost/app",
"host=db user=app dbname=app sslmode=require",
] {
let pg_config = DatabaseConfig {
primary_url: Some(url.to_owned()),
..Default::default()
};
assert!(
pg::PgFlagStore::from_database_config(&pg_config).is_some(),
"{url} is a Postgres target"
);
}
}
#[cfg(feature = "db")]
#[test]
fn pg_flag_store_new_refuses_a_non_postgres_target_at_first_use() {
let store = pg::PgFlagStore::new("sqlite://app:hunter2@host/app.db");
let err = store
.refresh()
.expect_err("a SQLite target is refused")
.to_string();
assert!(
err.contains("Postgres"),
"the refusal names the cause: {err}"
);
assert!(!err.contains("hunter2"), "the refusal leaks: {err}");
let store = pg::PgFlagStore::new("postgres://app:hunter2@[::1/db");
let err = store
.refresh()
.expect_err("a malformed target fails")
.to_string();
assert!(!err.contains("hunter2"), "the driver error leaks: {err}");
let store = pg::PgFlagStore::new("postgres://app:p%ss@localhost:1/db");
let err = store.refresh().expect_err("a bad escape fails").to_string();
assert!(!err.contains("p%ss"), "the driver error leaks: {err}");
}
}