1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
57
58use std::collections::HashMap;
59use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
60use std::sync::{Arc, Mutex, PoisonError, RwLock};
61
62use serde::{Deserialize, Serialize};
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct FlagChangeRecord {
69 pub key: String,
71 pub mutation: String,
73 pub actor: Option<String>,
75 pub timestamp_secs: u64,
77}
78
79impl FlagChangeRecord {
80 fn now(key: &str, mutation: impl Into<String>, actor: Option<&str>) -> Self {
81 let timestamp_secs = crate::time::clock_unix_secs(&crate::time::AmbientClock);
82 Self {
83 key: key.to_owned(),
84 mutation: mutation.into(),
85 actor: actor.map(str::to_owned),
86 timestamp_secs,
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FlagConfig {
104 pub key: String,
106 pub description: Option<String>,
108 pub enabled: bool,
110 pub rollout_pct: u8,
112 pub actor_allowlist: Vec<String>,
114 pub group_allowlist: Vec<String>,
116}
117
118impl FlagConfig {
119 #[must_use]
121 pub fn new(key: impl Into<String>) -> Self {
122 Self {
123 key: key.into(),
124 description: None,
125 enabled: false,
126 rollout_pct: 0,
127 actor_allowlist: Vec::new(),
128 group_allowlist: Vec::new(),
129 }
130 }
131}
132
133pub type GroupResolver = Arc<dyn Fn(&str, &str) -> bool + Send + Sync + 'static>;
140
141#[derive(Debug, thiserror::Error)]
145pub enum FlagStoreError {
146 #[error("flag store backend error: {0}")]
148 Backend(String),
149}
150
151pub trait FlagStore: Send + Sync + 'static {
156 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError>;
162
163 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError>;
169
170 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
178
179 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
188
189 fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError>;
198
199 fn allow_actor(
205 &self,
206 key: &str,
207 actor_id: &str,
208 actor: Option<&str>,
209 ) -> Result<(), FlagStoreError>;
210
211 fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
217
218 fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError>;
224
225 fn preload(&self) -> Result<(), FlagStoreError> {
235 Ok(())
236 }
237}
238
239impl FlagStore for Box<dyn FlagStore> {
241 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
242 (**self).get(key)
243 }
244 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
245 (**self).list()
246 }
247 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
248 (**self).enable(key, actor)
249 }
250 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
251 (**self).disable(key, actor)
252 }
253 fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
254 (**self).set_rollout(key, pct, actor)
255 }
256 fn allow_actor(
257 &self,
258 key: &str,
259 actor_id: &str,
260 actor: Option<&str>,
261 ) -> Result<(), FlagStoreError> {
262 (**self).allow_actor(key, actor_id, actor)
263 }
264 fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
265 (**self).add_group(key, group, actor)
266 }
267 fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
268 (**self).history(key, limit)
269 }
270 fn preload(&self) -> Result<(), FlagStoreError> {
271 (**self).preload()
272 }
273}
274
275impl<T: FlagStore + ?Sized> FlagStore for Arc<T> {
290 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
291 (**self).get(key)
292 }
293 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
294 (**self).list()
295 }
296 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
297 (**self).enable(key, actor)
298 }
299 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
300 (**self).disable(key, actor)
301 }
302 fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
303 (**self).set_rollout(key, pct, actor)
304 }
305 fn allow_actor(
306 &self,
307 key: &str,
308 actor_id: &str,
309 actor: Option<&str>,
310 ) -> Result<(), FlagStoreError> {
311 (**self).allow_actor(key, actor_id, actor)
312 }
313 fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
314 (**self).add_group(key, group, actor)
315 }
316 fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
317 (**self).history(key, limit)
318 }
319 fn preload(&self) -> Result<(), FlagStoreError> {
320 (**self).preload()
321 }
322}
323
324#[derive(Debug, Default)]
331pub struct InMemoryFlagStore {
332 flags: RwLock<HashMap<String, FlagConfig>>,
333 history: RwLock<HashMap<String, Vec<FlagChangeRecord>>>,
334}
335
336impl InMemoryFlagStore {
337 #[must_use]
339 pub fn new() -> Self {
340 Self::default()
341 }
342
343 fn upsert(&self, key: &str, f: impl FnOnce(&mut FlagConfig)) {
344 let mut flags = self.flags.write().unwrap();
345 let flag = flags
346 .entry(key.to_owned())
347 .or_insert_with(|| FlagConfig::new(key));
348 f(flag);
349 drop(flags);
350 }
351
352 fn record(&self, record: FlagChangeRecord) {
353 self.history
354 .write()
355 .unwrap()
356 .entry(record.key.clone())
357 .or_default()
358 .push(record);
359 }
360}
361
362impl FlagStore for InMemoryFlagStore {
363 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
364 Ok(self.flags.read().unwrap().get(key).cloned())
365 }
366
367 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
368 let mut flags: Vec<FlagConfig> = self.flags.read().unwrap().values().cloned().collect();
369 flags.sort_by(|a, b| a.key.cmp(&b.key));
370 Ok(flags)
371 }
372
373 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
374 self.upsert(key, |f| {
375 f.enabled = true;
376 f.rollout_pct = 100;
377 });
378 self.record(FlagChangeRecord::now(key, "enabled", actor));
379 Ok(())
380 }
381
382 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
383 self.upsert(key, |f| {
384 f.enabled = false;
385 });
386 self.record(FlagChangeRecord::now(key, "disabled", actor));
387 Ok(())
388 }
389
390 fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
391 let pct = pct.min(100);
392 self.upsert(key, |f| {
393 f.enabled = true;
394 f.rollout_pct = pct;
395 });
396 self.record(FlagChangeRecord::now(key, format!("rollout={pct}"), actor));
397 Ok(())
398 }
399
400 fn allow_actor(
401 &self,
402 key: &str,
403 actor_id: &str,
404 actor: Option<&str>,
405 ) -> Result<(), FlagStoreError> {
406 self.upsert(key, |f| {
407 if !f.enabled {
408 f.rollout_pct = 0;
412 }
413 f.enabled = true;
414 if !f.actor_allowlist.contains(&actor_id.to_owned()) {
415 f.actor_allowlist.push(actor_id.to_owned());
416 }
417 });
418 self.record(FlagChangeRecord::now(
419 key,
420 format!("allowed_actor={actor_id}"),
421 actor,
422 ));
423 Ok(())
424 }
425
426 fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
427 self.upsert(key, |f| {
428 if !f.enabled {
429 f.rollout_pct = 0;
431 }
432 f.enabled = true;
433 if !f.group_allowlist.contains(&group.to_owned()) {
434 f.group_allowlist.push(group.to_owned());
435 }
436 });
437 self.record(FlagChangeRecord::now(
438 key,
439 format!("added_group={group}"),
440 actor,
441 ));
442 Ok(())
443 }
444
445 fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
446 Ok(self
447 .history
448 .read()
449 .unwrap()
450 .get(key)
451 .map(|records| records.iter().rev().take(limit).cloned().collect())
452 .unwrap_or_default())
453 }
454}
455
456#[cfg(feature = "postgres")]
466pub mod pg {
467 use super::{FlagChangeRecord, FlagConfig, FlagStore, FlagStoreError};
468 use diesel::connection::SimpleConnection;
469 use diesel::prelude::*;
470 use std::collections::HashMap;
471 use std::sync::atomic::AtomicU32;
472 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
473 use std::sync::{Arc, Mutex, PoisonError, RwLock};
474 use std::time::{Duration, Instant};
475
476 const FLAG_COLUMNS: &str =
478 "key, description, enabled, rollout_pct, actor_allowlist, group_allowlist";
479
480 const CONNECT_TIMEOUT_SECS: u64 = 5;
482
483 pub(crate) const STATEMENT_TIMEOUT_MS: u64 = 5_000;
485
486 const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
488
489 pub(crate) fn with_connect_timeout(url: &str) -> String {
492 if has_connect_timeout(url) {
493 url.to_owned()
494 } else if url.starts_with("postgres://") || url.starts_with("postgresql://") {
495 let separator = if url.contains('?') { '&' } else { '?' };
496 format!("{url}{separator}connect_timeout={CONNECT_TIMEOUT_SECS}")
497 } else {
498 format!("{url} connect_timeout={CONNECT_TIMEOUT_SECS}")
500 }
501 }
502
503 fn has_connect_timeout(url: &str) -> bool {
505 if url.starts_with("postgres://") || url.starts_with("postgresql://") {
506 let query = url.split_once('?').map_or("", |(_, query)| query);
508 let query = query.split_once('#').map_or(query, |(query, _)| query);
509 return query
510 .split('&')
511 .any(|pair| pair.split('=').next() == Some("connect_timeout"));
512 }
513 conninfo_keys(url)
514 .iter()
515 .any(|key| key == "connect_timeout")
516 }
517
518 fn conninfo_keys(conninfo: &str) -> Vec<String> {
521 let mut keys = Vec::new();
522 let mut chars = conninfo.chars().peekable();
523 loop {
524 while chars.next_if(|c| c.is_whitespace()).is_some() {}
525 let mut key = String::new();
526 while let Some(c) = chars.next_if(|c| *c != '=' && !c.is_whitespace()) {
527 key.push(c);
528 }
529 if key.is_empty() {
530 return keys;
531 }
532 while chars.next_if(|c| c.is_whitespace()).is_some() {}
533 if chars.next_if_eq(&'=').is_none() {
534 return keys;
536 }
537 keys.push(key);
538 while chars.next_if(|c| c.is_whitespace()).is_some() {}
539 if chars.next_if_eq(&'\'').is_some() {
541 while let Some(c) = chars.next() {
542 match c {
543 '\\' => {
544 chars.next();
545 }
546 '\'' => break,
547 _ => {}
548 }
549 }
550 } else {
551 while let Some(c) = chars.next_if(|c| !c.is_whitespace()) {
552 if c == '\\' {
553 chars.next();
554 }
555 }
556 }
557 }
558 }
559
560 pub(crate) fn retry_delay(base: Duration, failures: u32) -> Duration {
563 if failures == 0 {
564 return base;
565 }
566 base.saturating_mul(2_u32.saturating_pow(failures.min(16)))
567 .min(base.max(MAX_RETRY_DELAY))
568 }
569
570 #[derive(Debug, Default)]
572 struct Snapshot {
573 flags: HashMap<String, FlagConfig>,
574 loaded: bool,
576 checked_at: Option<Instant>,
578 }
579
580 struct Shared {
582 database_url: String,
583 connect_url: String,
585 cache_ttl: Duration,
586 snapshot: RwLock<Snapshot>,
587 generation: AtomicU64,
590 refreshing: AtomicBool,
592 refresh_conn: Mutex<Option<diesel::PgConnection>>,
595 write_lock: Mutex<()>,
598 refresh_errors: AtomicU64,
599 failures: AtomicU32,
601 #[cfg(test)]
602 connect_threads: Mutex<Vec<std::thread::ThreadId>>,
603 }
604
605 impl std::fmt::Debug for Shared {
606 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608 f.debug_struct("PgFlagStore")
609 .field("cache_ttl", &self.cache_ttl)
610 .field("refresh_errors", &self.refresh_errors)
611 .finish_non_exhaustive()
612 }
613 }
614
615 struct RefreshSlot(Arc<Shared>);
618
619 impl Drop for RefreshSlot {
620 fn drop(&mut self) {
621 self.0.refreshing.store(false, Ordering::Release);
622 }
623 }
624
625 impl Shared {
626 fn connect(&self) -> Result<diesel::PgConnection, FlagStoreError> {
627 #[cfg(test)]
628 self.connect_threads
629 .lock()
630 .unwrap_or_else(PoisonError::into_inner)
631 .push(std::thread::current().id());
632 crate::db_url::require_postgres_target(&self.database_url, "PgFlagStore")
633 .map_err(FlagStoreError::Backend)?;
634 let mut conn = diesel::PgConnection::establish(&self.connect_url).map_err(|e| {
636 FlagStoreError::Backend(crate::db_url::redact_driver_error(
637 &e.to_string(),
638 &self.database_url,
639 ))
640 })?;
641 conn.batch_execute(&format!("SET statement_timeout = {STATEMENT_TIMEOUT_MS}"))
642 .map_err(|e| FlagStoreError::Backend(e.to_string()))?;
643 Ok(conn)
644 }
645
646 fn is_stale(&self) -> bool {
649 let wait = retry_delay(self.cache_ttl, self.failures.load(Ordering::Relaxed));
650 let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
651 snapshot.checked_at.is_none_or(|checked| {
652 checked
653 .checked_add(wait)
654 .is_some_and(|due| crate::time::system_instant() >= due)
655 })
656 }
657
658 fn read(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
659 let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
660 match snapshot.flags.get(key) {
661 Some(flag) => Ok(Some(flag.clone())),
662 None if snapshot.loaded => Ok(None),
663 None => Err(FlagStoreError::Backend(
664 "flag snapshot not loaded yet; call PgFlagStore::refresh at startup".to_owned(),
665 )),
666 }
667 }
668
669 fn begin_refresh(self: &Arc<Self>) -> Option<RefreshSlot> {
671 self.refreshing
672 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
673 .ok()
674 .map(|_| RefreshSlot(Arc::clone(self)))
675 }
676
677 fn reload(&self) -> Result<(), FlagStoreError> {
679 self.load().map(drop)
680 }
681
682 fn load(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
685 let mut conn = self
686 .refresh_conn
687 .lock()
688 .unwrap_or_else(PoisonError::into_inner);
689 let started = self.generation.load(Ordering::Acquire);
690 self.snapshot
691 .write()
692 .unwrap_or_else(PoisonError::into_inner)
693 .checked_at = Some(crate::time::system_instant());
694 let loaded = conn
695 .take()
696 .map_or_else(|| self.connect(), Ok)
697 .and_then(|mut open| {
698 let flags = load_all(&mut open)?;
699 *conn = Some(open);
701 Ok(flags)
702 });
703 if let Ok(flags) = &loaded {
704 self.install(flags.clone(), started);
706 }
707 drop(conn);
708 match loaded {
709 Ok(flags) => {
710 if self.failures.swap(0, Ordering::AcqRel) > 0 {
711 tracing::info!("feature flag refresh recovered");
712 }
713 Ok(flags)
714 }
715 Err(error) => {
716 self.refresh_errors.fetch_add(1, Ordering::Relaxed);
717 if self.failures.fetch_add(1, Ordering::AcqRel) == 0 {
718 tracing::warn!(
719 %error,
720 "feature flag refresh failed; serving the last-known snapshot"
721 );
722 } else {
723 tracing::debug!(%error, "feature flag refresh failed again");
724 }
725 Err(error)
726 }
727 }
728 }
729
730 fn install(&self, flags: Vec<FlagConfig>, started: u64) -> bool {
733 let mut snapshot = self
734 .snapshot
735 .write()
736 .unwrap_or_else(PoisonError::into_inner);
737 if self.generation.load(Ordering::Acquire) != started {
738 snapshot.checked_at = None;
740 return false;
741 }
742 snapshot.flags = flags.into_iter().map(|f| (f.key.clone(), f)).collect();
743 snapshot.loaded = true;
744 true
745 }
746
747 fn apply_local(&self, flag: FlagConfig) {
752 let mut snapshot = self
753 .snapshot
754 .write()
755 .unwrap_or_else(PoisonError::into_inner);
756 self.generation.fetch_add(1, Ordering::AcqRel);
757 snapshot.flags.insert(flag.key.clone(), flag);
758 snapshot.checked_at = None;
759 }
760 }
761
762 fn load_all(conn: &mut diesel::PgConnection) -> Result<Vec<FlagConfig>, FlagStoreError> {
763 diesel::sql_query(format!(
764 "SELECT {FLAG_COLUMNS} FROM autumn_feature_flags ORDER BY key"
765 ))
766 .load::<FlagRow>(conn)
767 .map(|rows| rows.into_iter().map(FlagRow::into_config).collect())
768 .map_err(|e| FlagStoreError::Backend(e.to_string()))
769 }
770
771 #[derive(Debug)]
807 pub struct PgFlagStore {
808 shared: Arc<Shared>,
809 }
810
811 impl Clone for PgFlagStore {
812 fn clone(&self) -> Self {
814 Self::with_cache_ttl(self.shared.database_url.clone(), self.shared.cache_ttl)
815 }
816 }
817
818 impl PgFlagStore {
819 pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(1);
821
822 #[must_use]
824 pub fn new(database_url: impl Into<String>) -> Self {
825 Self::with_cache_ttl(database_url, Self::DEFAULT_CACHE_TTL)
826 }
827
828 #[must_use]
831 pub fn with_cache_ttl(database_url: impl Into<String>, cache_ttl: Duration) -> Self {
832 let database_url = database_url.into();
833 Self {
834 shared: Arc::new(Shared {
835 connect_url: with_connect_timeout(&database_url),
836 database_url,
837 cache_ttl,
838 snapshot: RwLock::new(Snapshot::default()),
839 generation: AtomicU64::new(0),
840 refreshing: AtomicBool::new(false),
841 refresh_conn: Mutex::new(None),
842 write_lock: Mutex::new(()),
843 refresh_errors: AtomicU64::new(0),
844 failures: AtomicU32::new(0),
845 #[cfg(test)]
846 connect_threads: Mutex::new(Vec::new()),
847 }),
848 }
849 }
850
851 #[must_use]
860 pub fn from_database_config(config: &crate::config::DatabaseConfig) -> Option<Self> {
861 config.effective_primary_postgres_url().map(Self::new)
862 }
863
864 pub fn refresh(&self) -> Result<(), FlagStoreError> {
872 self.shared.reload()
873 }
874
875 #[must_use]
877 pub fn refresh_errors(&self) -> u64 {
878 self.shared.refresh_errors.load(Ordering::Relaxed)
879 }
880
881 fn upsert_flag(
882 conn: &mut diesel::PgConnection,
883 key: &str,
884 ) -> Result<(), diesel::result::Error> {
885 diesel::sql_query(
886 "INSERT INTO autumn_feature_flags (key) VALUES ($1) \
887 ON CONFLICT (key) DO NOTHING",
888 )
889 .bind::<diesel::sql_types::Text, _>(key)
890 .execute(conn)?;
891 Ok(())
892 }
893
894 fn notify(conn: &mut diesel::PgConnection, key: &str) -> Result<(), diesel::result::Error> {
895 diesel::sql_query("SELECT pg_notify('autumn_flags', $1)")
896 .bind::<diesel::sql_types::Text, _>(key)
897 .execute(conn)?;
898 Ok(())
899 }
900
901 fn write(
906 &self,
907 key: &str,
908 mutation: &str,
909 actor: Option<&str>,
910 update: impl FnOnce(&mut diesel::PgConnection) -> QueryResult<FlagRow>,
911 ) -> Result<(), FlagStoreError> {
912 let _ordered = self
913 .shared
914 .write_lock
915 .lock()
916 .unwrap_or_else(PoisonError::into_inner);
917 let mut conn = self.shared.connect()?;
918 let row = conn
919 .transaction::<FlagRow, diesel::result::Error, _>(|conn| {
920 Self::upsert_flag(conn, key)?;
921 let row = update(conn)?;
922 diesel::sql_query(
923 "INSERT INTO feature_flag_changes (key, mutation, actor) \
924 VALUES ($1, $2, $3)",
925 )
926 .bind::<diesel::sql_types::Text, _>(key)
927 .bind::<diesel::sql_types::Text, _>(mutation)
928 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
929 actor.map(str::to_owned),
930 )
931 .execute(conn)?;
932 Self::notify(conn, key)?;
933 Ok(row)
934 })
935 .map_err(|e| FlagStoreError::Backend(e.to_string()))?;
936 self.shared.apply_local(row.into_config());
937 Ok(())
938 }
939
940 #[allow(
953 clippy::must_use_candidate,
954 reason = "the thread runs detached; callers drop the handle"
955 )]
956 pub fn spawn_poll_listener(
957 store: Arc<Self>,
958 poll_interval: Duration,
959 ) -> std::thread::JoinHandle<()> {
960 let shared = Arc::downgrade(&store.shared);
961 drop(store);
962 std::thread::spawn(move || {
963 while let Some(shared) = shared.upgrade() {
964 if let Some(slot) = shared.begin_refresh() {
965 let reload = std::panic::AssertUnwindSafe(|| slot.0.reload());
968 if std::panic::catch_unwind(reload).is_err() {
969 tracing::warn!("feature flag refresh panicked");
970 }
971 }
972 let wait = retry_delay(poll_interval, shared.failures.load(Ordering::Relaxed));
973 drop(shared);
974 std::thread::sleep(wait);
975 }
976 })
977 }
978
979 #[cfg(test)]
980 fn install_for_test(&self, flags: Vec<FlagConfig>) {
981 assert!(self.install(flags, self.write_generation()));
982 self.shared
983 .snapshot
984 .write()
985 .unwrap_or_else(PoisonError::into_inner)
986 .checked_at = Some(crate::time::system_instant());
987 }
988
989 #[cfg(test)]
990 fn connect_threads_for_test(&self) -> Vec<std::thread::ThreadId> {
991 self.shared
992 .connect_threads
993 .lock()
994 .unwrap_or_else(PoisonError::into_inner)
995 .clone()
996 }
997
998 #[cfg(test)]
999 fn refreshing_for_test(&self) -> bool {
1000 self.shared.refreshing.load(Ordering::Acquire)
1001 }
1002
1003 #[cfg(test)]
1004 fn write_generation(&self) -> u64 {
1005 self.shared.generation.load(Ordering::Acquire)
1006 }
1007
1008 #[cfg(test)]
1009 fn apply_local(&self, flag: FlagConfig) {
1010 self.shared.apply_local(flag);
1011 }
1012
1013 #[cfg(test)]
1014 fn install(&self, flags: Vec<FlagConfig>, generation: u64) -> bool {
1015 self.shared.install(flags, generation)
1016 }
1017 }
1018
1019 #[derive(diesel::QueryableByName)]
1020 struct FlagRow {
1021 #[diesel(sql_type = diesel::sql_types::Text)]
1022 key: String,
1023 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1024 description: Option<String>,
1025 #[diesel(sql_type = diesel::sql_types::Bool)]
1026 enabled: bool,
1027 #[diesel(sql_type = diesel::sql_types::SmallInt)]
1028 rollout_pct: i16,
1029 #[diesel(sql_type = diesel::sql_types::Text)]
1030 actor_allowlist: String,
1031 #[diesel(sql_type = diesel::sql_types::Text)]
1032 group_allowlist: String,
1033 }
1034
1035 impl FlagRow {
1036 fn into_config(self) -> FlagConfig {
1037 let actor_allowlist: Vec<String> =
1038 serde_json::from_str(&self.actor_allowlist).unwrap_or_default();
1039 let group_allowlist: Vec<String> =
1040 serde_json::from_str(&self.group_allowlist).unwrap_or_default();
1041 FlagConfig {
1042 key: self.key,
1043 description: self.description,
1044 enabled: self.enabled,
1045 rollout_pct: u8::try_from(self.rollout_pct.clamp(0, 100)).unwrap_or(0),
1046 actor_allowlist,
1047 group_allowlist,
1048 }
1049 }
1050 }
1051
1052 #[derive(diesel::QueryableByName)]
1053 struct HistoryRow {
1054 #[diesel(sql_type = diesel::sql_types::Text)]
1055 key: String,
1056 #[diesel(sql_type = diesel::sql_types::Text)]
1057 mutation: String,
1058 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1059 actor: Option<String>,
1060 #[diesel(sql_type = diesel::sql_types::BigInt)]
1061 timestamp_secs: i64,
1062 }
1063
1064 impl FlagStore for PgFlagStore {
1065 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
1066 if self.shared.is_stale()
1067 && let Some(slot) = self.shared.begin_refresh()
1068 {
1069 match tokio::runtime::Handle::try_current() {
1070 Ok(_) => {
1072 drop(crate::time::spawn_blocking(move || {
1073 let _ = slot.0.reload();
1075 }));
1076 }
1077 Err(_) => {
1079 let _ = slot.0.reload();
1080 }
1081 }
1082 }
1083 self.shared.read(key)
1084 }
1085
1086 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
1087 self.shared.load()
1088 }
1089
1090 fn preload(&self) -> Result<(), FlagStoreError> {
1091 self.refresh()
1092 }
1093
1094 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1095 self.write(key, "enabled", actor, |conn| {
1096 diesel::sql_query(format!(
1097 "UPDATE autumn_feature_flags \
1098 SET enabled = true, rollout_pct = 100, updated_at = NOW() \
1099 WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1100 ))
1101 .bind::<diesel::sql_types::Text, _>(key)
1102 .get_result(conn)
1103 })
1104 }
1105
1106 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1107 self.write(key, "disabled", actor, |conn| {
1108 diesel::sql_query(format!(
1109 "UPDATE autumn_feature_flags SET enabled = false, updated_at = NOW() \
1110 WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1111 ))
1112 .bind::<diesel::sql_types::Text, _>(key)
1113 .get_result(conn)
1114 })
1115 }
1116
1117 fn set_rollout(
1118 &self,
1119 key: &str,
1120 pct: u8,
1121 actor: Option<&str>,
1122 ) -> Result<(), FlagStoreError> {
1123 let pct = i16::from(pct.min(100));
1124 self.write(key, &format!("rollout={pct}"), actor, |conn| {
1125 diesel::sql_query(format!(
1126 "UPDATE autumn_feature_flags \
1127 SET enabled = true, rollout_pct = $2, updated_at = NOW() \
1128 WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1129 ))
1130 .bind::<diesel::sql_types::Text, _>(key)
1131 .bind::<diesel::sql_types::SmallInt, _>(pct)
1132 .get_result(conn)
1133 })
1134 }
1135
1136 fn allow_actor(
1137 &self,
1138 key: &str,
1139 actor_id: &str,
1140 actor: Option<&str>,
1141 ) -> Result<(), FlagStoreError> {
1142 self.write(key, &format!("allowed_actor={actor_id}"), actor, |conn| {
1143 diesel::sql_query(format!(
1144 "UPDATE autumn_feature_flags \
1147 SET enabled = true, \
1148 rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
1149 actor_allowlist = (
1150 SELECT json_agg(DISTINCT elem) \
1151 FROM (
1152 SELECT jsonb_array_elements_text(actor_allowlist::jsonb) AS elem \
1153 UNION SELECT $2
1154 ) t \
1155 )::text, \
1156 updated_at = NOW() \
1157 WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1158 ))
1159 .bind::<diesel::sql_types::Text, _>(key)
1160 .bind::<diesel::sql_types::Text, _>(actor_id)
1161 .get_result(conn)
1162 })
1163 }
1164
1165 fn add_group(
1166 &self,
1167 key: &str,
1168 group: &str,
1169 actor: Option<&str>,
1170 ) -> Result<(), FlagStoreError> {
1171 self.write(key, &format!("added_group={group}"), actor, |conn| {
1172 diesel::sql_query(format!(
1173 "UPDATE autumn_feature_flags \
1175 SET enabled = true, \
1176 rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
1177 group_allowlist = (
1178 SELECT json_agg(DISTINCT elem) \
1179 FROM (
1180 SELECT jsonb_array_elements_text(group_allowlist::jsonb) AS elem \
1181 UNION SELECT $2
1182 ) t \
1183 )::text, \
1184 updated_at = NOW() \
1185 WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1186 ))
1187 .bind::<diesel::sql_types::Text, _>(key)
1188 .bind::<diesel::sql_types::Text, _>(group)
1189 .get_result(conn)
1190 })
1191 }
1192
1193 fn history(
1194 &self,
1195 key: &str,
1196 limit: usize,
1197 ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
1198 let limit = i64::try_from(limit).unwrap_or(i64::MAX);
1199 let mut conn = self.shared.connect()?;
1200 diesel::sql_query(
1201 "SELECT key, mutation, actor, \
1202 EXTRACT(EPOCH FROM changed_at)::bigint AS timestamp_secs \
1203 FROM feature_flag_changes \
1204 WHERE key = $1 \
1205 ORDER BY changed_at DESC LIMIT $2",
1206 )
1207 .bind::<diesel::sql_types::Text, _>(key)
1208 .bind::<diesel::sql_types::BigInt, _>(limit)
1209 .load::<HistoryRow>(&mut conn)
1210 .map(|rows| {
1211 rows.into_iter()
1212 .map(|r| FlagChangeRecord {
1213 key: r.key,
1214 mutation: r.mutation,
1215 actor: r.actor,
1216 timestamp_secs: u64::try_from(r.timestamp_secs).unwrap_or(0),
1217 })
1218 .collect()
1219 })
1220 .map_err(|e| FlagStoreError::Backend(e.to_string()))
1221 }
1222 }
1223
1224 #[cfg(test)]
1225 mod pg_tests {
1226 use super::*;
1227
1228 const DEAD_URL: &str = "postgres://autumn@127.0.0.1:1/autumn?connect_timeout=2";
1230
1231 fn enabled(key: &str) -> FlagConfig {
1232 let mut flag = FlagConfig::new(key);
1233 flag.enabled = true;
1234 flag.rollout_pct = 100;
1235 flag
1236 }
1237
1238 fn current_thread_runtime() -> tokio::runtime::Runtime {
1239 tokio::runtime::Builder::new_current_thread()
1240 .enable_all()
1241 .build()
1242 .unwrap()
1243 }
1244
1245 async fn settle(store: &PgFlagStore) {
1247 for _ in 0..1000 {
1248 if !store.refreshing_for_test() {
1249 return;
1250 }
1251 tokio::time::sleep(Duration::from_millis(10)).await;
1252 }
1253 panic!("background refresh did not finish");
1254 }
1255
1256 #[test]
1257 fn pg_get_serves_snapshot_when_refresh_fails() {
1258 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
1259 store.install_for_test(vec![enabled("beta")]);
1260
1261 assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1263 assert_eq!(store.get("absent").unwrap(), None);
1264 assert_eq!(store.refresh_errors(), 2);
1265 }
1266
1267 #[test]
1268 fn pg_get_within_ttl_does_not_connect() {
1269 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1270 store.install_for_test(vec![enabled("beta")]);
1271 assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1272 assert!(store.connect_threads_for_test().is_empty());
1273 }
1274
1275 #[test]
1276 fn pg_get_on_current_thread_runtime_never_connects_on_the_worker() {
1277 let store = std::sync::Arc::new(PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO));
1278 store.install_for_test(vec![enabled("beta")]);
1279 let svc = crate::feature_flags::FeatureFlagService::new(store.clone());
1280
1281 current_thread_runtime().block_on(async {
1282 let worker = std::thread::current().id();
1283 assert!(svc.is_enabled("beta", Some("user:1")));
1284 settle(&store).await;
1285
1286 let threads = store.connect_threads_for_test();
1287 assert!(!threads.is_empty(), "the stale snapshot is refreshed");
1288 assert!(
1289 !threads.contains(&worker),
1290 "flag evaluation must not connect on the Tokio worker"
1291 );
1292 });
1293 assert!(store.refresh_errors() >= 1);
1294 }
1295
1296 #[test]
1297 fn pg_cold_get_on_runtime_errors_and_loads_in_background() {
1298 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
1299 current_thread_runtime().block_on(async {
1300 let worker = std::thread::current().id();
1301 assert!(store.get("beta").is_err(), "no snapshot yet");
1302 settle(&store).await;
1303 let threads = store.connect_threads_for_test();
1304 assert!(!threads.is_empty());
1305 assert!(!threads.contains(&worker));
1306 });
1307 }
1308
1309 #[test]
1310 fn pg_refresh_older_than_a_local_write_is_discarded() {
1311 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1312 store.install_for_test(vec![]);
1313
1314 let started = store.write_generation();
1315 store.apply_local(enabled("beta"));
1316 assert!(!store.install(vec![FlagConfig::new("beta")], started));
1318 assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1319
1320 assert!(store.install(vec![FlagConfig::new("beta")], store.write_generation()));
1321 assert_eq!(store.get("beta").unwrap(), Some(FlagConfig::new("beta")));
1322 }
1323
1324 #[test]
1325 fn pg_refresh_reports_backend_error() {
1326 let store = PgFlagStore::new(DEAD_URL);
1327 assert!(store.refresh().is_err());
1328 assert_eq!(store.refresh_errors(), 1);
1329 assert!(store.preload().is_err());
1330 }
1331
1332 #[test]
1333 fn pg_poll_listener_stops_when_the_store_drops() {
1334 let store = std::sync::Arc::new(PgFlagStore::new(DEAD_URL));
1335 let handle = PgFlagStore::spawn_poll_listener(
1336 std::sync::Arc::clone(&store),
1337 Duration::from_millis(10),
1338 );
1339 drop(store);
1340 for _ in 0..1000 {
1341 if handle.is_finished() {
1342 return;
1343 }
1344 std::thread::sleep(Duration::from_millis(10));
1345 }
1346 panic!("poll listener outlived its store");
1347 }
1348
1349 #[test]
1350 fn connect_url_gets_a_connect_timeout() {
1351 assert_eq!(
1352 with_connect_timeout("postgres://h/db"),
1353 "postgres://h/db?connect_timeout=5"
1354 );
1355 assert_eq!(
1356 with_connect_timeout("postgresql://h/db?sslmode=require"),
1357 "postgresql://h/db?sslmode=require&connect_timeout=5"
1358 );
1359 assert_eq!(
1360 with_connect_timeout("host=db dbname=app"),
1361 "host=db dbname=app connect_timeout=5"
1362 );
1363 assert_eq!(DEAD_URL, with_connect_timeout(DEAD_URL));
1365 assert_eq!(
1366 with_connect_timeout("host=db connect_timeout = 3"),
1367 "host=db connect_timeout = 3"
1368 );
1369 assert_eq!(
1371 with_connect_timeout("postgres://u:connect_timeout@h/db"),
1372 "postgres://u:connect_timeout@h/db?connect_timeout=5"
1373 );
1374 assert_eq!(
1375 with_connect_timeout("postgres://h/db?application_name=connect_timeout"),
1376 "postgres://h/db?application_name=connect_timeout&connect_timeout=5"
1377 );
1378 assert_eq!(
1379 with_connect_timeout("host=db application_name=connect_timeout"),
1380 "host=db application_name=connect_timeout connect_timeout=5"
1381 );
1382 assert_eq!(
1384 with_connect_timeout("host=db application_name='foo connect_timeout=999'"),
1385 "host=db application_name='foo connect_timeout=999' connect_timeout=5"
1386 );
1387 assert_eq!(
1388 with_connect_timeout(r"host=db password='it\'s connect_timeout=1'"),
1389 r"host=db password='it\'s connect_timeout=1' connect_timeout=5"
1390 );
1391 assert_eq!(
1392 with_connect_timeout("host=db connect_timeout='7'"),
1393 "host=db connect_timeout='7'"
1394 );
1395 }
1396
1397 #[test]
1398 fn retry_delay_doubles_up_to_the_cap() {
1399 let second = Duration::from_secs(1);
1400 assert_eq!(retry_delay(second, 0), second);
1401 assert_eq!(retry_delay(second, 1), Duration::from_secs(2));
1402 assert_eq!(retry_delay(second, 3), Duration::from_secs(8));
1403 assert_eq!(retry_delay(second, 40), MAX_RETRY_DELAY);
1404 assert_eq!(retry_delay(Duration::ZERO, 5), Duration::ZERO);
1405 let minute = Duration::from_secs(60);
1407 assert_eq!(retry_delay(minute, 4), minute);
1408 }
1409
1410 #[test]
1411 fn pg_store_debug_hides_the_database_password() {
1412 let store = PgFlagStore::new("postgres://app:s3cret@db/app");
1413 assert!(!format!("{store:?}").contains("s3cret"));
1414 }
1415
1416 #[test]
1417 fn pg_list_serves_nothing_new_when_the_database_fails() {
1418 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1419 store.install_for_test(vec![enabled("beta")]);
1420 assert!(store.list().is_err());
1421 assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1422 }
1423
1424 #[test]
1425 fn pg_store_exposes_database_url() {
1426 let store = PgFlagStore::new("postgres://localhost/myapp");
1427 assert_eq!(store.shared.database_url, "postgres://localhost/myapp");
1428 }
1429
1430 #[test]
1431 fn pg_store_default_cache_ttl_is_one_second() {
1432 let store = PgFlagStore::new("postgres://localhost/myapp");
1433 assert_eq!(store.shared.cache_ttl, PgFlagStore::DEFAULT_CACHE_TTL);
1434 }
1435
1436 #[test]
1437 fn pg_store_with_cache_ttl_sets_custom_ttl() {
1438 let ttl = Duration::from_secs(30);
1439 let store = PgFlagStore::with_cache_ttl("postgres://localhost/myapp", ttl);
1440 assert_eq!(store.shared.cache_ttl, ttl);
1441 }
1442
1443 #[test]
1444 fn pg_store_clone_has_independent_snapshot() {
1445 let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1447 store.install_for_test(vec![enabled("cached")]);
1448 let cloned = store.clone();
1449 assert!(cloned.get("cached").is_err(), "a clone starts empty");
1450 assert_eq!(store.get("cached").unwrap(), Some(enabled("cached")));
1451 }
1452 }
1453}
1454
1455fn fnv1a_64(parts: &[&[u8]]) -> u64 {
1464 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
1465 const FNV_PRIME: u64 = 1_099_511_628_211;
1466 let mut hash = FNV_OFFSET;
1467 for &byte in parts.iter().copied().flatten() {
1468 hash ^= u64(byte);
1469 hash = hash.wrapping_mul(FNV_PRIME);
1470 }
1471 hash
1472}
1473
1474#[allow(clippy::cast_lossless)]
1475const fn u64(v: u8) -> u64 {
1476 v as u64
1477}
1478
1479#[must_use]
1484pub fn rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
1485 let hash = fnv1a_64(&[flag_key.as_bytes(), b":".as_slice(), actor_id.as_bytes()]);
1488 u8::try_from(hash % 100).unwrap_or(0)
1489}
1490
1491#[derive(Clone)]
1510pub struct FeatureFlagService {
1511 store: Arc<dyn FlagStore>,
1512 group_resolver: Option<GroupResolver>,
1513 defaults: Arc<HashMap<String, bool>>,
1514 health: Arc<StoreHealth>,
1515}
1516
1517#[derive(Default)]
1519struct StoreHealth {
1520 known: RwLock<Known>,
1521 errors: AtomicU64,
1522 failing: AtomicBool,
1524 writes: AtomicU64,
1526 write_order: Mutex<()>,
1529}
1530
1531#[derive(Default)]
1533struct Known {
1534 values: HashMap<String, FlagConfig>,
1535 last_write: HashMap<String, u64>,
1537}
1538
1539impl Known {
1540 fn written_after(&self, key: &str, started: u64) -> bool {
1542 self.last_write
1543 .get(key)
1544 .is_some_and(|write| *write > started)
1545 }
1546}
1547
1548impl StoreHealth {
1549 fn read_started(&self) -> u64 {
1552 self.writes.load(Ordering::Acquire)
1553 }
1554
1555 fn begin_write(&self, key: &str) -> u64 {
1559 let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1560 let write = self.writes.fetch_add(1, Ordering::AcqRel) + 1;
1561 known.last_write.insert(key.to_owned(), write);
1562 write
1563 }
1564
1565 fn remember(&self, key: &str, flag: Option<&FlagConfig>, started: u64) {
1571 if self.failing.load(Ordering::Relaxed) && self.failing.swap(false, Ordering::AcqRel) {
1572 tracing::info!("feature flag store recovered");
1573 }
1574 let known = self.known.read().unwrap_or_else(PoisonError::into_inner);
1575 if known.values.get(key) == flag {
1576 return;
1577 }
1578 drop(known);
1579 let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1580 if known.written_after(key, started) {
1581 return;
1582 }
1583 match flag {
1584 Some(flag) => {
1585 known.values.insert(key.to_owned(), flag.clone());
1586 }
1587 None => {
1588 known.values.remove(key);
1589 }
1590 }
1591 }
1592
1593 fn forget(&self, key: &str, disabled: bool, write: u64) {
1597 let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1598 if known.written_after(key, write) {
1599 return;
1600 }
1601 if disabled {
1602 known
1603 .values
1604 .entry(key.to_owned())
1605 .or_insert_with(|| FlagConfig::new(key))
1606 .enabled = false;
1607 } else {
1608 known.values.remove(key);
1609 }
1610 }
1611
1612 fn recall(&self, key: &str, error: &FlagStoreError) -> Option<FlagConfig> {
1614 self.errors.fetch_add(1, Ordering::Relaxed);
1615 if self.failing.swap(true, Ordering::AcqRel) {
1616 tracing::debug!(flag = key, %error, "feature flag store read failed again");
1617 } else {
1618 tracing::warn!(
1619 flag = key,
1620 %error,
1621 "feature flag store read failed; serving last-known values or declared defaults"
1622 );
1623 }
1624 self.known
1625 .read()
1626 .unwrap_or_else(PoisonError::into_inner)
1627 .values
1628 .get(key)
1629 .cloned()
1630 }
1631}
1632
1633impl std::fmt::Debug for FeatureFlagService {
1634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1635 f.debug_struct("FeatureFlagService").finish_non_exhaustive()
1636 }
1637}
1638
1639impl FeatureFlagService {
1640 #[must_use]
1642 pub fn new(store: Arc<dyn FlagStore>) -> Self {
1643 Self {
1644 store,
1645 group_resolver: None,
1646 defaults: Arc::default(),
1647 health: Arc::default(),
1648 }
1649 }
1650
1651 #[must_use]
1656 pub fn with_default(mut self, flag_key: impl Into<String>, default: bool) -> Self {
1657 Arc::make_mut(&mut self.defaults).insert(flag_key.into(), default);
1658 self
1659 }
1660
1661 #[must_use]
1663 pub fn store_errors(&self) -> u64 {
1664 self.health.errors.load(Ordering::Relaxed)
1665 }
1666
1667 pub fn preload(&self) -> Result<(), FlagStoreError> {
1673 self.store.preload()
1674 }
1675
1676 #[must_use]
1678 pub fn with_group_resolver(mut self, resolver: GroupResolver) -> Self {
1679 self.group_resolver = Some(resolver);
1680 self
1681 }
1682
1683 #[must_use]
1688 pub fn is_enabled(&self, flag_key: &str, actor_id: Option<&str>) -> bool {
1689 let started = self.health.read_started();
1690 let flag = match self.store.get(flag_key) {
1691 Ok(flag) => {
1692 self.health.remember(flag_key, flag.as_ref(), started);
1693 flag
1694 }
1695 Err(error) => self.health.recall(flag_key, &error),
1696 };
1697 flag.map_or_else(
1698 || self.defaults.get(flag_key).copied().unwrap_or(false),
1699 |flag| self.evaluate(&flag, actor_id),
1700 )
1701 }
1702
1703 fn evaluate(&self, flag: &FlagConfig, actor_id: Option<&str>) -> bool {
1704 if !flag.enabled {
1706 return false;
1707 }
1708
1709 if flag.rollout_pct >= 100 {
1711 return true;
1712 }
1713
1714 if let Some(actor) = actor_id
1716 && flag.actor_allowlist.iter().any(|a| a.as_str() == actor)
1717 {
1718 return true;
1719 }
1720
1721 if let (Some(actor), Some(resolver)) = (actor_id, &self.group_resolver) {
1723 for group in &flag.group_allowlist {
1724 if resolver(actor, group) {
1725 return true;
1726 }
1727 }
1728 }
1729
1730 if flag.rollout_pct > 0
1732 && let Some(actor) = actor_id
1733 {
1734 let bucket = rollout_bucket(&flag.key, actor);
1735 return bucket < flag.rollout_pct;
1736 }
1737
1738 false
1739 }
1740
1741 fn write(
1747 &self,
1748 flag_key: &str,
1749 disabled: bool,
1750 op: impl FnOnce(&dyn FlagStore) -> Result<(), FlagStoreError>,
1751 ) -> Result<(), FlagStoreError> {
1752 let _ordered = self
1753 .health
1754 .write_order
1755 .lock()
1756 .unwrap_or_else(PoisonError::into_inner);
1757 op(self.store.as_ref())?;
1758 let started = self.health.begin_write(flag_key);
1759 match self.store.get(flag_key) {
1760 Ok(flag) => self.health.remember(flag_key, flag.as_ref(), started),
1761 Err(_) => self.health.forget(flag_key, disabled, started),
1762 }
1763 Ok(())
1764 }
1765
1766 pub fn enable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1772 self.write(flag_key, false, |store| store.enable(flag_key, actor))
1773 }
1774
1775 pub fn disable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1781 self.write(flag_key, true, |store| store.disable(flag_key, actor))
1782 }
1783
1784 pub fn set_rollout(
1790 &self,
1791 flag_key: &str,
1792 pct: u8,
1793 actor: Option<&str>,
1794 ) -> Result<(), FlagStoreError> {
1795 self.write(flag_key, false, |store| {
1796 store.set_rollout(flag_key, pct, actor)
1797 })
1798 }
1799
1800 pub fn allow_actor(
1806 &self,
1807 flag_key: &str,
1808 actor_id: &str,
1809 actor: Option<&str>,
1810 ) -> Result<(), FlagStoreError> {
1811 self.write(flag_key, false, |store| {
1812 store.allow_actor(flag_key, actor_id, actor)
1813 })
1814 }
1815
1816 pub fn add_group(
1822 &self,
1823 flag_key: &str,
1824 group: &str,
1825 actor: Option<&str>,
1826 ) -> Result<(), FlagStoreError> {
1827 self.write(flag_key, false, |store| {
1828 store.add_group(flag_key, group, actor)
1829 })
1830 }
1831
1832 pub fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
1838 self.store.list()
1839 }
1840
1841 pub fn history(
1847 &self,
1848 flag_key: &str,
1849 limit: usize,
1850 ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
1851 self.store.history(flag_key, limit)
1852 }
1853}
1854
1855pub(crate) const PRELOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1858
1859pub(crate) fn preload_blocking(service: &FeatureFlagService, wait: std::time::Duration) {
1867 let service = service.clone();
1868 preload_with(
1869 "feature flag",
1870 "flags use declared defaults",
1871 wait,
1872 move || service.preload(),
1873 );
1874}
1875
1876pub(crate) fn preload_with<E: std::fmt::Display + Send + 'static>(
1879 what: &'static str,
1880 fallback: &'static str,
1881 wait: std::time::Duration,
1882 load: impl FnOnce() -> Result<(), E> + Send + 'static,
1883) {
1884 let (done, loaded) = std::sync::mpsc::channel();
1885 let spawned = std::thread::Builder::new()
1886 .name(format!("autumn-{}-preload", what.replace(' ', "-")))
1887 .spawn(move || {
1888 let _ = done.send(load());
1890 });
1891 if let Err(error) = spawned {
1892 tracing::warn!(%error, "{what} preload thread did not start");
1893 return;
1894 }
1895 match loaded.recv_timeout(wait) {
1896 Ok(Ok(())) => {}
1897 Ok(Err(error)) => {
1898 tracing::warn!(%error, "{what} preload failed; {fallback}");
1899 }
1900 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1901 tracing::warn!(
1902 ?wait,
1903 "{what} preload is slow; the app continues: {fallback}"
1904 );
1905 }
1906 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1907 tracing::warn!("{what} preload panicked");
1908 }
1909 }
1910}
1911
1912pub struct Flags {
1933 service: FeatureFlagService,
1934 actor_id: Option<String>,
1935}
1936
1937impl Flags {
1938 #[must_use]
1940 pub fn enabled(&self, flag_key: &str) -> bool {
1941 self.service.is_enabled(flag_key, self.actor_id.as_deref())
1942 }
1943
1944 #[must_use]
1946 pub const fn service(&self) -> &FeatureFlagService {
1947 &self.service
1948 }
1949}
1950
1951impl axum::extract::FromRequestParts<crate::AppState> for Flags {
1952 type Rejection = crate::AutumnError;
1953
1954 async fn from_request_parts(
1955 parts: &mut axum::http::request::Parts,
1956 state: &crate::AppState,
1957 ) -> Result<Self, Self::Rejection> {
1958 let service = state
1959 .extension::<FeatureFlagService>()
1960 .map(|arc| (*arc).clone())
1961 .ok_or_else(|| {
1962 crate::AutumnError::internal_server_error_msg(
1963 "feature flag service not registered; \
1964 install a FlagStore via AppBuilder::with_flag_store()",
1965 )
1966 })?;
1967
1968 let actor_id = if let Some(session) = parts.extensions.get::<crate::session::Session>() {
1970 session.get(state.auth_session_key()).await
1971 } else {
1972 None
1973 };
1974
1975 Ok(Self { service, actor_id })
1976 }
1977}
1978
1979#[cfg(test)]
1982mod tests {
1983 use super::*;
1984
1985 fn make_svc() -> FeatureFlagService {
1988 FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
1989 }
1990
1991 #[test]
1994 fn unknown_flag_returns_false() {
1995 let svc = make_svc();
1996 assert!(!svc.is_enabled("nonexistent", Some("user:1")));
1997 }
1998
1999 #[test]
2000 fn globally_enabled_flag_returns_true_for_any_actor() {
2001 let svc = make_svc();
2002 svc.enable("my_flag", None).unwrap();
2003 assert!(svc.is_enabled("my_flag", Some("user:1")));
2004 assert!(svc.is_enabled("my_flag", Some("user:99")));
2005 assert!(svc.is_enabled("my_flag", None));
2006 }
2007
2008 #[test]
2009 fn globally_disabled_flag_returns_false_for_any_actor() {
2010 let svc = make_svc();
2011 svc.enable("my_flag", None).unwrap();
2012 svc.disable("my_flag", None).unwrap();
2013 assert!(!svc.is_enabled("my_flag", Some("user:1")));
2014 assert!(!svc.is_enabled("my_flag", None));
2015 }
2016
2017 #[test]
2020 fn actor_allowlist_enables_specific_actor() {
2021 let svc = make_svc();
2022 svc.allow_actor("beta_feature", "user:42", None).unwrap();
2023 assert!(svc.is_enabled("beta_feature", Some("user:42")));
2024 assert!(!svc.is_enabled("beta_feature", Some("user:1")));
2025 }
2026
2027 #[test]
2028 fn group_resolver_enables_group_members() {
2029 let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new())).with_group_resolver(
2030 Arc::new(|actor_id: &str, group: &str| {
2031 group == "staff" && actor_id.starts_with("staff:")
2033 }),
2034 );
2035 svc.add_group("internal_feature", "staff", None).unwrap();
2036 assert!(svc.is_enabled("internal_feature", Some("staff:alice")));
2037 assert!(!svc.is_enabled("internal_feature", Some("user:bob")));
2038 }
2039
2040 #[test]
2041 fn percent_rollout_at_0_disables_for_all_actors() {
2042 let svc = make_svc();
2043 svc.set_rollout("gradual", 0, None).unwrap();
2044 for i in 0..50_u32 {
2046 let actor = format!("user:{i}");
2047 assert!(
2048 !svc.is_enabled("gradual", Some(&actor)),
2049 "expected disabled for {actor} at 0% rollout"
2050 );
2051 }
2052 }
2053
2054 #[test]
2055 fn percent_rollout_at_100_enables_for_all_actors() {
2056 let svc = make_svc();
2057 svc.set_rollout("gradual", 100, None).unwrap();
2058 for i in 0..50_u32 {
2059 let actor = format!("user:{i}");
2060 assert!(
2061 svc.is_enabled("gradual", Some(&actor)),
2062 "expected enabled for {actor} at 100% rollout"
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn percent_rollout_at_50_enables_roughly_half() {
2069 let svc = make_svc();
2070 svc.set_rollout("rollout_flag", 50, None).unwrap();
2071 let enabled_count = (0..200_u32)
2072 .filter(|i| svc.is_enabled("rollout_flag", Some(&format!("user:{i}"))))
2073 .count();
2074 assert!(
2076 (80..=120).contains(&enabled_count),
2077 "expected ~100 enabled actors, got {enabled_count}"
2078 );
2079 }
2080
2081 #[test]
2084 fn rollout_bucket_is_stable_across_calls() {
2085 let b1 = rollout_bucket("my_flag", "user:1");
2086 let b2 = rollout_bucket("my_flag", "user:1");
2087 assert_eq!(b1, b2, "bucket must be deterministic");
2088 }
2089
2090 #[test]
2091 fn rollout_bucket_differs_for_different_actors() {
2092 let buckets: std::collections::HashSet<u8> = (0..50_u32)
2095 .map(|i| rollout_bucket("flag", &format!("user:{i}")))
2096 .collect();
2097 assert!(
2098 buckets.len() > 10,
2099 "expected diverse buckets, got {}: {buckets:?}",
2100 buckets.len()
2101 );
2102 }
2103
2104 #[test]
2105 fn rollout_bucket_in_range_0_to_99() {
2106 for i in 0..1000_u32 {
2107 let b = rollout_bucket("flag", &format!("actor:{i}"));
2108 assert!(b < 100, "bucket out of range: {b}");
2109 }
2110 }
2111
2112 fn joined_fnv1a_64(data: &[u8]) -> u64 {
2115 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
2116 const FNV_PRIME: u64 = 1_099_511_628_211;
2117 let mut hash = FNV_OFFSET;
2118 for &byte in data {
2119 hash ^= u64::from(byte);
2120 hash = hash.wrapping_mul(FNV_PRIME);
2121 }
2122 hash
2123 }
2124
2125 fn joined_rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
2126 let key = format!("{flag_key}:{actor_id}");
2127 let hash = joined_fnv1a_64(key.as_bytes());
2128 u8::try_from(hash % 100).unwrap_or(0)
2129 }
2130
2131 #[test]
2132 fn rollout_bucket_is_fnv1a_of_the_joined_key() {
2133 let edge_cases = [
2136 ("my_flag", "user:1"),
2137 ("", ""),
2138 ("f", ""),
2139 ("", "a"),
2140 (":", ":"),
2141 ("a:b", "c:d"),
2142 ("ab", "c:d"),
2143 ("a:b:", ":c"),
2144 ("ünï", "çødé"),
2145 ("flag🚩", "用户:42"),
2146 ("tab\tflag", "line\nactor"),
2147 ];
2148 for (flag, actor) in edge_cases {
2149 assert_eq!(
2150 rollout_bucket(flag, actor),
2151 joined_rollout_bucket(flag, actor),
2152 "{flag:?}/{actor:?}"
2153 );
2154 }
2155 for flag in ["flag", "beta", "dark_mode", "checkout:v2"] {
2156 for i in 0..1_000_u32 {
2157 let actor = format!("actor:{i}");
2158 assert_eq!(
2159 rollout_bucket(flag, &actor),
2160 joined_rollout_bucket(flag, &actor),
2161 "{flag:?}/{actor:?}"
2162 );
2163 }
2164 }
2165 }
2166
2167 #[test]
2168 fn fnv1a_64_of_parts_equals_fnv1a_64_of_their_concatenation() {
2169 for key in ["", ":", "my_flag:user:1", "ünï:çødé", "a::b"] {
2172 let whole = joined_fnv1a_64(key.as_bytes());
2173 let bytes = key.as_bytes();
2174 for i in 0..=bytes.len() {
2175 for j in i..=bytes.len() {
2176 let (head, rest) = bytes.split_at(i);
2177 let (middle, tail) = rest.split_at(j - i);
2178 assert_eq!(fnv1a_64(&[head, middle, tail]), whole, "{key:?} {i} {j}");
2179 }
2180 }
2181 assert_eq!(fnv1a_64(&[bytes]), whole, "{key:?} in one part");
2182 }
2183 assert_eq!(fnv1a_64(&[]), joined_fnv1a_64(&[]), "no parts at all");
2184 }
2185
2186 #[test]
2187 fn rollout_bucket_known_fixtures() {
2188 assert_eq!(rollout_bucket("my_flag", "user:1"), 62);
2191 assert_eq!(rollout_bucket("beta", "user:42"), 28);
2192 assert_eq!(rollout_bucket("dark_mode", "user:7"), 77);
2193 assert_eq!(rollout_bucket("", ""), 89);
2194 assert_eq!(rollout_bucket("a:b", "c:d"), 53);
2195 assert_eq!(rollout_bucket("ünï", "çødé"), 63);
2196 }
2197
2198 #[test]
2199 fn percent_rollout_same_actor_same_flag_always_same_result() {
2200 let svc = make_svc();
2201 svc.set_rollout("stable_flag", 42, None).unwrap();
2202 let first = svc.is_enabled("stable_flag", Some("user:123"));
2203 for _ in 0..10 {
2204 assert_eq!(
2205 svc.is_enabled("stable_flag", Some("user:123")),
2206 first,
2207 "rollout result must not flip between calls"
2208 );
2209 }
2210 }
2211
2212 #[test]
2215 fn in_memory_store_returns_none_for_unknown_flag() {
2216 let store = InMemoryFlagStore::new();
2217 assert!(store.get("unknown").unwrap().is_none());
2218 }
2219
2220 #[test]
2221 fn in_memory_store_list_is_sorted() {
2222 let store = InMemoryFlagStore::new();
2223 store.enable("zebra", None).unwrap();
2224 store.enable("alpha", None).unwrap();
2225 store.enable("mango", None).unwrap();
2226 let keys: Vec<String> = store.list().unwrap().into_iter().map(|f| f.key).collect();
2227 assert_eq!(keys, vec!["alpha", "mango", "zebra"]);
2228 }
2229
2230 #[test]
2231 fn in_memory_store_enable_creates_flag_if_absent() {
2232 let store = InMemoryFlagStore::new();
2233 store.enable("new_flag", None).unwrap();
2234 let flag = store.get("new_flag").unwrap().unwrap();
2235 assert!(flag.enabled);
2236 }
2237
2238 #[test]
2239 fn in_memory_store_disable_sets_enabled_false() {
2240 let store = InMemoryFlagStore::new();
2241 store.enable("f", None).unwrap();
2242 store.disable("f", None).unwrap();
2243 assert!(!store.get("f").unwrap().unwrap().enabled);
2244 }
2245
2246 #[test]
2247 fn in_memory_store_allow_actor_does_not_duplicate() {
2248 let store = InMemoryFlagStore::new();
2249 store.allow_actor("f", "user:1", None).unwrap();
2250 store.allow_actor("f", "user:1", None).unwrap();
2251 let flag = store.get("f").unwrap().unwrap();
2252 assert_eq!(flag.actor_allowlist.len(), 1);
2253 }
2254
2255 #[test]
2256 fn in_memory_store_add_group_does_not_duplicate() {
2257 let store = InMemoryFlagStore::new();
2258 store.add_group("f", "staff", None).unwrap();
2259 store.add_group("f", "staff", None).unwrap();
2260 let flag = store.get("f").unwrap().unwrap();
2261 assert_eq!(flag.group_allowlist.len(), 1);
2262 }
2263
2264 #[test]
2267 fn mutations_are_recorded_in_history() {
2268 let svc = make_svc();
2269 svc.enable("tracked_flag", Some("ops@example.com")).unwrap();
2270 svc.disable("tracked_flag", Some("ops@example.com"))
2271 .unwrap();
2272 let history = svc.history("tracked_flag", 10).unwrap();
2273 assert_eq!(history.len(), 2, "two mutations should be recorded");
2274 assert_eq!(history[0].mutation, "disabled");
2275 assert_eq!(history[0].actor.as_deref(), Some("ops@example.com"));
2276 assert_eq!(history[1].mutation, "enabled");
2277 }
2278
2279 #[test]
2280 fn history_respects_limit() {
2281 let svc = make_svc();
2282 for _ in 0..5 {
2283 svc.enable("limited_flag", None).unwrap();
2284 }
2285 let history = svc.history("limited_flag", 3).unwrap();
2286 assert_eq!(history.len(), 3);
2287 }
2288
2289 #[test]
2290 fn history_empty_for_unknown_flag() {
2291 let svc = make_svc();
2292 let history = svc.history("ghost_flag", 10).unwrap();
2293 assert!(history.is_empty());
2294 }
2295
2296 #[test]
2297 fn rollout_mutation_recorded_with_pct() {
2298 let svc = make_svc();
2299 svc.set_rollout("roll", 25, Some("cli")).unwrap();
2300 let history = svc.history("roll", 1).unwrap();
2301 assert_eq!(history[0].mutation, "rollout=25");
2302 assert_eq!(history[0].actor.as_deref(), Some("cli"));
2303 }
2304
2305 #[test]
2306 fn allow_actor_mutation_recorded() {
2307 let svc = make_svc();
2308 svc.allow_actor("f", "user:7", Some("cli")).unwrap();
2309 let h = svc.history("f", 1).unwrap();
2310 assert_eq!(h[0].mutation, "allowed_actor=user:7");
2311 }
2312
2313 #[test]
2316 fn flag_config_new_defaults_to_disabled() {
2317 let f = FlagConfig::new("my_flag");
2318 assert_eq!(f.key, "my_flag");
2319 assert!(!f.enabled);
2320 assert_eq!(f.rollout_pct, 0);
2321 assert!(f.actor_allowlist.is_empty());
2322 assert!(f.group_allowlist.is_empty());
2323 }
2324
2325 #[test]
2328 fn set_rollout_clamps_to_100() {
2329 let store = InMemoryFlagStore::new();
2330 store.set_rollout("f", 200, None).unwrap();
2331 assert_eq!(store.get("f").unwrap().unwrap().rollout_pct, 100);
2332 }
2333
2334 #[test]
2337 fn disable_kills_flag_even_when_rollout_is_100_percent() {
2338 let svc = make_svc();
2339 svc.set_rollout("roll_flag", 100, None).unwrap();
2340 svc.disable("roll_flag", None).unwrap();
2341 for i in 0..20_u32 {
2342 assert!(
2343 !svc.is_enabled("roll_flag", Some(&format!("user:{i}"))),
2344 "disable() must override rollout for actor user:{i}"
2345 );
2346 }
2347 assert!(!svc.is_enabled("roll_flag", None));
2348 }
2349
2350 #[test]
2351 fn disable_kills_flag_even_when_actor_is_in_allowlist() {
2352 let svc = make_svc();
2353 svc.allow_actor("guarded", "user:42", None).unwrap();
2354 svc.disable("guarded", None).unwrap();
2355 assert!(
2356 !svc.is_enabled("guarded", Some("user:42")),
2357 "disable() must override actor allowlist"
2358 );
2359 }
2360
2361 #[test]
2362 fn enable_after_disable_restores_rollout_config() {
2363 let svc = make_svc();
2364 svc.set_rollout("roll_flag", 50, None).unwrap();
2365 svc.disable("roll_flag", None).unwrap();
2366 svc.enable("roll_flag", None).unwrap();
2369 assert!(svc.is_enabled("roll_flag", None));
2370 assert!(svc.is_enabled("roll_flag", Some("user:1")));
2371 }
2372
2373 #[test]
2376 fn allow_actor_after_kill_switch_does_not_restore_global_rollout() {
2377 let svc = make_svc();
2381 svc.enable("targeted", None).unwrap(); svc.disable("targeted", None).unwrap(); svc.allow_actor("targeted", "user:42", None).unwrap(); assert!(
2386 svc.is_enabled("targeted", Some("user:42")),
2387 "allowlisted actor must see the flag"
2388 );
2389 for i in [1_u32, 5, 10, 99] {
2391 let actor = format!("user:{i}");
2392 assert!(
2393 !svc.is_enabled("targeted", Some(&actor)),
2394 "non-allowlisted actor {actor} must NOT see the flag after allowlist-only re-enable"
2395 );
2396 }
2397 }
2398
2399 #[test]
2400 fn allow_actor_on_active_rollout_preserves_rollout_pct() {
2401 let svc = make_svc();
2404 svc.set_rollout("staged", 50, None).unwrap(); svc.allow_actor("staged", "user:42", None).unwrap();
2406
2407 let store = InMemoryFlagStore::new();
2409 store.set_rollout("staged", 50, None).unwrap();
2410 store.allow_actor("staged", "user:42", None).unwrap();
2411 let flag = store.get("staged").unwrap().unwrap();
2412 assert_eq!(
2413 flag.rollout_pct, 50,
2414 "rollout_pct must be preserved when flag was already enabled"
2415 );
2416 assert!(flag.actor_allowlist.contains(&"user:42".to_owned()));
2417 }
2418
2419 #[test]
2422 fn arc_flag_store_delegates_get() {
2423 let store = Arc::new(InMemoryFlagStore::new());
2424 store.enable("arc_flag", None).unwrap();
2425 let arc_store: Arc<dyn FlagStore> = store;
2426 let flag = arc_store.get("arc_flag").unwrap().unwrap();
2427 assert!(flag.enabled);
2428 }
2429
2430 #[test]
2431 fn arc_flag_store_delegates_list() {
2432 let store = Arc::new(InMemoryFlagStore::new());
2433 store.enable("f1", None).unwrap();
2434 store.enable("f2", None).unwrap();
2435 let arc_store: Arc<dyn FlagStore> = store;
2436 let flags = arc_store.list().unwrap();
2437 assert_eq!(flags.len(), 2);
2438 }
2439
2440 #[test]
2441 fn arc_flag_store_delegates_enable_and_disable() {
2442 let store = Arc::new(InMemoryFlagStore::new());
2443 let arc_store: Arc<dyn FlagStore> = store;
2444 arc_store.enable("f", None).unwrap();
2445 assert!(arc_store.get("f").unwrap().unwrap().enabled);
2446 arc_store.disable("f", None).unwrap();
2447 assert!(!arc_store.get("f").unwrap().unwrap().enabled);
2448 }
2449
2450 #[test]
2451 fn arc_flag_store_delegates_set_rollout() {
2452 let store = Arc::new(InMemoryFlagStore::new());
2453 let arc_store: Arc<dyn FlagStore> = store;
2454 arc_store.set_rollout("f", 42, None).unwrap();
2455 let flag = arc_store.get("f").unwrap().unwrap();
2456 assert_eq!(flag.rollout_pct, 42);
2457 }
2458
2459 #[test]
2460 fn arc_flag_store_delegates_allow_actor() {
2461 let store = Arc::new(InMemoryFlagStore::new());
2462 let arc_store: Arc<dyn FlagStore> = store;
2463 arc_store.allow_actor("f", "user:1", None).unwrap();
2464 let flag = arc_store.get("f").unwrap().unwrap();
2465 assert!(flag.actor_allowlist.contains(&"user:1".to_owned()));
2466 }
2467
2468 #[test]
2469 fn arc_flag_store_delegates_add_group() {
2470 let store = Arc::new(InMemoryFlagStore::new());
2471 let arc_store: Arc<dyn FlagStore> = store;
2472 arc_store.add_group("f", "beta_testers", None).unwrap();
2473 let flag = arc_store.get("f").unwrap().unwrap();
2474 assert!(flag.group_allowlist.contains(&"beta_testers".to_owned()));
2475 }
2476
2477 #[test]
2478 fn arc_flag_store_delegates_history() {
2479 let store = Arc::new(InMemoryFlagStore::new());
2480 let arc_store: Arc<dyn FlagStore> = store;
2481 arc_store.enable("f", Some("cli")).unwrap();
2482 let history = arc_store.history("f", 10).unwrap();
2483 assert_eq!(history.len(), 1);
2484 assert_eq!(history[0].mutation, "enabled");
2485 }
2486
2487 #[test]
2490 fn box_flag_store_delegates_all_operations() {
2491 let store = InMemoryFlagStore::new();
2492 let boxed: Box<dyn FlagStore> = Box::new(store);
2493 boxed.enable("f", None).unwrap();
2494 assert!(boxed.get("f").unwrap().unwrap().enabled);
2495 boxed.set_rollout("g", 25, Some("cli")).unwrap();
2496 assert_eq!(boxed.get("g").unwrap().unwrap().rollout_pct, 25);
2497 boxed.allow_actor("h", "user:1", None).unwrap();
2498 boxed.add_group("h", "staff", None).unwrap();
2499 let flags = boxed.list().unwrap();
2500 assert_eq!(flags.len(), 3);
2502 let hist = boxed.history("f", 5).unwrap();
2503 assert_eq!(hist[0].mutation, "enabled");
2504 boxed.disable("f", None).unwrap();
2505 assert!(!boxed.get("f").unwrap().unwrap().enabled);
2506 }
2507
2508 #[test]
2511 fn flag_store_error_displays_message() {
2512 let err = FlagStoreError::Backend("connection refused".to_owned());
2513 assert_eq!(
2514 err.to_string(),
2515 "flag store backend error: connection refused"
2516 );
2517 }
2518
2519 #[test]
2522 fn flag_config_clone_is_equal_to_original() {
2523 let mut f = FlagConfig::new("cloned");
2524 f.enabled = true;
2525 f.rollout_pct = 50;
2526 f.actor_allowlist = vec!["user:1".to_owned()];
2527 let g = f.clone();
2528 assert_eq!(f, g);
2529 }
2530
2531 #[test]
2534 fn rollout_with_no_actor_returns_false() {
2535 let svc = make_svc();
2538 svc.set_rollout("gradual", 99, None).unwrap();
2539 assert!(
2540 !svc.is_enabled("gradual", None),
2541 "percent rollout must not fire for anonymous (None) actor"
2542 );
2543 }
2544
2545 #[test]
2546 fn group_resolver_with_no_actor_does_not_panic() {
2547 let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
2548 .with_group_resolver(Arc::new(|_: &str, _: &str| true));
2549 svc.add_group("f", "everyone", None).unwrap();
2550 assert!(!svc.is_enabled("f", None));
2552 }
2553
2554 #[test]
2555 fn add_group_mutation_format() {
2556 let store = InMemoryFlagStore::new();
2557 store.add_group("f", "beta_testers", Some("cli")).unwrap();
2558 let hist = store.history("f", 1).unwrap();
2559 assert_eq!(hist[0].mutation, "added_group=beta_testers");
2560 assert_eq!(hist[0].actor.as_deref(), Some("cli"));
2561 }
2562
2563 #[test]
2564 fn service_list_returns_all_flags() {
2565 let svc = make_svc();
2566 svc.enable("a", None).unwrap();
2567 svc.disable("b", None).unwrap();
2568 svc.set_rollout("c", 10, None).unwrap();
2569 let flags = svc.list().unwrap();
2570 assert_eq!(flags.len(), 3);
2571 assert_eq!(flags[0].key, "a");
2572 assert_eq!(flags[1].key, "b");
2573 assert_eq!(flags[2].key, "c");
2574 }
2575
2576 #[test]
2577 fn service_debug_does_not_panic() {
2578 let svc = make_svc();
2579 let _ = format!("{svc:?}");
2580 }
2581
2582 #[test]
2583 fn flags_enabled_delegates_to_service() {
2584 let svc = make_svc();
2586 svc.enable("active", None).unwrap();
2587 assert!(svc.is_enabled("active", Some("any_user")));
2588 assert!(!svc.is_enabled("missing", Some("any_user")));
2589 }
2590
2591 #[tokio::test]
2592 async fn from_request_parts_respects_custom_auth_session_key() {
2593 use axum::extract::FromRequestParts;
2594 use std::collections::HashMap;
2595
2596 let svc = make_svc();
2597 let state = crate::AppState::for_test().with_auth_session_key("custom_user_id");
2598 state.insert_extension(svc);
2599
2600 let mut data = HashMap::new();
2601 data.insert("custom_user_id".to_owned(), "user:123".to_owned());
2602 data.insert("user_id".to_owned(), "user:999".to_owned()); let session = crate::session::Session::new_for_test("session_id".to_owned(), data);
2604
2605 let mut req = axum::http::Request::builder().body(()).unwrap();
2606 req.extensions_mut().insert(session);
2607 let mut parts = req.into_parts().0;
2608
2609 let flags = Flags::from_request_parts(&mut parts, &state).await.unwrap();
2610 assert_eq!(flags.actor_id.as_deref(), Some("user:123"));
2611 }
2612
2613 #[derive(Default)]
2617 struct ScriptedStore {
2618 inner: InMemoryFlagStore,
2619 failing: std::sync::atomic::AtomicBool,
2620 hidden: std::sync::atomic::AtomicBool,
2621 preloads: std::sync::atomic::AtomicUsize,
2622 preload_delay_ms: std::sync::atomic::AtomicU64,
2624 }
2625
2626 impl ScriptedStore {
2627 fn fail(&self, on: bool) {
2628 self.failing.store(on, std::sync::atomic::Ordering::SeqCst);
2629 }
2630
2631 fn hide(&self, on: bool) {
2632 self.hidden.store(on, std::sync::atomic::Ordering::SeqCst);
2633 }
2634 }
2635
2636 impl FlagStore for ScriptedStore {
2637 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
2638 if self.failing.load(std::sync::atomic::Ordering::SeqCst) {
2639 return Err(FlagStoreError::Backend("brownout".to_owned()));
2640 }
2641 if self.hidden.load(std::sync::atomic::Ordering::SeqCst) {
2642 return Ok(None);
2643 }
2644 self.inner.get(key)
2645 }
2646 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
2647 self.inner.list()
2648 }
2649 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2650 self.inner.enable(key, actor)
2651 }
2652 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2653 self.inner.disable(key, actor)
2654 }
2655 fn set_rollout(
2656 &self,
2657 key: &str,
2658 pct: u8,
2659 actor: Option<&str>,
2660 ) -> Result<(), FlagStoreError> {
2661 self.inner.set_rollout(key, pct, actor)
2662 }
2663 fn allow_actor(
2664 &self,
2665 key: &str,
2666 actor_id: &str,
2667 actor: Option<&str>,
2668 ) -> Result<(), FlagStoreError> {
2669 self.inner.allow_actor(key, actor_id, actor)
2670 }
2671 fn add_group(
2672 &self,
2673 key: &str,
2674 group: &str,
2675 actor: Option<&str>,
2676 ) -> Result<(), FlagStoreError> {
2677 self.inner.add_group(key, group, actor)
2678 }
2679 fn history(
2680 &self,
2681 key: &str,
2682 limit: usize,
2683 ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
2684 self.inner.history(key, limit)
2685 }
2686 fn preload(&self) -> Result<(), FlagStoreError> {
2687 let delay = self
2688 .preload_delay_ms
2689 .load(std::sync::atomic::Ordering::SeqCst);
2690 std::thread::sleep(std::time::Duration::from_millis(delay));
2691 self.preloads
2692 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2693 Ok(())
2694 }
2695 }
2696
2697 #[test]
2698 fn failing_store_serves_last_known_value() {
2699 let store = Arc::new(ScriptedStore::default());
2700 let svc = FeatureFlagService::new(store.clone());
2701 store.enable("beta", None).unwrap();
2702 assert!(svc.is_enabled("beta", Some("user:1")));
2703
2704 store.fail(true);
2705 assert!(
2706 svc.is_enabled("beta", Some("user:1")),
2707 "a store error must not turn a known flag off"
2708 );
2709 assert_eq!(svc.store_errors(), 1);
2710 }
2711
2712 #[test]
2713 fn disable_through_the_service_replaces_the_last_known_value() {
2714 let store = Arc::new(ScriptedStore::default());
2715 let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
2716 svc.enable("kill", None).unwrap();
2717 assert!(svc.is_enabled("kill", None));
2718
2719 store.fail(true);
2721 svc.disable("kill", None).unwrap();
2722 assert!(
2723 !svc.is_enabled("kill", None),
2724 "a successful disable must win over the old last-known value"
2725 );
2726
2727 store.fail(false);
2729 svc.enable("kill", None).unwrap();
2730 svc.disable("kill", None).unwrap();
2731 store.fail(true);
2732 assert!(!svc.is_enabled("kill", None));
2733 }
2734
2735 #[test]
2736 fn read_started_before_a_write_does_not_replace_its_value() {
2737 let store = Arc::new(ScriptedStore::default());
2738 let svc = FeatureFlagService::new(store.clone());
2739 svc.enable("kill", None).unwrap();
2740
2741 let started = svc.health.read_started();
2743 let stale = store.get("kill").unwrap();
2744 svc.disable("kill", None).unwrap();
2745 svc.health.remember("kill", stale.as_ref(), started);
2747
2748 store.fail(true);
2749 assert!(
2750 !svc.is_enabled("kill", None),
2751 "a read that started before a write must not replace its value"
2752 );
2753 }
2754
2755 #[test]
2756 fn a_write_to_one_flag_keeps_reads_of_other_flags() {
2757 let store = Arc::new(ScriptedStore::default());
2758 let svc = FeatureFlagService::new(store.clone());
2759 store.enable("b", None).unwrap();
2760
2761 let started = svc.health.read_started();
2762 let read = store.get("b").unwrap();
2763 svc.enable("a", None).unwrap(); svc.health.remember("b", read.as_ref(), started);
2765
2766 store.fail(true);
2767 assert!(svc.is_enabled("b", None), "the read of `b` is still good");
2768 }
2769
2770 #[test]
2771 fn an_older_failed_read_back_does_not_undo_a_newer_write() {
2772 let svc = make_svc();
2773 let mut on = FlagConfig::new("k");
2774 on.enabled = true;
2775 on.rollout_pct = 100;
2776
2777 let disable = svc.health.begin_write("k");
2778 let enable = svc.health.begin_write("k");
2779 svc.health.remember("k", Some(&on), enable);
2780 svc.health.forget("k", true, disable);
2782
2783 let error = FlagStoreError::Backend("down".to_owned());
2784 assert_eq!(svc.health.recall("k", &error), Some(on));
2785 }
2786
2787 struct PausingStore {
2789 inner: ScriptedStore,
2790 entered: std::sync::Mutex<std::sync::mpsc::Sender<()>>,
2791 release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
2792 }
2793
2794 impl FlagStore for PausingStore {
2795 fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
2796 self.inner.get(key)
2797 }
2798 fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
2799 self.inner.list()
2800 }
2801 fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2802 self.inner.enable(key, actor)?;
2803 self.entered.lock().unwrap().send(()).unwrap();
2804 self.release.lock().unwrap().recv().unwrap();
2805 Ok(())
2806 }
2807 fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2808 self.inner.disable(key, actor)
2809 }
2810 fn set_rollout(
2811 &self,
2812 key: &str,
2813 pct: u8,
2814 actor: Option<&str>,
2815 ) -> Result<(), FlagStoreError> {
2816 self.inner.set_rollout(key, pct, actor)
2817 }
2818 fn allow_actor(
2819 &self,
2820 key: &str,
2821 actor_id: &str,
2822 actor: Option<&str>,
2823 ) -> Result<(), FlagStoreError> {
2824 self.inner.allow_actor(key, actor_id, actor)
2825 }
2826 fn add_group(
2827 &self,
2828 key: &str,
2829 group: &str,
2830 actor: Option<&str>,
2831 ) -> Result<(), FlagStoreError> {
2832 self.inner.add_group(key, group, actor)
2833 }
2834 fn history(
2835 &self,
2836 key: &str,
2837 limit: usize,
2838 ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
2839 self.inner.history(key, limit)
2840 }
2841 }
2842
2843 #[test]
2844 fn an_older_write_cannot_undo_a_later_disable() {
2845 let (entered_tx, entered) = std::sync::mpsc::channel();
2846 let (release, release_rx) = std::sync::mpsc::channel();
2847 let store = Arc::new(PausingStore {
2848 inner: ScriptedStore::default(),
2849 entered: std::sync::Mutex::new(entered_tx),
2850 release: std::sync::Mutex::new(release_rx),
2851 });
2852 let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
2853
2854 let older = {
2856 let svc = svc.clone();
2857 std::thread::spawn(move || svc.enable("kill", None).unwrap())
2858 };
2859 entered.recv().unwrap();
2860
2861 let later = {
2863 let svc = svc.clone();
2864 std::thread::spawn(move || svc.disable("kill", None).unwrap())
2865 };
2866 std::thread::sleep(std::time::Duration::from_millis(200));
2867
2868 store.inner.fail(true);
2870 release.send(()).unwrap();
2871 older.join().unwrap();
2872 later.join().unwrap();
2873
2874 assert!(
2875 !svc.is_enabled("kill", None),
2876 "the later disable must hold during the outage"
2877 );
2878 }
2879
2880 #[test]
2881 fn failing_store_keeps_a_kill_switch_off() {
2882 let store = Arc::new(ScriptedStore::default());
2883 let svc = FeatureFlagService::new(store.clone()).with_default("payments", true);
2884 store.enable("payments", None).unwrap();
2885 store.disable("payments", None).unwrap();
2886 assert!(!svc.is_enabled("payments", None));
2887
2888 store.fail(true);
2889 assert!(
2890 !svc.is_enabled("payments", None),
2891 "the last-known value wins over the declared default"
2892 );
2893 }
2894
2895 #[test]
2896 fn failing_store_without_last_known_value_serves_declared_default() {
2897 let store = Arc::new(ScriptedStore::default());
2898 let svc = FeatureFlagService::new(store.clone()).with_default("fail_open", true);
2899 store.fail(true);
2900 assert!(svc.is_enabled("fail_open", None));
2901 assert!(!svc.is_enabled("no_default", None));
2902 assert_eq!(svc.store_errors(), 2);
2903 }
2904
2905 #[test]
2906 fn unknown_flag_serves_declared_default() {
2907 let svc = make_svc().with_default("absent", true);
2908 assert!(svc.is_enabled("absent", None));
2909 assert!(!svc.is_enabled("other", None));
2910 assert_eq!(svc.store_errors(), 0);
2911 }
2912
2913 #[test]
2914 fn flag_removed_from_store_forgets_last_known_value() {
2915 let store = Arc::new(ScriptedStore::default());
2916 let svc = FeatureFlagService::new(store.clone());
2917 store.enable("gone", None).unwrap();
2918 assert!(svc.is_enabled("gone", None));
2919
2920 store.hide(true);
2921 assert!(!svc.is_enabled("gone", None));
2922 store.hide(false);
2923 store.fail(true);
2924 assert!(
2925 !svc.is_enabled("gone", None),
2926 "a flag the store reported absent has no last-known value"
2927 );
2928 }
2929
2930 #[test]
2931 fn service_clones_share_last_known_values() {
2932 let store = Arc::new(ScriptedStore::default());
2934 let svc = FeatureFlagService::new(store.clone());
2935 store.enable("shared", None).unwrap();
2936 assert!(svc.is_enabled("shared", None));
2937
2938 let per_request = svc.clone();
2939 store.fail(true);
2940 assert!(per_request.is_enabled("shared", None));
2941 assert_eq!(svc.store_errors(), 1, "clones share the error count");
2942 }
2943
2944 #[test]
2945 fn service_preload_delegates_to_the_store() {
2946 let store = Arc::new(ScriptedStore::default());
2947 let svc = FeatureFlagService::new(store.clone());
2948 svc.preload().unwrap();
2949 assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2950 make_svc().preload().unwrap();
2952 }
2953
2954 #[tokio::test]
2955 async fn test_app_preloads_the_flag_store_at_build() {
2956 let store = Arc::new(ScriptedStore::default());
2957 let _client = crate::test::TestApp::new()
2958 .with_flag_store(Arc::clone(&store))
2959 .build();
2960 assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2961 }
2962
2963 #[test]
2964 fn preload_blocking_waits_for_the_load_up_to_the_limit() {
2965 let store = Arc::new(ScriptedStore::default());
2966 let svc = FeatureFlagService::new(store.clone());
2967 preload_blocking(&svc, std::time::Duration::from_secs(10));
2968 assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2969
2970 store
2972 .preload_delay_ms
2973 .store(300, std::sync::atomic::Ordering::SeqCst);
2974 preload_blocking(&svc, std::time::Duration::from_millis(10));
2975 assert_eq!(
2976 store.preloads.load(std::sync::atomic::Ordering::SeqCst),
2977 1,
2978 "the wait ended before the slow load"
2979 );
2980 }
2981
2982 #[test]
2983 fn feature_flag_guide_describes_polling_not_listen() {
2984 let guide = include_str!("../../docs/guide/feature-flags.md");
2985 assert!(
2986 !guide.contains("All replicas listening on that channel"),
2987 "Autumn does not LISTEN; replicas poll"
2988 );
2989 assert!(guide.contains("spawn_poll_listener"));
2990 assert!(guide.contains("last-known"));
2991 }
2992
2993 #[cfg(feature = "db")]
3001 #[test]
3002 fn pg_flag_store_refuses_a_non_postgres_target() {
3003 use crate::config::DatabaseConfig;
3004
3005 let sqlite = DatabaseConfig {
3006 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
3007 ..Default::default()
3008 };
3009 assert!(
3010 pg::PgFlagStore::from_database_config(&sqlite).is_none(),
3011 "a SQLite target has no Postgres flag store"
3012 );
3013
3014 let unclassifiable = DatabaseConfig {
3016 primary_url: Some("/var/lib/app.db".to_owned()),
3017 ..Default::default()
3018 };
3019 assert!(
3020 pg::PgFlagStore::from_database_config(&unclassifiable).is_none(),
3021 "an unclassifiable target has no Postgres flag store"
3022 );
3023
3024 for url in [
3026 "postgres://localhost/app",
3027 "host=db user=app dbname=app sslmode=require",
3028 ] {
3029 let pg_config = DatabaseConfig {
3030 primary_url: Some(url.to_owned()),
3031 ..Default::default()
3032 };
3033 assert!(
3034 pg::PgFlagStore::from_database_config(&pg_config).is_some(),
3035 "{url} is a Postgres target"
3036 );
3037 }
3038 }
3039
3040 #[cfg(feature = "db")]
3043 #[test]
3044 fn pg_flag_store_new_refuses_a_non_postgres_target_at_first_use() {
3045 let store = pg::PgFlagStore::new("sqlite://app:hunter2@host/app.db");
3046 let err = store
3047 .refresh()
3048 .expect_err("a SQLite target is refused")
3049 .to_string();
3050 assert!(
3051 err.contains("Postgres"),
3052 "the refusal names the cause: {err}"
3053 );
3054 assert!(!err.contains("hunter2"), "the refusal leaks: {err}");
3055
3056 let store = pg::PgFlagStore::new("postgres://app:hunter2@[::1/db");
3058 let err = store
3059 .refresh()
3060 .expect_err("a malformed target fails")
3061 .to_string();
3062 assert!(!err.contains("hunter2"), "the driver error leaks: {err}");
3063
3064 let store = pg::PgFlagStore::new("postgres://app:p%ss@localhost:1/db");
3066 let err = store.refresh().expect_err("a bad escape fails").to_string();
3067 assert!(!err.contains("p%ss"), "the driver error leaks: {err}");
3068 }
3069}