1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
86
87use std::collections::HashMap;
88use std::sync::{Arc, Mutex, RwLock};
89
90use serde::{Deserialize, Serialize};
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ExperimentState {
101 Draft,
103 Running,
105 Concluded,
107 Archived,
109}
110
111impl std::fmt::Display for ExperimentState {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::Draft => write!(f, "draft"),
115 Self::Running => write!(f, "running"),
116 Self::Concluded => write!(f, "concluded"),
117 Self::Archived => write!(f, "archived"),
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct VariantConfig {
131 pub name: String,
133 pub weight: u32,
135}
136
137impl VariantConfig {
138 #[must_use]
140 pub fn new(name: impl Into<String>, weight: u32) -> Self {
141 Self {
142 name: name.into(),
143 weight,
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct ExperimentConfig {
153 pub name: String,
155 pub description: Option<String>,
157 pub state: ExperimentState,
159 pub variants: Vec<VariantConfig>,
161 pub winner: Option<String>,
163 pub exclusion_group: Option<String>,
166 pub updated_at_secs: u64,
168}
169
170impl ExperimentConfig {
171 #[must_use]
173 pub fn new(name: impl Into<String>, variants: Vec<VariantConfig>) -> Self {
174 Self {
175 name: name.into(),
176 description: None,
177 state: ExperimentState::Draft,
178 variants,
179 winner: None,
180 exclusion_group: None,
181 updated_at_secs: now_secs(),
182 }
183 }
184
185 #[must_use]
187 pub fn description(mut self, desc: impl Into<String>) -> Self {
188 self.description = Some(desc.into());
189 self
190 }
191
192 #[must_use]
194 pub fn exclusion_group(mut self, group: impl Into<String>) -> Self {
195 self.exclusion_group = Some(group.into());
196 self
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Assignment {
208 pub experiment: String,
210 pub actor: String,
212 pub variant: String,
214 pub is_override: bool,
217 pub assigned_at_secs: u64,
219}
220
221impl Assignment {
222 fn new(experiment: &str, actor: &str, variant: &str, is_override: bool) -> Self {
223 Self {
224 experiment: experiment.to_owned(),
225 actor: actor.to_owned(),
226 variant: variant.to_owned(),
227 is_override,
228 assigned_at_secs: now_secs(),
229 }
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ChangeRecord {
238 pub experiment: String,
240 pub mutation: String,
242 pub actor: Option<String>,
244 pub timestamp_secs: u64,
246}
247
248impl ChangeRecord {
249 fn now(experiment: &str, mutation: impl Into<String>, actor: Option<&str>) -> Self {
250 Self {
251 experiment: experiment.to_owned(),
252 mutation: mutation.into(),
253 actor: actor.map(str::to_owned),
254 timestamp_secs: now_secs(),
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ExposureRecord {
267 pub experiment: String,
269 pub variant: String,
271 pub actor: String,
273 pub request_id: Option<String>,
275 pub is_override: bool,
277 pub timestamp_secs: u64,
279}
280
281pub trait ExposureSink: Send + Sync + 'static {
310 fn record(&self, exposure: ExposureRecord);
312}
313
314pub struct TracingExposureSink;
320
321impl ExposureSink for TracingExposureSink {
322 fn record(&self, exposure: ExposureRecord) {
323 tracing::info!(
324 experiment = %exposure.experiment,
325 variant = %exposure.variant,
326 actor = %exposure.actor,
327 request_id = exposure.request_id.as_deref().unwrap_or(""),
328 is_override = %exposure.is_override,
329 "experiment_exposure"
330 );
331 }
332}
333
334pub struct NoOpExposureSink;
339
340impl ExposureSink for NoOpExposureSink {
341 fn record(&self, _: ExposureRecord) {}
342}
343
344pub struct RecordingExposureSink {
368 records: Arc<Mutex<Vec<ExposureRecord>>>,
369}
370
371impl Default for RecordingExposureSink {
372 fn default() -> Self {
373 Self::new().0
374 }
375}
376
377impl RecordingExposureSink {
378 #[must_use]
381 pub fn new() -> (Self, Arc<Mutex<Vec<ExposureRecord>>>) {
382 let records = Arc::new(Mutex::new(Vec::new()));
383 let sink = Self {
384 records: Arc::clone(&records),
385 };
386 (sink, records)
387 }
388}
389
390impl ExposureSink for RecordingExposureSink {
391 fn record(&self, exposure: ExposureRecord) {
392 self.records.lock().unwrap().push(exposure);
393 }
394}
395
396#[derive(Debug, thiserror::Error)]
400pub enum ExperimentStoreError {
401 #[error("experiment store backend error: {0}")]
403 Backend(String),
404}
405
406pub trait ExperimentStore: Send + Sync + 'static {
413 fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError>;
419
420 fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError>;
426
427 fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError>;
433
434 fn set_state(
442 &self,
443 name: &str,
444 state: ExperimentState,
445 winner: Option<&str>,
446 ) -> Result<(), ExperimentStoreError>;
447
448 fn set_variants(
456 &self,
457 name: &str,
458 variants: Vec<VariantConfig>,
459 actor: Option<&str>,
460 ) -> Result<(), ExperimentStoreError>;
461
462 fn get_assignment(
468 &self,
469 experiment: &str,
470 actor: &str,
471 ) -> Result<Option<Assignment>, ExperimentStoreError>;
472
473 fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError>;
484
485 fn get_override(
491 &self,
492 experiment: &str,
493 actor: &str,
494 ) -> Result<Option<String>, ExperimentStoreError>;
495
496 fn set_override(
502 &self,
503 experiment: &str,
504 actor: &str,
505 variant: &str,
506 ) -> Result<(), ExperimentStoreError>;
507
508 fn has_assignment_in_group(
518 &self,
519 actor: &str,
520 group: &str,
521 exclude_experiment: &str,
522 ) -> Result<bool, ExperimentStoreError>;
523
524 fn history(
531 &self,
532 experiment: &str,
533 limit: usize,
534 ) -> Result<Vec<ChangeRecord>, ExperimentStoreError>;
535
536 fn preload(&self) -> Result<(), ExperimentStoreError> {
545 Ok(())
546 }
547}
548
549impl<T: ExperimentStore> ExperimentStore for Arc<T> {
550 fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
551 (**self).get(name)
552 }
553 fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
554 (**self).list()
555 }
556 fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
557 (**self).upsert(config)
558 }
559 fn set_state(
560 &self,
561 name: &str,
562 state: ExperimentState,
563 winner: Option<&str>,
564 ) -> Result<(), ExperimentStoreError> {
565 (**self).set_state(name, state, winner)
566 }
567 fn set_variants(
568 &self,
569 name: &str,
570 variants: Vec<VariantConfig>,
571 actor: Option<&str>,
572 ) -> Result<(), ExperimentStoreError> {
573 (**self).set_variants(name, variants, actor)
574 }
575 fn get_assignment(
576 &self,
577 experiment: &str,
578 actor: &str,
579 ) -> Result<Option<Assignment>, ExperimentStoreError> {
580 (**self).get_assignment(experiment, actor)
581 }
582 fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
583 (**self).record_assignment(assignment)
584 }
585 fn get_override(
586 &self,
587 experiment: &str,
588 actor: &str,
589 ) -> Result<Option<String>, ExperimentStoreError> {
590 (**self).get_override(experiment, actor)
591 }
592 fn set_override(
593 &self,
594 experiment: &str,
595 actor: &str,
596 variant: &str,
597 ) -> Result<(), ExperimentStoreError> {
598 (**self).set_override(experiment, actor, variant)
599 }
600 fn has_assignment_in_group(
601 &self,
602 actor: &str,
603 group: &str,
604 exclude_experiment: &str,
605 ) -> Result<bool, ExperimentStoreError> {
606 (**self).has_assignment_in_group(actor, group, exclude_experiment)
607 }
608 fn history(
609 &self,
610 experiment: &str,
611 limit: usize,
612 ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
613 (**self).history(experiment, limit)
614 }
615 fn preload(&self) -> Result<(), ExperimentStoreError> {
616 (**self).preload()
617 }
618}
619
620#[derive(Default)]
623struct StoreInner {
624 experiments: HashMap<String, ExperimentConfig>,
625 assignments: HashMap<PairKeyOwned, Assignment>,
628 overrides: HashMap<PairKeyOwned, String>,
630 changes: HashMap<String, Vec<ChangeRecord>>,
631}
632
633trait PairKey {
636 fn pair(&self) -> (&str, &str);
637}
638
639#[derive(Debug, PartialEq, Eq)]
646struct PairKeyOwned(String, String);
647
648impl PairKey for PairKeyOwned {
649 fn pair(&self) -> (&str, &str) {
650 (self.0.as_str(), self.1.as_str())
651 }
652}
653
654impl std::hash::Hash for PairKeyOwned {
657 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
658 std::hash::Hash::hash(&self.pair(), state);
659 }
660}
661
662impl PairKey for (&str, &str) {
663 fn pair(&self) -> (&str, &str) {
664 (self.0, self.1)
665 }
666}
667
668impl<'a> std::borrow::Borrow<dyn PairKey + 'a> for PairKeyOwned {
669 fn borrow(&self) -> &(dyn PairKey + 'a) {
670 self
671 }
672}
673
674impl std::hash::Hash for dyn PairKey + '_ {
677 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
678 std::hash::Hash::hash(&self.pair(), state);
679 }
680}
681
682impl PartialEq for dyn PairKey + '_ {
683 fn eq(&self, other: &Self) -> bool {
684 self.pair() == other.pair()
685 }
686}
687
688impl Eq for dyn PairKey + '_ {}
689
690#[derive(Default)]
698pub struct InMemoryExperimentStore {
699 inner: RwLock<StoreInner>,
700}
701
702impl InMemoryExperimentStore {
703 #[must_use]
705 pub fn new() -> Self {
706 Self::default()
707 }
708}
709
710impl ExperimentStore for InMemoryExperimentStore {
711 fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
712 Ok(self.inner.read().unwrap().experiments.get(name).cloned())
713 }
714
715 fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
716 let mut exps: Vec<ExperimentConfig> = {
717 let inner = self.inner.read().unwrap();
718 inner.experiments.values().cloned().collect()
719 };
720 exps.sort_by(|a, b| a.name.cmp(&b.name));
721 Ok(exps)
722 }
723
724 fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
725 let name = config.name.clone();
726 {
727 let mut inner = self.inner.write().unwrap();
728 let active_variants: std::collections::HashSet<String> = inner
729 .assignments
730 .values()
731 .filter(|a| a.experiment == name)
732 .map(|a| a.variant.clone())
733 .collect();
734
735 let new_variants: std::collections::HashSet<&str> =
736 config.variants.iter().map(|v| v.name.as_str()).collect();
737
738 for variant in active_variants {
739 if !new_variants.contains(variant.as_str()) {
740 return Err(ExperimentStoreError::Backend(format!(
741 "cannot delete variant '{variant}' because it has active assignments"
742 )));
743 }
744 }
745
746 let exists = inner.experiments.contains_key(&name);
747 inner.experiments.insert(name.clone(), config);
748 let mutation = if exists { "updated" } else { "created" };
749 inner
750 .changes
751 .entry(name.clone())
752 .or_default()
753 .push(ChangeRecord::now(&name, mutation, None));
754 }
755 Ok(())
756 }
757
758 fn set_state(
759 &self,
760 name: &str,
761 state: ExperimentState,
762 winner: Option<&str>,
763 ) -> Result<(), ExperimentStoreError> {
764 {
765 let mut inner = self.inner.write().unwrap();
766 if let Some(exp) = inner.experiments.get_mut(name) {
767 exp.state = state;
768 if let Some(w) = winner {
769 exp.winner = Some(w.to_owned());
770 }
771 exp.updated_at_secs = now_secs();
772 }
773 inner
774 .changes
775 .entry(name.to_owned())
776 .or_default()
777 .push(ChangeRecord::now(
778 name,
779 winner.map_or_else(|| format!("state={state}"), |w| format!("concluded={w}")),
780 None,
781 ));
782 }
783 Ok(())
784 }
785
786 fn set_variants(
787 &self,
788 name: &str,
789 variants: Vec<VariantConfig>,
790 actor: Option<&str>,
791 ) -> Result<(), ExperimentStoreError> {
792 {
793 let mut inner = self.inner.write().unwrap();
794 let active_variants: std::collections::HashSet<String> = inner
795 .assignments
796 .values()
797 .filter(|a| a.experiment == name)
798 .map(|a| a.variant.clone())
799 .collect();
800
801 let new_variants: std::collections::HashSet<&str> =
802 variants.iter().map(|v| v.name.as_str()).collect();
803
804 for variant in active_variants {
805 if !new_variants.contains(variant.as_str()) {
806 return Err(ExperimentStoreError::Backend(format!(
807 "cannot delete variant '{variant}' because it has active assignments"
808 )));
809 }
810 }
811
812 if let Some(exp) = inner.experiments.get_mut(name) {
813 exp.variants = variants;
814 exp.updated_at_secs = now_secs();
815 }
816 inner
817 .changes
818 .entry(name.to_owned())
819 .or_default()
820 .push(ChangeRecord::now(name, "set_weights", actor));
821 }
822 Ok(())
823 }
824
825 fn get_assignment(
826 &self,
827 experiment: &str,
828 actor: &str,
829 ) -> Result<Option<Assignment>, ExperimentStoreError> {
830 let inner = self.inner.read().unwrap();
831 Ok(inner
833 .assignments
834 .get(&(experiment, actor) as &dyn PairKey)
835 .cloned())
836 }
837
838 fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
839 let mut inner = self.inner.write().unwrap();
840
841 if !assignment.is_override {
842 if let Some(group) = inner
844 .experiments
845 .get(&assignment.experiment)
846 .and_then(|c| c.exclusion_group.as_ref())
847 {
848 for (exp_name, exp_config) in &inner.experiments {
850 if exp_name == &assignment.experiment {
851 continue;
852 }
853 if exp_config.exclusion_group.as_ref() == Some(group)
854 && inner
855 .assignments
856 .contains_key(
857 &(exp_name.as_str(), assignment.actor.as_str()) as &dyn PairKey
858 )
859 {
860 return Err(ExperimentStoreError::Backend(format!(
861 "ExcludedByGroup:{group}"
862 )));
863 }
864 }
865 }
866 }
867
868 let variant = assignment.variant.clone();
869 let key = PairKeyOwned(assignment.experiment.clone(), assignment.actor.clone());
870 inner.assignments.insert(key, assignment);
871 drop(inner);
872 Ok(variant)
873 }
874
875 fn get_override(
876 &self,
877 experiment: &str,
878 actor: &str,
879 ) -> Result<Option<String>, ExperimentStoreError> {
880 let inner = self.inner.read().unwrap();
881 Ok(inner
882 .overrides
883 .get(&(experiment, actor) as &dyn PairKey)
884 .cloned())
885 }
886
887 fn set_override(
888 &self,
889 experiment: &str,
890 actor: &str,
891 variant: &str,
892 ) -> Result<(), ExperimentStoreError> {
893 let key = PairKeyOwned(experiment.to_owned(), actor.to_owned());
894 self.inner
895 .write()
896 .unwrap()
897 .overrides
898 .insert(key, variant.to_owned());
899 Ok(())
900 }
901
902 fn has_assignment_in_group(
903 &self,
904 actor: &str,
905 group: &str,
906 exclude_experiment: &str,
907 ) -> Result<bool, ExperimentStoreError> {
908 let inner = self.inner.read().unwrap();
909 for (exp_name, config) in &inner.experiments {
910 if exp_name == exclude_experiment {
911 continue;
912 }
913 if config.exclusion_group.as_deref() != Some(group) {
914 continue;
915 }
916 if inner
917 .assignments
918 .contains_key(&(exp_name.as_str(), actor) as &dyn PairKey)
919 {
920 return Ok(true);
921 }
922 }
923 Ok(false)
924 }
925
926 fn history(
927 &self,
928 experiment: &str,
929 limit: usize,
930 ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
931 let records = {
932 let inner = self.inner.read().unwrap();
933 inner
934 .changes
935 .get(experiment)
936 .map(|v| {
937 if limit == 0 {
938 v.clone()
939 } else {
940 v.iter().rev().take(limit).cloned().collect()
941 }
942 })
943 .unwrap_or_default()
944 };
945 Ok(records)
946 }
947}
948
949fn now_secs() -> u64 {
952 crate::time::clock_unix_secs(&crate::time::AmbientClock)
953}
954
955fn fnv1a_64(parts: &[&[u8]]) -> u64 {
960 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
961 const FNV_PRIME: u64 = 1_099_511_628_211;
962 let mut hash = FNV_OFFSET;
963 for &byte in parts.iter().copied().flatten() {
964 #[allow(clippy::cast_lossless)]
965 {
966 hash ^= byte as u64;
967 }
968 hash = hash.wrapping_mul(FNV_PRIME);
969 }
970 hash
971}
972
973#[must_use]
989pub fn experiment_bucket(experiment: &str, actor_id: &str) -> u64 {
990 fnv1a_64(&[experiment.as_bytes(), b":".as_slice(), actor_id.as_bytes()]) % 10_000
993}
994
995fn select_variant(variants: &[VariantConfig], bucket: u64) -> Option<&str> {
999 let total_weight: u64 = variants.iter().map(|v| u64::from(v.weight)).sum();
1000 if total_weight == 0 {
1001 return None;
1002 }
1003 let threshold = bucket * total_weight / 10_000;
1004 let mut cumulative: u64 = 0;
1005 for v in variants {
1006 cumulative += u64::from(v.weight);
1007 if threshold < cumulative {
1008 return Some(&v.name);
1009 }
1010 }
1011 variants.last().map(|v| v.name.as_str())
1012}
1013
1014#[derive(Debug, thiserror::Error)]
1018pub enum ExperimentError {
1019 #[error("experiment '{0}' not found")]
1021 NotFound(String),
1022
1023 #[error("experiment '{0}' is not running (state: {1})")]
1025 NotRunning(String, ExperimentState),
1026
1027 #[error("experiment '{0}' is archived")]
1029 Archived(String),
1030
1031 #[error("actor excluded from experiment '{0}' by mutual exclusion group '{1}'")]
1033 ExcludedByGroup(String, String),
1034
1035 #[error("experiment '{0}' has no assignable variant (all weights are zero)")]
1037 NoVariant(String),
1038
1039 #[error(transparent)]
1041 Store(#[from] ExperimentStoreError),
1042}
1043
1044fn validate_variants(variants: &[VariantConfig]) -> Result<(), ExperimentError> {
1047 let mut seen = std::collections::HashSet::new();
1048 for v in variants {
1049 if v.name.trim().is_empty() {
1050 return Err(ExperimentError::NoVariant(
1051 "variant name must not be empty".into(),
1052 ));
1053 }
1054 if !seen.insert(v.name.as_str()) {
1055 return Err(ExperimentError::NoVariant(format!(
1056 "duplicate variant name: '{}'",
1057 v.name
1058 )));
1059 }
1060 }
1061 Ok(())
1062}
1063
1064#[derive(Clone)]
1093pub struct ExperimentService {
1094 store: Arc<dyn ExperimentStore>,
1095 exposure_sink: Arc<dyn ExposureSink>,
1096}
1097
1098impl std::fmt::Debug for ExperimentService {
1099 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1100 f.debug_struct("ExperimentService").finish_non_exhaustive()
1101 }
1102}
1103
1104impl ExperimentService {
1105 #[must_use]
1108 pub fn new(store: Arc<dyn ExperimentStore>) -> Self {
1109 Self {
1110 store,
1111 exposure_sink: Arc::new(TracingExposureSink),
1112 }
1113 }
1114
1115 #[must_use]
1117 pub fn with_exposure_sink(mut self, sink: Arc<dyn ExposureSink>) -> Self {
1118 self.exposure_sink = sink;
1119 self
1120 }
1121
1122 pub fn assign(&self, experiment: &str, actor: &str) -> Result<String, ExperimentError> {
1145 self.assign_with_request_id(experiment, actor, None)
1146 }
1147
1148 pub async fn assign_async(
1158 &self,
1159 experiment: &str,
1160 actor: &str,
1161 request_id: Option<&str>,
1162 ) -> Result<String, ExperimentError> {
1163 let service = self.clone();
1164 let (experiment, actor) = (experiment.to_owned(), actor.to_owned());
1165 let request_id = request_id.map(str::to_owned);
1166 crate::time::spawn_blocking(move || {
1167 service.assign_with_request_id(&experiment, &actor, request_id.as_deref())
1168 })
1169 .await
1170 .unwrap_or_else(|error| {
1171 Err(ExperimentError::Store(ExperimentStoreError::Backend(
1172 format!("assignment task failed: {error}"),
1173 )))
1174 })
1175 }
1176
1177 pub fn preload(&self) -> Result<(), ExperimentStoreError> {
1183 self.store.preload()
1184 }
1185
1186 pub fn assign_with_request_id(
1195 &self,
1196 experiment: &str,
1197 actor: &str,
1198 request_id: Option<&str>,
1199 ) -> Result<String, ExperimentError> {
1200 let config = self
1201 .store
1202 .get(experiment)?
1203 .ok_or_else(|| ExperimentError::NotFound(experiment.to_owned()))?;
1204
1205 match config.state {
1206 ExperimentState::Archived => {
1207 return Err(ExperimentError::Archived(experiment.to_owned()));
1208 }
1209 ExperimentState::Concluded => {
1210 let winner = config
1212 .winner
1213 .ok_or_else(|| ExperimentError::NoVariant(experiment.to_owned()))?;
1214 return Ok(winner);
1215 }
1216 ExperimentState::Draft => {
1217 return Err(ExperimentError::NotRunning(
1218 experiment.to_owned(),
1219 ExperimentState::Draft,
1220 ));
1221 }
1222 ExperimentState::Running => {}
1223 }
1224
1225 if let Some(override_variant) = self.store.get_override(experiment, actor)?
1228 && config.variants.iter().any(|v| v.name == override_variant)
1229 {
1230 let sticky = Assignment::new(experiment, actor, &override_variant, true);
1231 if let Err(e) = self.store.record_assignment(sticky) {
1232 match e {
1233 ExperimentStoreError::Backend(msg) if msg.starts_with("ExcludedByGroup:") => {
1234 let group = msg
1235 .strip_prefix("ExcludedByGroup:")
1236 .unwrap_or("")
1237 .trim()
1238 .to_owned();
1239 return Err(ExperimentError::ExcludedByGroup(
1240 experiment.to_owned(),
1241 group,
1242 ));
1243 }
1244 other @ ExperimentStoreError::Backend(_) => {
1245 return Err(ExperimentError::Store(other));
1246 }
1247 }
1248 }
1249 self.emit_exposure(experiment, &override_variant, actor, request_id, true);
1250 return Ok(override_variant);
1251 }
1252
1253 if let Some(existing) = self.store.get_assignment(experiment, actor)? {
1255 self.emit_exposure(
1256 experiment,
1257 &existing.variant,
1258 actor,
1259 request_id,
1260 existing.is_override,
1261 );
1262 return Ok(existing.variant);
1263 }
1264
1265 if let Some(group) = &config.exclusion_group
1267 && self
1268 .store
1269 .has_assignment_in_group(actor, group, experiment)?
1270 {
1271 return Err(ExperimentError::ExcludedByGroup(
1272 experiment.to_owned(),
1273 group.clone(),
1274 ));
1275 }
1276
1277 let bucket = experiment_bucket(experiment, actor);
1279 let variant_name = select_variant(&config.variants, bucket)
1280 .ok_or_else(|| ExperimentError::NoVariant(experiment.to_owned()))?
1281 .to_owned();
1282
1283 let assignment = Assignment::new(experiment, actor, &variant_name, false);
1286 let persisted_variant = match self.store.record_assignment(assignment) {
1287 Ok(v) => v,
1288 Err(ExperimentStoreError::Backend(msg)) if msg.starts_with("ExcludedByGroup:") => {
1289 let group = msg
1290 .strip_prefix("ExcludedByGroup:")
1291 .unwrap_or("")
1292 .trim()
1293 .to_owned();
1294 return Err(ExperimentError::ExcludedByGroup(
1295 experiment.to_owned(),
1296 group,
1297 ));
1298 }
1299 Err(other @ ExperimentStoreError::Backend(_)) => {
1300 return Err(ExperimentError::Store(other));
1301 }
1302 };
1303 self.emit_exposure(experiment, &persisted_variant, actor, request_id, false);
1304
1305 Ok(persisted_variant)
1306 }
1307
1308 fn emit_exposure(
1309 &self,
1310 experiment: &str,
1311 variant: &str,
1312 actor: &str,
1313 request_id: Option<&str>,
1314 is_override: bool,
1315 ) {
1316 self.exposure_sink.record(ExposureRecord {
1317 experiment: experiment.to_owned(),
1318 variant: variant.to_owned(),
1319 actor: actor.to_owned(),
1320 request_id: request_id.map(str::to_owned),
1321 is_override,
1322 timestamp_secs: now_secs(),
1323 });
1324 }
1325
1326 pub fn create(&self, config: ExperimentConfig) -> Result<(), ExperimentError> {
1332 validate_variants(&config.variants)?;
1333 if config.state == ExperimentState::Concluded {
1334 let winner = config
1335 .winner
1336 .as_deref()
1337 .filter(|w| !w.trim().is_empty())
1338 .ok_or_else(|| {
1339 ExperimentError::NoVariant(
1340 "concluded experiment requires a non-empty winner".into(),
1341 )
1342 })?;
1343 if !config.variants.iter().any(|v| v.name == winner) {
1344 return Err(ExperimentError::NoVariant(format!(
1345 "'{winner}' is not a configured variant"
1346 )));
1347 }
1348 }
1349 self.store.upsert(config)?;
1350 Ok(())
1351 }
1352
1353 pub fn start(&self, name: &str) -> Result<(), ExperimentError> {
1362 let config = self
1363 .store
1364 .get(name)?
1365 .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1366 if config.state == ExperimentState::Archived {
1367 return Err(ExperimentError::Archived(name.to_owned()));
1368 }
1369 self.store.set_state(name, ExperimentState::Running, None)?;
1370 Ok(())
1371 }
1372
1373 pub fn conclude(&self, name: &str, winner: &str) -> Result<(), ExperimentError> {
1382 let config = self
1383 .store
1384 .get(name)?
1385 .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1386 if config.state == ExperimentState::Archived {
1387 return Err(ExperimentError::Archived(name.to_owned()));
1388 }
1389 if !config.variants.iter().any(|v| v.name == winner) {
1390 return Err(ExperimentError::NoVariant(format!(
1391 "'{winner}' is not a configured variant of experiment '{name}'"
1392 )));
1393 }
1394 self.store
1395 .set_state(name, ExperimentState::Concluded, Some(winner))?;
1396 Ok(())
1397 }
1398
1399 pub fn archive(&self, name: &str) -> Result<(), ExperimentError> {
1408 self.store
1409 .get(name)?
1410 .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1411 self.store
1412 .set_state(name, ExperimentState::Archived, None)?;
1413 Ok(())
1414 }
1415
1416 pub fn set_weights(
1426 &self,
1427 name: &str,
1428 variants: Vec<VariantConfig>,
1429 actor: Option<&str>,
1430 ) -> Result<(), ExperimentError> {
1431 validate_variants(&variants)?;
1432 let config = self
1433 .store
1434 .get(name)?
1435 .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1436 match config.state {
1437 ExperimentState::Concluded => {
1438 return Err(ExperimentError::NotRunning(
1439 name.to_owned(),
1440 ExperimentState::Concluded,
1441 ));
1442 }
1443 ExperimentState::Archived => {
1444 return Err(ExperimentError::Archived(name.to_owned()));
1445 }
1446 _ => {}
1447 }
1448 self.store.set_variants(name, variants, actor)?;
1449 Ok(())
1450 }
1451
1452 pub fn set_override(
1461 &self,
1462 experiment: &str,
1463 actor: &str,
1464 variant: &str,
1465 ) -> Result<(), ExperimentError> {
1466 let config = self
1467 .store
1468 .get(experiment)?
1469 .ok_or_else(|| ExperimentError::NotFound(experiment.to_owned()))?;
1470 if !config.variants.iter().any(|v| v.name == variant) {
1471 return Err(ExperimentError::NoVariant(format!(
1472 "'{variant}' is not a configured variant of experiment '{experiment}'"
1473 )));
1474 }
1475 self.store.set_override(experiment, actor, variant)?;
1476 Ok(())
1477 }
1478
1479 pub fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentError> {
1485 Ok(self.store.list()?)
1486 }
1487
1488 pub fn status(&self, name: &str) -> Result<ExperimentConfig, ExperimentError> {
1494 self.store
1495 .get(name)?
1496 .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))
1497 }
1498
1499 pub fn history(
1505 &self,
1506 experiment: &str,
1507 limit: usize,
1508 ) -> Result<Vec<ChangeRecord>, ExperimentError> {
1509 Ok(self.store.history(experiment, limit)?)
1510 }
1511}
1512
1513pub(crate) fn preload_blocking(service: &ExperimentService, wait: std::time::Duration) {
1518 let service = service.clone();
1519 crate::feature_flags::preload_with(
1520 "experiment",
1521 "assignments fail until the first refresh",
1522 wait,
1523 move || service.preload(),
1524 );
1525}
1526
1527pub struct Experiments {
1556 service: ExperimentService,
1557 actor_id: Option<String>,
1558 request_id: Option<String>,
1559}
1560
1561impl Experiments {
1562 pub fn assign(&self, experiment: &str) -> Result<String, ExperimentError> {
1576 let actor = self.actor_id.as_deref().unwrap_or("anonymous");
1577 self.service
1578 .assign_with_request_id(experiment, actor, self.request_id.as_deref())
1579 }
1580
1581 pub async fn assign_async(&self, experiment: &str) -> Result<String, ExperimentError> {
1590 let actor = self.actor_id.as_deref().unwrap_or("anonymous");
1591 self.service
1592 .assign_async(experiment, actor, self.request_id.as_deref())
1593 .await
1594 }
1595
1596 #[must_use]
1598 pub const fn service(&self) -> &ExperimentService {
1599 &self.service
1600 }
1601}
1602
1603impl axum::extract::FromRequestParts<crate::AppState> for Experiments {
1604 type Rejection = crate::AutumnError;
1605
1606 async fn from_request_parts(
1607 parts: &mut axum::http::request::Parts,
1608 state: &crate::AppState,
1609 ) -> Result<Self, Self::Rejection> {
1610 let service = state
1611 .extension::<ExperimentService>()
1612 .map(|arc| (*arc).clone())
1613 .ok_or_else(|| {
1614 crate::AutumnError::internal_server_error_msg(
1615 "experiment service not registered; \
1616 install an ExperimentStore via AppBuilder::with_experiment_store()",
1617 )
1618 })?;
1619
1620 let actor_id = if let Some(session) = parts.extensions.get::<crate::session::Session>() {
1621 let session_key = state.auth_session_key();
1625 if let Some(uid) = session.get(session_key).await {
1626 Some(uid)
1627 } else {
1628 const ANON_KEY: &str = "_autumn_anon_actor";
1633 if let Some(existing) = session.get(ANON_KEY).await {
1634 Some(existing)
1635 } else {
1636 let id = session.id().await;
1637 session.insert(ANON_KEY, &id).await;
1638 Some(id)
1639 }
1640 }
1641 } else {
1642 None
1643 };
1644
1645 let request_id = parts
1646 .headers
1647 .get("x-request-id")
1648 .and_then(|v| v.to_str().ok())
1649 .map(str::to_owned);
1650
1651 Ok(Self {
1652 service,
1653 actor_id,
1654 request_id,
1655 })
1656 }
1657}
1658
1659#[cfg(feature = "postgres")]
1662pub mod pg {
1663 use super::{
1664 Assignment, ChangeRecord, ExperimentConfig, ExperimentState, ExperimentStore,
1665 ExperimentStoreError, VariantConfig,
1666 };
1667 use crate::feature_flags::pg::{STATEMENT_TIMEOUT_MS, retry_delay, with_connect_timeout};
1668 use diesel::connection::SimpleConnection;
1669 use diesel::prelude::*;
1670 use std::collections::HashMap;
1671 use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
1672 use std::sync::{Arc, Mutex, PoisonError, RwLock};
1673 use std::time::{Duration, Instant};
1674
1675 #[derive(diesel::QueryableByName)]
1678 struct ExperimentRow {
1679 #[diesel(sql_type = diesel::sql_types::Text)]
1680 name: String,
1681 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1682 description: Option<String>,
1683 #[diesel(sql_type = diesel::sql_types::Text)]
1684 state: String,
1685 #[diesel(sql_type = diesel::sql_types::Text)]
1686 variants: String,
1687 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1688 winner: Option<String>,
1689 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1690 exclusion_group: Option<String>,
1691 #[diesel(sql_type = diesel::sql_types::BigInt)]
1692 updated_at_secs: i64,
1693 }
1694
1695 impl ExperimentRow {
1696 fn into_config(self) -> ExperimentConfig {
1697 let variants: Vec<VariantConfig> =
1698 serde_json::from_str(&self.variants).unwrap_or_default();
1699 let state = match self.state.as_str() {
1700 "running" => ExperimentState::Running,
1701 "concluded" => ExperimentState::Concluded,
1702 "archived" => ExperimentState::Archived,
1703 _ => ExperimentState::Draft,
1704 };
1705 ExperimentConfig {
1706 name: self.name,
1707 description: self.description,
1708 state,
1709 variants,
1710 winner: self.winner,
1711 exclusion_group: self.exclusion_group,
1712 updated_at_secs: u64::try_from(self.updated_at_secs).unwrap_or(0),
1713 }
1714 }
1715 }
1716
1717 #[derive(diesel::QueryableByName)]
1718 struct AssignmentRow {
1719 #[diesel(sql_type = diesel::sql_types::Text)]
1720 experiment: String,
1721 #[diesel(sql_type = diesel::sql_types::Text)]
1722 actor: String,
1723 #[diesel(sql_type = diesel::sql_types::Text)]
1724 variant: String,
1725 #[diesel(sql_type = diesel::sql_types::Bool)]
1726 is_override: bool,
1727 #[diesel(sql_type = diesel::sql_types::BigInt)]
1728 assigned_at_secs: i64,
1729 }
1730
1731 #[derive(diesel::QueryableByName)]
1732 struct BoolRow {
1733 #[diesel(sql_type = diesel::sql_types::Bool)]
1734 result: bool,
1735 }
1736
1737 #[derive(diesel::QueryableByName)]
1738 struct VariantNameRow {
1739 #[diesel(sql_type = diesel::sql_types::Text)]
1740 variant: String,
1741 }
1742
1743 #[derive(diesel::QueryableByName)]
1744 struct ExclusionGroupRow {
1745 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1746 exclusion_group: Option<String>,
1747 }
1748
1749 #[derive(diesel::QueryableByName)]
1750 struct ChangeRow {
1751 #[diesel(sql_type = diesel::sql_types::Text)]
1752 experiment: String,
1753 #[diesel(sql_type = diesel::sql_types::Text)]
1754 mutation: String,
1755 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1756 actor: Option<String>,
1757 #[diesel(sql_type = diesel::sql_types::BigInt)]
1758 timestamp_secs: i64,
1759 }
1760
1761 #[derive(Debug, Default)]
1765 struct Snapshot {
1766 experiments: HashMap<String, ExperimentConfig>,
1767 loaded: bool,
1769 checked_at: Option<Instant>,
1771 }
1772
1773 struct Shared {
1775 database_url: String,
1776 connect_url: String,
1778 cache_ttl: Duration,
1779 snapshot: RwLock<Snapshot>,
1780 refreshing: AtomicBool,
1782 refresh_conn: Mutex<Option<diesel::PgConnection>>,
1785 refresh_errors: AtomicU64,
1786 failures: AtomicU32,
1788 #[cfg(test)]
1789 connect_threads: Mutex<Vec<std::thread::ThreadId>>,
1790 }
1791
1792 impl std::fmt::Debug for Shared {
1793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1795 f.debug_struct("PgExperimentStore")
1796 .field("cache_ttl", &self.cache_ttl)
1797 .field("refresh_errors", &self.refresh_errors)
1798 .finish_non_exhaustive()
1799 }
1800 }
1801
1802 struct RefreshSlot(Arc<Shared>);
1805
1806 impl Drop for RefreshSlot {
1807 fn drop(&mut self) {
1808 self.0.refreshing.store(false, Ordering::Release);
1809 }
1810 }
1811
1812 impl Shared {
1813 fn connect(&self) -> Result<diesel::PgConnection, ExperimentStoreError> {
1814 #[cfg(test)]
1815 self.connect_threads
1816 .lock()
1817 .unwrap_or_else(PoisonError::into_inner)
1818 .push(std::thread::current().id());
1819 crate::db_url::require_postgres_target(&self.database_url, "PgExperimentStore")
1820 .map_err(ExperimentStoreError::Backend)?;
1821 let mut conn = diesel::PgConnection::establish(&self.connect_url).map_err(|e| {
1823 ExperimentStoreError::Backend(crate::db_url::redact_driver_error(
1824 &e.to_string(),
1825 &self.database_url,
1826 ))
1827 })?;
1828 conn.batch_execute(&format!("SET statement_timeout = {STATEMENT_TIMEOUT_MS}"))
1829 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
1830 Ok(conn)
1831 }
1832
1833 fn is_stale(&self) -> bool {
1836 let wait = retry_delay(self.cache_ttl, self.failures.load(Ordering::Relaxed));
1837 let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
1838 snapshot.checked_at.is_none_or(|checked| {
1839 checked
1840 .checked_add(wait)
1841 .is_some_and(|due| crate::time::system_instant() >= due)
1842 })
1843 }
1844
1845 fn is_loaded(&self) -> bool {
1846 self.snapshot
1847 .read()
1848 .unwrap_or_else(PoisonError::into_inner)
1849 .loaded
1850 }
1851
1852 fn mark_stale(&self) {
1853 self.snapshot
1854 .write()
1855 .unwrap_or_else(PoisonError::into_inner)
1856 .checked_at = None;
1857 }
1858
1859 fn read(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
1860 let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
1861 match snapshot.experiments.get(name) {
1862 Some(config) => Ok(Some(config.clone())),
1863 None if snapshot.loaded => Ok(None),
1864 None => Err(ExperimentStoreError::Backend(
1865 "experiment snapshot not loaded yet; call PgExperimentStore::refresh at startup"
1866 .to_owned(),
1867 )),
1868 }
1869 }
1870
1871 fn begin_refresh(self: &Arc<Self>) -> Option<RefreshSlot> {
1873 self.refreshing
1874 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1875 .ok()
1876 .map(|_| RefreshSlot(Arc::clone(self)))
1877 }
1878
1879 fn reload(&self) -> Result<(), ExperimentStoreError> {
1881 self.load().map(drop)
1882 }
1883
1884 fn load(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
1887 let mut conn = self
1888 .refresh_conn
1889 .lock()
1890 .unwrap_or_else(PoisonError::into_inner);
1891 self.snapshot
1892 .write()
1893 .unwrap_or_else(PoisonError::into_inner)
1894 .checked_at = Some(crate::time::system_instant());
1895 let fresh = || {
1896 let mut open = self.connect()?;
1897 let all = load_all(&mut open)?;
1898 Ok((open, all))
1899 };
1900 let loaded = conn
1902 .take()
1903 .map_or_else(fresh, |mut open| {
1904 load_all(&mut open)
1905 .map(|all| (open, all))
1906 .or_else(|_| fresh())
1907 })
1908 .map(|(open, all)| {
1909 *conn = Some(open);
1911 all
1912 });
1913 if let Ok(all) = &loaded {
1914 let mut snapshot = self
1916 .snapshot
1917 .write()
1918 .unwrap_or_else(PoisonError::into_inner);
1919 snapshot.experiments = all.iter().map(|e| (e.name.clone(), e.clone())).collect();
1920 snapshot.loaded = true;
1921 }
1922 drop(conn);
1923 match loaded {
1924 Ok(all) => {
1925 if self.failures.swap(0, Ordering::AcqRel) > 0 {
1926 tracing::info!("experiment refresh recovered");
1927 }
1928 Ok(all)
1929 }
1930 Err(error) => {
1931 self.refresh_errors.fetch_add(1, Ordering::Relaxed);
1932 if self.failures.fetch_add(1, Ordering::AcqRel) == 0 {
1933 tracing::warn!(
1934 %error,
1935 "experiment refresh failed; serving the last-known snapshot"
1936 );
1937 } else {
1938 tracing::debug!(%error, "experiment refresh failed again");
1939 }
1940 Err(error)
1941 }
1942 }
1943 }
1944 }
1945
1946 fn load_all(
1947 conn: &mut diesel::PgConnection,
1948 ) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
1949 diesel::sql_query(
1950 "SELECT name, description, state::text, variants::text, winner, \
1951 exclusion_group, \
1952 EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_secs \
1953 FROM autumn_experiments ORDER BY name",
1954 )
1955 .load::<ExperimentRow>(conn)
1956 .map(|rows| rows.into_iter().map(ExperimentRow::into_config).collect())
1957 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
1958 }
1959
1960 #[derive(Debug)]
1995 pub struct PgExperimentStore {
1996 shared: Arc<Shared>,
1997 }
1998
1999 impl Clone for PgExperimentStore {
2000 fn clone(&self) -> Self {
2002 Self::with_cache_ttl(self.shared.database_url.clone(), self.shared.cache_ttl)
2003 }
2004 }
2005
2006 impl PgExperimentStore {
2007 pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(1);
2009
2010 #[must_use]
2012 pub fn new(database_url: impl Into<String>) -> Self {
2013 Self::with_cache_ttl(database_url, Self::DEFAULT_CACHE_TTL)
2014 }
2015
2016 #[must_use]
2019 pub fn with_cache_ttl(database_url: impl Into<String>, cache_ttl: Duration) -> Self {
2020 let database_url = database_url.into();
2021 Self {
2022 shared: Arc::new(Shared {
2023 connect_url: with_connect_timeout(&database_url),
2024 database_url,
2025 cache_ttl,
2026 snapshot: RwLock::new(Snapshot::default()),
2027 refreshing: AtomicBool::new(false),
2028 refresh_conn: Mutex::new(None),
2029 refresh_errors: AtomicU64::new(0),
2030 failures: AtomicU32::new(0),
2031 #[cfg(test)]
2032 connect_threads: Mutex::new(Vec::new()),
2033 }),
2034 }
2035 }
2036
2037 #[must_use]
2044 pub fn from_database_config(config: &crate::config::DatabaseConfig) -> Option<Self> {
2045 config.effective_primary_postgres_url().map(Self::new)
2046 }
2047
2048 pub fn refresh(&self) -> Result<(), ExperimentStoreError> {
2056 self.shared.reload()
2057 }
2058
2059 #[must_use]
2061 pub fn refresh_errors(&self) -> u64 {
2062 self.shared.refresh_errors.load(Ordering::Relaxed)
2063 }
2064
2065 fn connect(&self) -> Result<diesel::PgConnection, ExperimentStoreError> {
2066 self.shared.connect()
2067 }
2068
2069 fn refresh_after_write(&self) {
2073 if self.shared.reload().is_err() {
2074 self.shared.mark_stale();
2075 }
2076 }
2077
2078 #[allow(
2085 clippy::must_use_candidate,
2086 reason = "the thread runs detached; callers drop the handle"
2087 )]
2088 pub fn spawn_poll_listener(
2089 store: Arc<Self>,
2090 poll_interval: Duration,
2091 ) -> std::thread::JoinHandle<()> {
2092 let shared = Arc::downgrade(&store.shared);
2093 drop(store);
2094 std::thread::spawn(move || {
2095 while let Some(shared) = shared.upgrade() {
2096 if let Some(slot) = shared.begin_refresh() {
2097 let reload = std::panic::AssertUnwindSafe(|| slot.0.reload());
2100 if std::panic::catch_unwind(reload).is_err() {
2101 tracing::warn!("experiment refresh panicked");
2102 }
2103 }
2104 let wait = retry_delay(poll_interval, shared.failures.load(Ordering::Relaxed));
2105 drop(shared);
2106 std::thread::sleep(wait);
2107 }
2108 })
2109 }
2110
2111 #[cfg(test)]
2112 fn install_for_test(&self, experiments: Vec<ExperimentConfig>) {
2113 let mut snapshot = self
2114 .shared
2115 .snapshot
2116 .write()
2117 .unwrap_or_else(PoisonError::into_inner);
2118 snapshot.experiments = experiments
2119 .into_iter()
2120 .map(|e| (e.name.clone(), e))
2121 .collect();
2122 snapshot.loaded = true;
2123 snapshot.checked_at = Some(crate::time::system_instant());
2124 }
2125
2126 #[cfg(test)]
2127 fn connect_threads_for_test(&self) -> Vec<std::thread::ThreadId> {
2128 self.shared
2129 .connect_threads
2130 .lock()
2131 .unwrap_or_else(PoisonError::into_inner)
2132 .clone()
2133 }
2134
2135 #[cfg(test)]
2136 fn refreshing_for_test(&self) -> bool {
2137 self.shared.refreshing.load(Ordering::Acquire)
2138 }
2139
2140 #[cfg(test)]
2141 fn begin_refresh_for_test(&self) -> Option<RefreshSlot> {
2142 self.shared.begin_refresh()
2143 }
2144 }
2145
2146 impl ExperimentStore for PgExperimentStore {
2149 fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
2150 if self.shared.is_stale()
2151 && let Some(slot) = self.shared.begin_refresh()
2152 {
2153 match tokio::runtime::Handle::try_current() {
2154 Ok(_) => {
2156 drop(crate::time::spawn_blocking(move || {
2157 let _ = slot.0.reload();
2159 }));
2160 }
2161 Err(_) => {
2164 if let Err(error) = slot.0.reload()
2165 && !self.shared.is_loaded()
2166 {
2167 return Err(error);
2168 }
2169 }
2170 }
2171 }
2172 self.shared.read(name)
2173 }
2174
2175 fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
2176 self.shared.load()
2177 }
2178
2179 fn preload(&self) -> Result<(), ExperimentStoreError> {
2180 self.refresh()
2181 }
2182
2183 fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
2184 let mut conn = self.connect()?;
2185
2186 let active_variants = diesel::sql_query(
2187 "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
2188 )
2189 .bind::<diesel::sql_types::Text, _>(&config.name)
2190 .load::<VariantNameRow>(&mut conn)
2191 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2192
2193 let new_variants: std::collections::HashSet<&str> =
2194 config.variants.iter().map(|v| v.name.as_str()).collect();
2195
2196 for row in active_variants {
2197 if !new_variants.contains(row.variant.as_str()) {
2198 return Err(ExperimentStoreError::Backend(format!(
2199 "cannot delete variant '{}' because it has active assignments",
2200 row.variant
2201 )));
2202 }
2203 }
2204
2205 let variants_json =
2206 serde_json::to_string(&config.variants).unwrap_or_else(|_| "[]".to_owned());
2207 let state_str = config.state.to_string();
2208 let rows_affected = diesel::sql_query(
2209 "WITH upserted AS ( \
2210 INSERT INTO autumn_experiments \
2211 (name, description, state, variants, winner, exclusion_group) \
2212 VALUES ($1, $2, $3::autumn_experiment_state, $4::jsonb, $5, $6) \
2213 ON CONFLICT (name) DO UPDATE SET \
2214 description = EXCLUDED.description, \
2215 state = EXCLUDED.state, \
2216 variants = EXCLUDED.variants, \
2217 winner = EXCLUDED.winner, \
2218 exclusion_group = EXCLUDED.exclusion_group, \
2219 updated_at = NOW() \
2220 WHERE NOT EXISTS ( \
2221 SELECT 1 FROM autumn_experiment_assignments a \
2222 WHERE a.experiment = EXCLUDED.name \
2223 AND a.variant NOT IN ( \
2224 SELECT x.name FROM jsonb_to_recordset(EXCLUDED.variants) AS x(name text) \
2225 ) \
2226 ) \
2227 RETURNING name, (xmax = 0) AS is_insert \
2228 ) \
2229 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2230 SELECT name, CASE WHEN is_insert THEN 'created' ELSE 'updated' END, NULL FROM upserted",
2231 )
2232 .bind::<diesel::sql_types::Text, _>(&config.name)
2233 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.description)
2234 .bind::<diesel::sql_types::Text, _>(&state_str)
2235 .bind::<diesel::sql_types::Text, _>(&variants_json)
2236 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.winner)
2237 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.exclusion_group)
2238 .execute(&mut conn)
2239 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2240
2241 if rows_affected == 0 {
2242 return Err(ExperimentStoreError::Backend(
2243 "cannot delete variant because it has active assignments".to_owned(),
2244 ));
2245 }
2246
2247 self.refresh_after_write();
2248 Ok(())
2249 }
2250
2251 fn set_state(
2252 &self,
2253 name: &str,
2254 state: ExperimentState,
2255 winner: Option<&str>,
2256 ) -> Result<(), ExperimentStoreError> {
2257 let state_str = state.to_string();
2258 let mutation =
2259 winner.map_or_else(|| format!("state={state}"), |w| format!("concluded={w}"));
2260 let mut conn = self.connect()?;
2261 diesel::sql_query(
2262 "WITH updated AS ( \
2263 UPDATE autumn_experiments \
2264 SET state = $2::autumn_experiment_state, \
2265 winner = COALESCE($3, winner), \
2266 updated_at = NOW() \
2267 WHERE name = $1 \
2268 RETURNING name \
2269 ) \
2270 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2271 SELECT name, $4, NULL FROM updated",
2272 )
2273 .bind::<diesel::sql_types::Text, _>(name)
2274 .bind::<diesel::sql_types::Text, _>(&state_str)
2275 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
2276 winner.map(str::to_owned),
2277 )
2278 .bind::<diesel::sql_types::Text, _>(&mutation)
2279 .execute(&mut conn)
2280 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2281 self.refresh_after_write();
2282 Ok(())
2283 }
2284
2285 fn set_variants(
2286 &self,
2287 name: &str,
2288 variants: Vec<VariantConfig>,
2289 actor: Option<&str>,
2290 ) -> Result<(), ExperimentStoreError> {
2291 let mut conn = self.connect()?;
2292
2293 let active_variants = diesel::sql_query(
2294 "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
2295 )
2296 .bind::<diesel::sql_types::Text, _>(name)
2297 .load::<VariantNameRow>(&mut conn)
2298 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2299
2300 let new_variants: std::collections::HashSet<&str> =
2301 variants.iter().map(|v| v.name.as_str()).collect();
2302
2303 for row in active_variants {
2304 if !new_variants.contains(row.variant.as_str()) {
2305 return Err(ExperimentStoreError::Backend(format!(
2306 "cannot delete variant '{}' because it has active assignments",
2307 row.variant
2308 )));
2309 }
2310 }
2311
2312 let variants_json =
2313 serde_json::to_string(&variants).unwrap_or_else(|_| "[]".to_owned());
2314 let rows_affected = diesel::sql_query(
2315 "WITH updated AS ( \
2316 UPDATE autumn_experiments \
2317 SET variants = $2::jsonb, updated_at = NOW() \
2318 WHERE name = $1 \
2319 AND NOT EXISTS ( \
2320 SELECT 1 FROM autumn_experiment_assignments a \
2321 WHERE a.experiment = name \
2322 AND a.variant NOT IN ( \
2323 SELECT x.name FROM jsonb_to_recordset($2::jsonb) AS x(name text) \
2324 ) \
2325 ) \
2326 RETURNING name \
2327 ) \
2328 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2329 SELECT name, 'set_weights', $3 FROM updated",
2330 )
2331 .bind::<diesel::sql_types::Text, _>(name)
2332 .bind::<diesel::sql_types::Text, _>(&variants_json)
2333 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
2334 actor.map(str::to_owned),
2335 )
2336 .execute(&mut conn)
2337 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2338
2339 if rows_affected == 0 {
2340 let exists_row = diesel::sql_query(
2341 "SELECT EXISTS(SELECT 1 FROM autumn_experiments WHERE name = $1) AS result",
2342 )
2343 .bind::<diesel::sql_types::Text, _>(name)
2344 .get_result::<BoolRow>(&mut conn)
2345 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2346
2347 if exists_row.result {
2348 return Err(ExperimentStoreError::Backend(
2349 "cannot delete variant because it has active assignments".to_owned(),
2350 ));
2351 }
2352 }
2353
2354 self.refresh_after_write();
2355 Ok(())
2356 }
2357
2358 fn get_assignment(
2359 &self,
2360 experiment: &str,
2361 actor: &str,
2362 ) -> Result<Option<Assignment>, ExperimentStoreError> {
2363 let mut conn = self.connect()?;
2364 diesel::sql_query(
2365 "SELECT experiment, actor, variant, is_override, \
2366 EXTRACT(EPOCH FROM assigned_at)::bigint AS assigned_at_secs \
2367 FROM autumn_experiment_assignments \
2368 WHERE experiment = $1 AND actor = $2",
2369 )
2370 .bind::<diesel::sql_types::Text, _>(experiment)
2371 .bind::<diesel::sql_types::Text, _>(actor)
2372 .get_result::<AssignmentRow>(&mut conn)
2373 .optional()
2374 .map(|r| {
2375 r.map(|row| Assignment {
2376 experiment: row.experiment,
2377 actor: row.actor,
2378 variant: row.variant,
2379 is_override: row.is_override,
2380 assigned_at_secs: u64::try_from(row.assigned_at_secs).unwrap_or(0),
2381 })
2382 })
2383 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2384 }
2385
2386 fn record_assignment(
2387 &self,
2388 assignment: Assignment,
2389 ) -> Result<String, ExperimentStoreError> {
2390 use diesel::connection::Connection as _;
2391
2392 #[derive(Debug)]
2393 enum TxError {
2394 Database(diesel::result::Error),
2395 Excluded(String),
2396 }
2397 impl From<diesel::result::Error> for TxError {
2398 fn from(e: diesel::result::Error) -> Self {
2399 Self::Database(e)
2400 }
2401 }
2402
2403 let mut conn = self.connect()?;
2404 let result = conn.transaction::<String, TxError, _>(|conn| {
2405 diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1))")
2407 .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2408 .execute(conn)?;
2409
2410 if !assignment.is_override {
2412 let group_row = diesel::sql_query(
2414 "SELECT exclusion_group FROM autumn_experiments WHERE name = $1"
2415 )
2416 .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2417 .get_result::<ExclusionGroupRow>(conn)
2418 .optional()?;
2419
2420 if let Some(ExclusionGroupRow { exclusion_group: Some(group) }) = group_row {
2421 let exists = diesel::sql_query(
2423 "SELECT EXISTS ( \
2424 SELECT 1 \
2425 FROM autumn_experiment_assignments a \
2426 JOIN autumn_experiments e ON e.name = a.experiment \
2427 WHERE a.actor = $1 \
2428 AND e.exclusion_group = $2 \
2429 AND a.experiment <> $3 \
2430 ) AS result",
2431 )
2432 .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2433 .bind::<diesel::sql_types::Text, _>(&group)
2434 .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2435 .get_result::<BoolRow>(conn)
2436 .map(|r| r.result)?;
2437
2438 if exists {
2439 return Err(TxError::Excluded(group));
2440 }
2441 }
2442 }
2443
2444 let variant_name = diesel::sql_query(
2446 "INSERT INTO autumn_experiment_assignments \
2447 (experiment, actor, variant, is_override) \
2448 VALUES ($1, $2, $3, $4) \
2449 ON CONFLICT (experiment, actor) DO UPDATE \
2450 SET variant = CASE WHEN EXCLUDED.is_override THEN EXCLUDED.variant ELSE autumn_experiment_assignments.variant END, \
2451 is_override = CASE WHEN EXCLUDED.is_override THEN EXCLUDED.is_override ELSE autumn_experiment_assignments.is_override END \
2452 RETURNING variant",
2453 )
2454 .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2455 .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2456 .bind::<diesel::sql_types::Text, _>(&assignment.variant)
2457 .bind::<diesel::sql_types::Bool, _>(assignment.is_override)
2458 .get_result::<VariantNameRow>(conn)
2459 .map(|r| r.variant)?;
2460
2461 Ok(variant_name)
2462 });
2463
2464 match result {
2465 Ok(v) => Ok(v),
2466 Err(TxError::Database(e)) => Err(ExperimentStoreError::Backend(e.to_string())),
2467 Err(TxError::Excluded(group)) => Err(ExperimentStoreError::Backend(format!(
2468 "ExcludedByGroup:{group}"
2469 ))),
2470 }
2471 }
2472
2473 fn get_override(
2474 &self,
2475 experiment: &str,
2476 actor: &str,
2477 ) -> Result<Option<String>, ExperimentStoreError> {
2478 let mut conn = self.connect()?;
2479 diesel::sql_query(
2480 "SELECT variant FROM autumn_experiment_overrides \
2481 WHERE experiment = $1 AND actor = $2",
2482 )
2483 .bind::<diesel::sql_types::Text, _>(experiment)
2484 .bind::<diesel::sql_types::Text, _>(actor)
2485 .get_result::<VariantNameRow>(&mut conn)
2486 .optional()
2487 .map(|r| r.map(|row| row.variant))
2488 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2489 }
2490
2491 fn set_override(
2492 &self,
2493 experiment: &str,
2494 actor: &str,
2495 variant: &str,
2496 ) -> Result<(), ExperimentStoreError> {
2497 let mut conn = self.connect()?;
2498 diesel::sql_query(
2499 "WITH upserted AS ( \
2500 INSERT INTO autumn_experiment_overrides (experiment, actor, variant) \
2501 VALUES ($1, $2, $3) \
2502 ON CONFLICT (experiment, actor) DO UPDATE SET variant = EXCLUDED.variant \
2503 RETURNING experiment, actor, variant \
2504 ) \
2505 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2506 SELECT experiment, 'override=' || actor || ':' || variant, NULL FROM upserted",
2507 )
2508 .bind::<diesel::sql_types::Text, _>(experiment)
2509 .bind::<diesel::sql_types::Text, _>(actor)
2510 .bind::<diesel::sql_types::Text, _>(variant)
2511 .execute(&mut conn)
2512 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2513 Ok(())
2514 }
2515
2516 fn has_assignment_in_group(
2517 &self,
2518 actor: &str,
2519 group: &str,
2520 exclude_experiment: &str,
2521 ) -> Result<bool, ExperimentStoreError> {
2522 let mut conn = self.connect()?;
2523 diesel::sql_query(
2524 "SELECT EXISTS ( \
2525 SELECT 1 \
2526 FROM autumn_experiment_assignments a \
2527 JOIN autumn_experiments e ON e.name = a.experiment \
2528 WHERE a.actor = $1 \
2529 AND e.exclusion_group = $2 \
2530 AND a.experiment <> $3 \
2531 ) AS result",
2532 )
2533 .bind::<diesel::sql_types::Text, _>(actor)
2534 .bind::<diesel::sql_types::Text, _>(group)
2535 .bind::<diesel::sql_types::Text, _>(exclude_experiment)
2536 .get_result::<BoolRow>(&mut conn)
2537 .map(|r| r.result)
2538 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2539 }
2540
2541 fn history(
2542 &self,
2543 experiment: &str,
2544 limit: usize,
2545 ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
2546 let limit = i64::try_from(limit).unwrap_or(i64::MAX);
2547 let mut conn = self.connect()?;
2548 diesel::sql_query(
2549 "SELECT experiment, mutation, actor, \
2550 EXTRACT(EPOCH FROM changed_at)::bigint AS timestamp_secs \
2551 FROM autumn_experiment_changes \
2552 WHERE experiment = $1 \
2553 ORDER BY changed_at DESC \
2554 LIMIT NULLIF($2::bigint, 0)",
2555 )
2556 .bind::<diesel::sql_types::Text, _>(experiment)
2557 .bind::<diesel::sql_types::BigInt, _>(limit)
2558 .load::<ChangeRow>(&mut conn)
2559 .map(|rows| {
2560 rows.into_iter()
2561 .map(|r| ChangeRecord {
2562 experiment: r.experiment,
2563 mutation: r.mutation,
2564 actor: r.actor,
2565 timestamp_secs: u64::try_from(r.timestamp_secs).unwrap_or(0),
2566 })
2567 .collect()
2568 })
2569 .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2570 }
2571 }
2572
2573 #[cfg(test)]
2574 mod pg_tests {
2575 use super::*;
2576
2577 const DEAD_URL: &str = "postgres://autumn@127.0.0.1:1/autumn?connect_timeout=2";
2579
2580 fn running(name: &str) -> ExperimentConfig {
2581 let mut config = ExperimentConfig::new(name, vec![VariantConfig::new("control", 100)]);
2582 config.state = ExperimentState::Running;
2583 config.updated_at_secs = 0;
2585 config
2586 }
2587
2588 fn current_thread_runtime() -> tokio::runtime::Runtime {
2589 tokio::runtime::Builder::new_current_thread()
2590 .enable_all()
2591 .build()
2592 .unwrap()
2593 }
2594
2595 async fn settle(store: &PgExperimentStore) {
2597 for _ in 0..1000 {
2598 if !store.refreshing_for_test() {
2599 return;
2600 }
2601 tokio::time::sleep(Duration::from_millis(10)).await;
2602 }
2603 panic!("background refresh did not finish");
2604 }
2605
2606 #[test]
2607 fn get_serves_last_known_value_when_refresh_fails() {
2608 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2609 store.install_for_test(vec![running("exp")]);
2610
2611 assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2613 assert_eq!(store.get("absent").unwrap(), None);
2614 assert_eq!(store.refresh_errors(), 2);
2615 }
2616
2617 #[test]
2618 fn get_within_ttl_does_not_connect() {
2619 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2620 store.install_for_test(vec![running("exp")]);
2621 assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2622 assert!(store.connect_threads_for_test().is_empty());
2623 }
2624
2625 #[test]
2626 fn get_on_runtime_never_connects_on_the_worker() {
2627 let store =
2628 std::sync::Arc::new(PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO));
2629 store.install_for_test(vec![running("exp")]);
2630
2631 current_thread_runtime().block_on(async {
2632 let worker = std::thread::current().id();
2633 assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2634 settle(&store).await;
2635
2636 let threads = store.connect_threads_for_test();
2637 assert!(!threads.is_empty(), "the stale snapshot is refreshed");
2638 assert!(!threads.contains(&worker), "no connect on the Tokio worker");
2639 });
2640 assert!(store.refresh_errors() >= 1);
2641 }
2642
2643 #[test]
2644 fn cold_get_on_runtime_errors_and_loads_in_background() {
2645 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2646 current_thread_runtime().block_on(async {
2647 let worker = std::thread::current().id();
2648 assert!(store.get("exp").is_err(), "no snapshot yet");
2649 settle(&store).await;
2650 let threads = store.connect_threads_for_test();
2651 assert!(!threads.is_empty());
2652 assert!(!threads.contains(&worker));
2653 });
2654 }
2655
2656 #[test]
2657 fn only_one_refresh_runs_at_a_time() {
2658 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2659 let slot = store.begin_refresh_for_test();
2660 assert!(slot.is_some());
2661 assert!(store.begin_refresh_for_test().is_none());
2662 drop(slot);
2663 assert!(store.begin_refresh_for_test().is_some());
2664 }
2665
2666 #[test]
2667 fn failed_refresh_after_write_marks_the_snapshot_stale() {
2668 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2669 store.install_for_test(vec![running("exp")]);
2670 assert!(!store.shared.is_stale());
2671 store.refresh_after_write();
2672 assert!(store.shared.is_stale());
2673 }
2674
2675 #[test]
2676 fn cold_get_without_runtime_reports_the_refresh_error() {
2677 let store = PgExperimentStore::new(
2678 "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2679 );
2680 let error = store.get("exp").unwrap_err().to_string();
2681 assert!(!error.contains("not loaded"), "{error}");
2682 assert!(!error.contains("hunter2"), "{error}");
2683 }
2684
2685 #[test]
2686 fn cold_get_while_a_refresh_runs_does_not_connect() {
2687 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2688 let _slot = store.begin_refresh_for_test();
2689 let error = store.get("exp").unwrap_err().to_string();
2690 assert!(error.contains("not loaded"), "{error}");
2691 assert!(store.connect_threads_for_test().is_empty());
2692 }
2693
2694 #[test]
2695 fn refresh_reports_backend_error() {
2696 let store = PgExperimentStore::new(DEAD_URL);
2697 assert!(store.refresh().is_err());
2698 assert_eq!(store.refresh_errors(), 1);
2699 assert!(store.preload().is_err());
2700 }
2701
2702 #[test]
2703 fn refresh_error_hides_the_password() {
2704 let store = PgExperimentStore::new(
2705 "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2706 );
2707 let error = store.refresh().unwrap_err().to_string();
2708 assert!(!error.contains("hunter2"), "{error}");
2709 }
2710
2711 #[test]
2712 fn debug_output_hides_the_password() {
2713 let store = PgExperimentStore::new(
2714 "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2715 );
2716 let shown = format!("{store:?}");
2717 assert!(shown.contains("PgExperimentStore"), "{shown}");
2718 assert!(!shown.contains("hunter2"), "{shown}");
2719 }
2720
2721 #[test]
2722 fn clone_keeps_the_ttl_and_starts_with_an_empty_snapshot() {
2723 let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2724 store.install_for_test(vec![running("exp")]);
2725 let copy = store.clone();
2726 assert_eq!(copy.shared.cache_ttl, Duration::from_secs(60));
2727 assert!(!copy.shared.is_loaded());
2728 assert!(store.shared.is_loaded(), "the original keeps its snapshot");
2729 }
2730
2731 #[test]
2732 fn poll_listener_refreshes_until_the_store_drops() {
2733 let store = std::sync::Arc::new(PgExperimentStore::new(DEAD_URL));
2734 let handle = PgExperimentStore::spawn_poll_listener(
2735 std::sync::Arc::clone(&store),
2736 Duration::from_millis(10),
2737 );
2738 for _ in 0..1000 {
2739 if store.refresh_errors() >= 1 {
2740 break;
2741 }
2742 std::thread::sleep(Duration::from_millis(10));
2743 }
2744 assert!(store.refresh_errors() >= 1, "the listener refreshed");
2745 drop(store);
2746 for _ in 0..3000 {
2747 if handle.is_finished() {
2748 return;
2749 }
2750 std::thread::sleep(Duration::from_millis(10));
2751 }
2752 panic!("poll listener outlived its store");
2753 }
2754
2755 #[test]
2756 fn poll_listener_stops_when_the_store_drops() {
2757 let store = std::sync::Arc::new(PgExperimentStore::new(DEAD_URL));
2758 let handle = PgExperimentStore::spawn_poll_listener(
2759 std::sync::Arc::clone(&store),
2760 Duration::from_millis(10),
2761 );
2762 drop(store);
2763 for _ in 0..1000 {
2764 if handle.is_finished() {
2765 return;
2766 }
2767 std::thread::sleep(Duration::from_millis(10));
2768 }
2769 panic!("poll listener outlived its store");
2770 }
2771 }
2772}
2773
2774#[cfg(test)]
2779#[derive(Default)]
2780pub(crate) struct ProbeStore {
2781 inner: InMemoryExperimentStore,
2782 pub(crate) preloads: std::sync::atomic::AtomicUsize,
2783 pub(crate) threads: Mutex<Vec<std::thread::ThreadId>>,
2784 panic_on_assignment: std::sync::atomic::AtomicBool,
2786}
2787
2788#[cfg(test)]
2789impl ProbeStore {
2790 fn seen(&self) {
2791 self.threads
2792 .lock()
2793 .unwrap()
2794 .push(std::thread::current().id());
2795 }
2796}
2797
2798#[cfg(test)]
2799impl ExperimentStore for ProbeStore {
2800 fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
2801 self.inner.get(name)
2802 }
2803 fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
2804 self.inner.list()
2805 }
2806 fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
2807 self.inner.upsert(config)
2808 }
2809 fn set_state(
2810 &self,
2811 name: &str,
2812 state: ExperimentState,
2813 winner: Option<&str>,
2814 ) -> Result<(), ExperimentStoreError> {
2815 self.inner.set_state(name, state, winner)
2816 }
2817 fn set_variants(
2818 &self,
2819 name: &str,
2820 variants: Vec<VariantConfig>,
2821 actor: Option<&str>,
2822 ) -> Result<(), ExperimentStoreError> {
2823 self.inner.set_variants(name, variants, actor)
2824 }
2825 fn get_assignment(
2826 &self,
2827 experiment: &str,
2828 actor: &str,
2829 ) -> Result<Option<Assignment>, ExperimentStoreError> {
2830 self.seen();
2831 assert!(
2832 !self
2833 .panic_on_assignment
2834 .load(std::sync::atomic::Ordering::SeqCst),
2835 "store call panicked"
2836 );
2837 self.inner.get_assignment(experiment, actor)
2838 }
2839 fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
2840 self.seen();
2841 self.inner.record_assignment(assignment)
2842 }
2843 fn get_override(
2844 &self,
2845 experiment: &str,
2846 actor: &str,
2847 ) -> Result<Option<String>, ExperimentStoreError> {
2848 self.seen();
2849 self.inner.get_override(experiment, actor)
2850 }
2851 fn set_override(
2852 &self,
2853 experiment: &str,
2854 actor: &str,
2855 variant: &str,
2856 ) -> Result<(), ExperimentStoreError> {
2857 self.inner.set_override(experiment, actor, variant)
2858 }
2859 fn has_assignment_in_group(
2860 &self,
2861 actor: &str,
2862 group: &str,
2863 exclude_experiment: &str,
2864 ) -> Result<bool, ExperimentStoreError> {
2865 self.seen();
2866 self.inner
2867 .has_assignment_in_group(actor, group, exclude_experiment)
2868 }
2869 fn history(
2870 &self,
2871 experiment: &str,
2872 limit: usize,
2873 ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
2874 self.inner.history(experiment, limit)
2875 }
2876 fn preload(&self) -> Result<(), ExperimentStoreError> {
2877 self.preloads
2878 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2879 Ok(())
2880 }
2881}
2882
2883#[cfg(test)]
2886mod tests {
2887 use super::*;
2888
2889 fn make_svc() -> ExperimentService {
2892 ExperimentService::new(Arc::new(InMemoryExperimentStore::new()))
2893 }
2894
2895 fn running_config(name: &str) -> ExperimentConfig {
2896 let mut config = ExperimentConfig::new(
2897 name,
2898 vec![
2899 VariantConfig::new("control", 50),
2900 VariantConfig::new("b", 50),
2901 ],
2902 );
2903 config.state = ExperimentState::Running;
2904 config
2905 }
2906
2907 #[tokio::test(flavor = "current_thread")]
2908 async fn assign_async_runs_store_calls_off_the_worker() {
2909 let store = Arc::new(ProbeStore::default());
2910 store.upsert(running_config("exp")).unwrap();
2911 let svc = ExperimentService::new(store.clone());
2912 let worker = std::thread::current().id();
2913
2914 let first = svc.assign_async("exp", "user:1", None).await.unwrap();
2915 let again = svc.assign_async("exp", "user:1", None).await.unwrap();
2916
2917 assert_eq!(first, again, "assignment stays sticky");
2918 let threads = store.threads.lock().unwrap().clone();
2919 assert!(!threads.is_empty(), "the store was called");
2920 assert!(!threads.contains(&worker), "no store call on the worker");
2921 }
2922
2923 #[tokio::test(flavor = "current_thread")]
2924 async fn experiments_assign_async_uses_actor_and_runs_off_the_worker() {
2925 let store = Arc::new(ProbeStore::default());
2926 store.upsert(running_config("exp")).unwrap();
2927 let exps = Experiments {
2928 service: ExperimentService::new(store.clone()),
2929 actor_id: Some("user:7".to_owned()),
2930 request_id: None,
2931 };
2932 let worker = std::thread::current().id();
2933
2934 let variant = exps.assign_async("exp").await.unwrap();
2935
2936 let threads = store.threads.lock().unwrap().clone();
2937 assert!(!threads.contains(&worker));
2938 let sticky = store.get_assignment("exp", "user:7").unwrap().unwrap();
2939 assert_eq!(sticky.variant, variant);
2940 }
2941
2942 #[tokio::test]
2943 async fn assign_async_maps_a_store_panic_to_a_store_error() {
2944 let store = Arc::new(ProbeStore::default());
2945 store.upsert(running_config("exp")).unwrap();
2946 store
2947 .panic_on_assignment
2948 .store(true, std::sync::atomic::Ordering::SeqCst);
2949 let svc = ExperimentService::new(store);
2950 let result = svc.assign_async("exp", "user:1", None).await;
2951 assert!(
2952 matches!(
2953 &result,
2954 Err(ExperimentError::Store(ExperimentStoreError::Backend(m)))
2955 if m.contains("assignment task failed")
2956 ),
2957 "{result:?}"
2958 );
2959 }
2960
2961 #[test]
2962 fn preload_with_returns_after_the_wait_when_the_load_is_slow() {
2963 let started = std::time::Instant::now();
2964 crate::feature_flags::preload_with(
2965 "test",
2966 "none",
2967 std::time::Duration::from_millis(10),
2968 || {
2969 std::thread::sleep(std::time::Duration::from_millis(300));
2970 Ok::<(), String>(())
2971 },
2972 );
2973 assert!(started.elapsed() < std::time::Duration::from_millis(250));
2974 }
2975
2976 #[test]
2977 fn preload_with_returns_when_the_load_panics_or_fails() {
2978 let wait = std::time::Duration::from_secs(5);
2979 crate::feature_flags::preload_with("test", "none", wait, || -> Result<(), String> {
2980 panic!("load panicked")
2981 });
2982 crate::feature_flags::preload_with("test", "none", wait, || Err("failed".to_owned()));
2983 }
2984
2985 #[test]
2986 fn probe_store_delegates_to_the_in_memory_store() {
2987 let store = ProbeStore::default();
2988 store.upsert(running_config("exp")).unwrap();
2989 store
2990 .set_state("exp", ExperimentState::Concluded, Some("control"))
2991 .unwrap();
2992 store
2993 .set_variants("exp", vec![VariantConfig::new("control", 100)], None)
2994 .unwrap();
2995 store.set_override("exp", "user:1", "control").unwrap();
2996 assert_eq!(store.list().unwrap().len(), 1);
2997 assert_eq!(
2998 store.get("exp").unwrap().unwrap().winner.as_deref(),
2999 Some("control")
3000 );
3001 assert!(!store.history("exp", 10).unwrap().is_empty());
3002 }
3003
3004 #[test]
3005 fn store_preload_defaults_to_ok() {
3006 assert!(InMemoryExperimentStore::new().preload().is_ok());
3007 let probe = Arc::new(ProbeStore::default());
3008 probe.preload().unwrap();
3009 assert_eq!(probe.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
3010 }
3011
3012 fn make_svc_with_sink() -> (ExperimentService, Arc<Mutex<Vec<ExposureRecord>>>) {
3013 let (sink, records) = RecordingExposureSink::new();
3014 let svc = ExperimentService::new(Arc::new(InMemoryExperimentStore::new()))
3015 .with_exposure_sink(Arc::new(sink));
3016 (svc, records)
3017 }
3018
3019 fn fifty_fifty(name: &str) -> ExperimentConfig {
3020 ExperimentConfig::new(
3021 name,
3022 vec![
3023 VariantConfig::new("control", 50),
3024 VariantConfig::new("treatment", 50),
3025 ],
3026 )
3027 }
3028
3029 fn running(svc: &ExperimentService, name: &str) {
3030 svc.create(fifty_fifty(name)).unwrap();
3031 svc.start(name).unwrap();
3032 }
3033
3034 #[test]
3042 fn assign_unknown_experiment_returns_not_found() {
3043 let svc = make_svc();
3044 let err = svc.assign("ghost", "user:1").unwrap_err();
3045 assert!(
3046 matches!(err, ExperimentError::NotFound(_)),
3047 "expected NotFound, got {err}"
3048 );
3049 }
3050
3051 #[test]
3054 fn assign_draft_experiment_returns_not_running() {
3055 let svc = make_svc();
3056 svc.create(fifty_fifty("exp")).unwrap();
3057 let err = svc.assign("exp", "user:1").unwrap_err();
3058 assert!(
3059 matches!(err, ExperimentError::NotRunning(_, ExperimentState::Draft)),
3060 "expected NotRunning(Draft), got {err}"
3061 );
3062 }
3063
3064 #[test]
3067 fn assign_archived_experiment_returns_archived() {
3068 let svc = make_svc();
3069 running(&svc, "exp");
3070 svc.archive("exp").unwrap();
3071 let err = svc.assign("exp", "user:1").unwrap_err();
3072 assert!(
3073 matches!(err, ExperimentError::Archived(_)),
3074 "expected Archived, got {err}"
3075 );
3076 }
3077
3078 #[test]
3081 fn concluded_experiment_returns_winner_for_all_actors() {
3082 let svc = make_svc();
3083 running(&svc, "exp");
3084 svc.conclude("exp", "treatment").unwrap();
3085 for i in 0..100_u32 {
3087 let actor = format!("user:{i}");
3088 let v = svc.assign("exp", &actor).unwrap();
3089 assert_eq!(
3090 v, "treatment",
3091 "concluded experiment must return winner for {actor}"
3092 );
3093 }
3094 }
3095
3096 #[test]
3099 fn concluded_experiment_emits_no_exposures() {
3100 let (svc, records) = make_svc_with_sink();
3101 let store = Arc::new(InMemoryExperimentStore::new());
3102 let (sink2, records2) = RecordingExposureSink::new();
3103 let svc2 = ExperimentService::new(store as Arc<dyn ExperimentStore>)
3104 .with_exposure_sink(Arc::new(sink2));
3105 svc2.create(fifty_fifty("exp")).unwrap();
3106 svc2.start("exp").unwrap();
3107 svc2.conclude("exp", "treatment").unwrap();
3108 svc2.assign("exp", "user:1").unwrap();
3109 assert_eq!(
3110 records2.lock().unwrap().len(),
3111 0,
3112 "concluded experiment must not emit exposure events"
3113 );
3114 let _ = records; let _ = svc;
3116 }
3117
3118 #[test]
3121 fn assign_running_experiment_returns_valid_variant() {
3122 let svc = make_svc();
3123 running(&svc, "exp");
3124 let v = svc.assign("exp", "user:1").unwrap();
3125 assert!(
3126 v == "control" || v == "treatment",
3127 "variant must be one of the declared names, got {v:?}"
3128 );
3129 }
3130
3131 #[test]
3134 fn assign_is_deterministic_for_same_actor() {
3135 let svc = make_svc();
3136 running(&svc, "exp");
3137 let v1 = svc.assign("exp", "user:42").unwrap();
3138 let v2 = svc.assign("exp", "exp").unwrap(); let svc2 = make_svc();
3141 running(&svc2, "exp");
3142 let v3 = svc2.assign("exp", "user:42").unwrap();
3143 assert_eq!(
3144 v1, v3,
3145 "same actor must receive the same variant across different service instances"
3146 );
3147 let _ = v2;
3148 }
3149
3150 #[test]
3153 fn zero_reassignments_across_10000_requests() {
3154 let svc = make_svc();
3155 running(&svc, "exp");
3156 let first = svc.assign("exp", "stable_user").unwrap();
3157 for _ in 1..10_000 {
3158 let v = svc.assign("exp", "stable_user").unwrap();
3159 assert_eq!(v, first, "re-assignment must return the same variant");
3160 }
3161 }
3162
3163 #[test]
3166 fn stable_hash_regression_known_fixtures() {
3167 let b1 = experiment_bucket("checkout_v2", "user:1");
3171 let b2 = experiment_bucket("checkout_v2", "user:1");
3172 assert_eq!(
3173 b1, b2,
3174 "hash must be deterministic (same input → same output)"
3175 );
3176
3177 assert_eq!(
3179 b1, 4_830,
3180 "checkout_v2:user:1 bucket changed — hash regression"
3181 );
3182 assert_eq!(
3183 experiment_bucket("checkout_v2", "user:2"),
3184 6_619,
3185 "checkout_v2:user:2 bucket changed — hash regression"
3186 );
3187 assert_eq!(
3188 experiment_bucket("onboarding_v3", "user:100"),
3189 6_602,
3190 "onboarding_v3:user:100 bucket changed — hash regression"
3191 );
3192 }
3193
3194 fn joined_fnv1a_64(data: &[u8]) -> u64 {
3197 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
3198 const FNV_PRIME: u64 = 1_099_511_628_211;
3199 let mut hash = FNV_OFFSET;
3200 for &byte in data {
3201 #[allow(clippy::cast_lossless)]
3202 {
3203 hash ^= byte as u64;
3204 }
3205 hash = hash.wrapping_mul(FNV_PRIME);
3206 }
3207 hash
3208 }
3209
3210 fn joined_experiment_bucket(experiment: &str, actor_id: &str) -> u64 {
3211 let key = format!("{experiment}:{actor_id}");
3212 joined_fnv1a_64(key.as_bytes()) % 10_000
3213 }
3214
3215 #[test]
3216 fn experiment_bucket_is_fnv1a_of_the_joined_key() {
3217 let edge_cases = [
3220 ("checkout_v2", "user:1"),
3221 ("", ""),
3222 ("e", ""),
3223 ("", "a"),
3224 (":", ":"),
3225 ("a:b", "c:d"),
3226 ("ab", "c:d"),
3227 ("a:b:", ":c"),
3228 ("ünï", "çødé"),
3229 ("exp🧪", "用户:42"),
3230 ("tab\texp", "line\nactor"),
3231 ];
3232 for (experiment, actor) in edge_cases {
3233 assert_eq!(
3234 experiment_bucket(experiment, actor),
3235 joined_experiment_bucket(experiment, actor),
3236 "{experiment:?}/{actor:?}"
3237 );
3238 }
3239 for experiment in ["checkout_v2", "onboarding_v3", "exp", "a:b"] {
3240 for i in 0..1_000_u32 {
3241 let actor = format!("user:{i}");
3242 assert_eq!(
3243 experiment_bucket(experiment, &actor),
3244 joined_experiment_bucket(experiment, &actor),
3245 "{experiment:?}/{actor:?}"
3246 );
3247 }
3248 }
3249 }
3250
3251 #[test]
3252 fn fnv1a_64_of_parts_equals_fnv1a_64_of_their_concatenation() {
3253 for key in ["", ":", "checkout_v2:user:1", "ünï:çødé", "a::b"] {
3256 let whole = joined_fnv1a_64(key.as_bytes());
3257 let bytes = key.as_bytes();
3258 for i in 0..=bytes.len() {
3259 for j in i..=bytes.len() {
3260 let (head, rest) = bytes.split_at(i);
3261 let (middle, tail) = rest.split_at(j - i);
3262 assert_eq!(fnv1a_64(&[head, middle, tail]), whole, "{key:?} {i} {j}");
3263 }
3264 }
3265 assert_eq!(fnv1a_64(&[bytes]), whole, "{key:?} in one part");
3266 }
3267 assert_eq!(fnv1a_64(&[]), joined_fnv1a_64(&[]), "no parts at all");
3268 }
3269
3270 #[test]
3273 fn pair_key_hashes_and_compares_like_the_owned_key() {
3274 use std::hash::BuildHasher as _;
3277 let state = std::collections::hash_map::RandomState::new();
3278 for (experiment, actor) in [("exp", "user:1"), ("", ""), ("ex", "puser:1"), ("ünï", "ø")]
3279 {
3280 let owned = PairKeyOwned(experiment.to_owned(), actor.to_owned());
3281 let owned_view: &dyn PairKey = &owned;
3282 let borrowed: &dyn PairKey = &(experiment, actor);
3283 assert_eq!(
3284 state.hash_one(&owned),
3285 state.hash_one(borrowed),
3286 "{owned:?}"
3287 );
3288 assert_eq!(
3289 state.hash_one(owned_view),
3290 state.hash_one(borrowed),
3291 "{owned:?}"
3292 );
3293 assert!(owned_view == borrowed, "{owned:?}");
3294 }
3295 let split_elsewhere: &dyn PairKey = &("ex", "puser:1");
3297 let owned = PairKeyOwned("exp".to_owned(), "user:1".to_owned());
3298 let owned_view: &dyn PairKey = &owned;
3299 assert!(owned_view != split_elsewhere);
3300 }
3301
3302 #[test]
3303 fn in_memory_store_probes_pair_keys_by_borrow() {
3304 let store = InMemoryExperimentStore::new();
3305 store.set_override("exp", "qa:1", "treatment").unwrap();
3306 store
3307 .record_assignment(Assignment::new("exp", "user:1", "control", false))
3308 .unwrap();
3309 assert_eq!(
3310 store.get_override("exp", "qa:1").unwrap().as_deref(),
3311 Some("treatment")
3312 );
3313 assert_eq!(store.get_override("exp", "qa:2").unwrap(), None);
3314 assert_eq!(store.get_override("ex", "pqa:1").unwrap(), None);
3316 assert_eq!(store.get_override("exp:", "qa:1").unwrap(), None);
3317 assert_eq!(
3318 store
3319 .get_assignment("exp", "user:1")
3320 .unwrap()
3321 .unwrap()
3322 .variant,
3323 "control"
3324 );
3325 assert!(store.get_assignment("exp:user", "1").unwrap().is_none());
3326 assert!(store.get_assignment("exp", "user:2").unwrap().is_none());
3327 }
3328
3329 #[test]
3330 fn exclusion_group_probe_finds_a_sibling_assignment_by_borrow() {
3331 let store = InMemoryExperimentStore::new();
3334 for name in ["exp_a", "exp_b"] {
3335 store
3336 .upsert(
3337 ExperimentConfig::new(name, vec![VariantConfig::new("v1", 1)])
3338 .exclusion_group("group"),
3339 )
3340 .unwrap();
3341 }
3342 store
3343 .record_assignment(Assignment::new("exp_a", "user:1", "v1", false))
3344 .unwrap();
3345 assert!(
3346 store
3347 .has_assignment_in_group("user:1", "group", "exp_b")
3348 .unwrap()
3349 );
3350 assert!(
3351 !store
3352 .has_assignment_in_group("user:2", "group", "exp_b")
3353 .unwrap()
3354 );
3355 assert!(
3356 !store
3357 .has_assignment_in_group("user:1", "group", "exp_a")
3358 .unwrap(),
3359 "the excluded experiment itself does not count"
3360 );
3361 let err = store
3362 .record_assignment(Assignment::new("exp_b", "user:1", "v1", false))
3363 .unwrap_err();
3364 assert!(err.to_string().contains("ExcludedByGroup:group"), "{err}");
3365 store
3366 .record_assignment(Assignment::new("exp_b", "user:2", "v1", false))
3367 .unwrap();
3368 }
3369
3370 #[test]
3373 fn zero_weight_variant_never_assigned() {
3374 let svc = make_svc();
3375 svc.create(ExperimentConfig::new(
3376 "exp",
3377 vec![
3378 VariantConfig::new("control", 100),
3379 VariantConfig::new("dead", 0),
3380 ],
3381 ))
3382 .unwrap();
3383 svc.start("exp").unwrap();
3384 for i in 0..200_u32 {
3385 let v = svc.assign("exp", &format!("user:{i}")).unwrap();
3386 assert_eq!(v, "control", "zero-weight variant must never be assigned");
3387 }
3388 }
3389
3390 #[test]
3393 fn single_variant_always_assigned() {
3394 let svc = make_svc();
3395 svc.create(ExperimentConfig::new(
3396 "exp",
3397 vec![VariantConfig::new("only", 100)],
3398 ))
3399 .unwrap();
3400 svc.start("exp").unwrap();
3401 for i in 0..100_u32 {
3402 let v = svc.assign("exp", &format!("user:{i}")).unwrap();
3403 assert_eq!(v, "only");
3404 }
3405 }
3406
3407 #[test]
3410 fn fifty_fifty_weights_split_roughly_evenly() {
3411 let svc = make_svc();
3412 running(&svc, "exp");
3413 let mut control_count = 0_u32;
3414 for i in 0..1000_u32 {
3415 if svc.assign("exp", &format!("user:{i}")).unwrap() == "control" {
3416 control_count += 1;
3417 }
3418 }
3419 assert!(
3420 (400..=600).contains(&control_count),
3421 "expected ~500 control assignments, got {control_count}"
3422 );
3423 }
3424
3425 #[test]
3428 fn all_zero_weights_returns_no_variant_error() {
3429 let svc = make_svc();
3430 svc.create(ExperimentConfig::new(
3431 "exp",
3432 vec![VariantConfig::new("a", 0), VariantConfig::new("b", 0)],
3433 ))
3434 .unwrap();
3435 svc.start("exp").unwrap();
3436 let err = svc.assign("exp", "user:1").unwrap_err();
3437 assert!(
3438 matches!(err, ExperimentError::NoVariant(_)),
3439 "expected NoVariant, got {err}"
3440 );
3441 }
3442
3443 #[test]
3446 fn sticky_assignment_returned_on_subsequent_calls() {
3447 let svc = make_svc();
3448 running(&svc, "exp");
3449 let first = svc.assign("exp", "user:1").unwrap();
3450 for _ in 0..10 {
3452 assert_eq!(
3453 svc.assign("exp", "user:1").unwrap(),
3454 first,
3455 "sticky assignment must be returned on all subsequent calls"
3456 );
3457 }
3458 }
3459
3460 #[test]
3463 fn set_weights_does_not_rebucket_existing_assignments() {
3464 let svc = make_svc();
3465 running(&svc, "exp");
3466 let original = svc.assign("exp", "user:1").unwrap();
3467
3468 let (new_heavy, new_light) = if original == "control" {
3470 (0_u32, 100_u32)
3471 } else {
3472 (100_u32, 0_u32)
3473 };
3474 svc.set_weights(
3475 "exp",
3476 vec![
3477 VariantConfig::new("control", new_heavy),
3478 VariantConfig::new("treatment", new_light),
3479 ],
3480 None,
3481 )
3482 .unwrap();
3483
3484 let after = svc.assign("exp", "user:1").unwrap();
3485 assert_eq!(
3486 original, after,
3487 "existing sticky assignment must not be re-bucketed after weight change"
3488 );
3489 }
3490
3491 #[test]
3492 fn set_weights_rejects_deleting_assigned_variant() {
3493 let svc = make_svc();
3494 running(&svc, "exp");
3495 let original = svc.assign("exp", "user:1").unwrap();
3496
3497 let remaining_variant = if original == "control" {
3498 "treatment"
3499 } else {
3500 "control"
3501 };
3502 let err = svc
3503 .set_weights(
3504 "exp",
3505 vec![VariantConfig::new(remaining_variant, 100)],
3506 None,
3507 )
3508 .unwrap_err();
3509 assert!(
3510 err.to_string().contains("cannot delete variant"),
3511 "expected active assignment delete guard error, got {err}"
3512 );
3513 }
3514
3515 #[test]
3518 fn exposure_emitted_exactly_once_per_assign_call() {
3519 let (svc, records) = make_svc_with_sink();
3520 running(&svc, "exp");
3521 svc.assign("exp", "user:1").unwrap();
3522 assert_eq!(
3523 records.lock().unwrap().len(),
3524 1,
3525 "first assign → 1 exposure"
3526 );
3527 svc.assign("exp", "user:1").unwrap();
3528 assert_eq!(
3529 records.lock().unwrap().len(),
3530 2,
3531 "second assign → 2 total exposures"
3532 );
3533 svc.assign("exp", "user:2").unwrap();
3534 assert_eq!(
3535 records.lock().unwrap().len(),
3536 3,
3537 "different actor → 3 total exposures"
3538 );
3539 }
3540
3541 #[test]
3544 fn exposure_record_contains_correct_fields() {
3545 let (svc, records) = make_svc_with_sink();
3546 running(&svc, "checkout_v2");
3547 let variant = svc
3548 .assign_with_request_id("checkout_v2", "user:42", Some("req-abc"))
3549 .unwrap();
3550 let (len, exp_name, exp_variant, exp_actor, exp_req_id, exp_is_override) = {
3551 let rec = records.lock().unwrap();
3552 let r = &rec[0];
3553 (
3554 rec.len(),
3555 r.experiment.clone(),
3556 r.variant.clone(),
3557 r.actor.clone(),
3558 r.request_id.clone(),
3559 r.is_override,
3560 )
3561 };
3562 assert_eq!(len, 1);
3563 assert_eq!(exp_name, "checkout_v2");
3564 assert_eq!(exp_variant, variant);
3565 assert_eq!(exp_actor, "user:42");
3566 assert_eq!(exp_req_id.as_deref(), Some("req-abc"));
3567 assert!(!exp_is_override);
3568 }
3569
3570 #[test]
3573 fn override_bypasses_weights() {
3574 let svc = make_svc();
3575 svc.create(ExperimentConfig::new(
3577 "exp",
3578 vec![
3579 VariantConfig::new("control", 100),
3580 VariantConfig::new("treatment", 0),
3581 ],
3582 ))
3583 .unwrap();
3584 svc.start("exp").unwrap();
3585 svc.set_override("exp", "qa:alice", "treatment").unwrap();
3586 let v = svc.assign("exp", "qa:alice").unwrap();
3587 assert_eq!(
3588 v, "treatment",
3589 "override must bypass weight-based bucketing"
3590 );
3591 }
3592
3593 #[test]
3596 fn override_emits_exposure_tagged_as_override() {
3597 let (svc, records) = make_svc_with_sink();
3598 running(&svc, "exp");
3599 svc.set_override("exp", "qa:alice", "treatment").unwrap();
3600 svc.assign("exp", "qa:alice").unwrap();
3601 let (len, is_override, exp_variant) = {
3602 let rec = records.lock().unwrap();
3603 (rec.len(), rec[0].is_override, rec[0].variant.clone())
3604 };
3605 assert_eq!(len, 1);
3606 assert!(
3607 is_override,
3608 "exposure from override must be tagged is_override = true"
3609 );
3610 assert_eq!(exp_variant, "treatment");
3611 }
3612
3613 #[test]
3616 fn mutual_exclusion_prevents_sibling_assignment() {
3617 let svc = make_svc();
3618 svc.create(
3620 ExperimentConfig::new("exp_a", vec![VariantConfig::new("v1", 1)])
3621 .exclusion_group("checkout"),
3622 )
3623 .unwrap();
3624 svc.start("exp_a").unwrap();
3625 svc.create(
3626 ExperimentConfig::new("exp_b", vec![VariantConfig::new("v1", 1)])
3627 .exclusion_group("checkout"),
3628 )
3629 .unwrap();
3630 svc.start("exp_b").unwrap();
3631
3632 svc.assign("exp_a", "user:1").unwrap();
3634
3635 let err = svc.assign("exp_b", "user:1").unwrap_err();
3637 assert!(
3638 matches!(err, ExperimentError::ExcludedByGroup(_, _)),
3639 "expected ExcludedByGroup, got {err}"
3640 );
3641 }
3642
3643 #[test]
3646 fn different_groups_do_not_exclude_each_other() {
3647 let svc = make_svc();
3648 svc.create(
3649 ExperimentConfig::new("exp_a", vec![VariantConfig::new("v1", 1)])
3650 .exclusion_group("group_a"),
3651 )
3652 .unwrap();
3653 svc.start("exp_a").unwrap();
3654 svc.create(
3655 ExperimentConfig::new("exp_b", vec![VariantConfig::new("v1", 1)])
3656 .exclusion_group("group_b"),
3657 )
3658 .unwrap();
3659 svc.start("exp_b").unwrap();
3660
3661 svc.assign("exp_a", "user:1").unwrap();
3662 let result = svc.assign("exp_b", "user:1");
3663 assert!(
3664 result.is_ok(),
3665 "experiments in different groups must not exclude each other"
3666 );
3667 }
3668
3669 #[test]
3672 fn no_exclusion_group_allows_both_assignments() {
3673 let svc = make_svc();
3674 running(&svc, "exp_a");
3675 running(&svc, "exp_b");
3676 svc.assign("exp_a", "user:1").unwrap();
3677 let result = svc.assign("exp_b", "user:1");
3678 assert!(
3679 result.is_ok(),
3680 "experiments without exclusion groups must not exclude each other"
3681 );
3682 }
3683
3684 #[test]
3687 fn experiment_bucket_is_stable_and_in_range() {
3688 for i in 0..100_u32 {
3689 let actor = format!("user:{i}");
3690 let b1 = experiment_bucket("my_exp", &actor);
3691 let b2 = experiment_bucket("my_exp", &actor);
3692 assert_eq!(b1, b2, "bucket must be deterministic for {actor}");
3693 assert!(b1 < 10_000, "bucket must be in [0, 10000) for {actor}");
3694 }
3695 }
3696
3697 #[test]
3700 fn experiment_bucket_produces_diverse_values() {
3701 let buckets: std::collections::HashSet<u64> = (0..100_u32)
3702 .map(|i| experiment_bucket("exp", &format!("user:{i}")))
3703 .collect();
3704 assert!(
3705 buckets.len() > 50,
3706 "expected diverse buckets across 100 actors, got {}",
3707 buckets.len()
3708 );
3709 }
3710
3711 #[test]
3714 fn list_returns_all_experiments() {
3715 let svc = make_svc();
3716 svc.create(fifty_fifty("alpha")).unwrap();
3717 svc.create(fifty_fifty("beta")).unwrap();
3718 svc.create(fifty_fifty("gamma")).unwrap();
3719 let experiments = svc.list().unwrap();
3720 assert_eq!(experiments.len(), 3);
3721 assert_eq!(experiments[0].name, "alpha");
3722 assert_eq!(experiments[1].name, "beta");
3723 assert_eq!(experiments[2].name, "gamma");
3724 }
3725
3726 #[test]
3729 fn status_returns_current_config() {
3730 let svc = make_svc();
3731 running(&svc, "exp");
3732 let cfg = svc.status("exp").unwrap();
3733 assert_eq!(cfg.state, ExperimentState::Running);
3734 assert_eq!(cfg.variants.len(), 2);
3735 }
3736
3737 #[test]
3740 fn history_records_create_and_start_mutations() {
3741 let svc = make_svc();
3742 svc.create(fifty_fifty("exp")).unwrap();
3743 svc.start("exp").unwrap();
3744 let hist = svc.history("exp", 10).unwrap();
3745 assert!(!hist.is_empty(), "history must record mutations");
3746 }
3747
3748 #[test]
3751 fn set_weights_recorded_in_history() {
3752 let svc = make_svc();
3753 running(&svc, "exp");
3754 svc.set_weights(
3755 "exp",
3756 vec![
3757 VariantConfig::new("control", 30),
3758 VariantConfig::new("treatment", 70),
3759 ],
3760 Some("ops@example.com"),
3761 )
3762 .unwrap();
3763 let hist = svc.history("exp", 10).unwrap();
3764 let has_set_weights = hist.iter().any(|r| r.mutation == "set_weights");
3765 assert!(has_set_weights, "set_weights must be recorded in history");
3766 }
3767
3768 #[test]
3771 fn select_variant_returns_none_for_empty_variants() {
3772 assert_eq!(select_variant(&[], 0), None);
3773 }
3774
3775 #[test]
3776 fn select_variant_returns_none_for_all_zero_weights() {
3777 let vs = vec![VariantConfig::new("a", 0), VariantConfig::new("b", 0)];
3778 assert_eq!(select_variant(&vs, 5_000), None);
3779 }
3780
3781 #[test]
3782 fn select_variant_50_50_boundary() {
3783 let vs = vec![
3784 VariantConfig::new("control", 50),
3785 VariantConfig::new("treatment", 50),
3786 ];
3787 assert_eq!(select_variant(&vs, 0), Some("control"));
3789 assert_eq!(select_variant(&vs, 4_999), Some("control"));
3791 assert_eq!(select_variant(&vs, 5_000), Some("treatment"));
3794 assert_eq!(select_variant(&vs, 9_999), Some("treatment"));
3796 }
3797
3798 #[test]
3801 fn arc_experiment_store_delegates_all_operations() {
3802 let store = Arc::new(InMemoryExperimentStore::new());
3803 let arc_store: Arc<dyn ExperimentStore> = Arc::clone(&store) as _;
3804
3805 let cfg = fifty_fifty("my_exp");
3806 arc_store.upsert(cfg).unwrap();
3807 assert!(arc_store.get("my_exp").unwrap().is_some());
3808
3809 arc_store
3810 .set_state("my_exp", ExperimentState::Running, None)
3811 .unwrap();
3812 assert_eq!(
3813 arc_store.get("my_exp").unwrap().unwrap().state,
3814 ExperimentState::Running
3815 );
3816
3817 arc_store
3818 .record_assignment(Assignment::new("my_exp", "user:1", "control", false))
3819 .unwrap();
3820 let asgn = arc_store.get_assignment("my_exp", "user:1").unwrap();
3821 assert_eq!(asgn.unwrap().variant, "control");
3822
3823 arc_store
3824 .set_override("my_exp", "qa:1", "treatment")
3825 .unwrap();
3826 assert_eq!(
3827 arc_store.get_override("my_exp", "qa:1").unwrap().unwrap(),
3828 "treatment"
3829 );
3830 }
3831
3832 #[test]
3835 fn experiment_state_display_matches_expected() {
3836 assert_eq!(ExperimentState::Draft.to_string(), "draft");
3837 assert_eq!(ExperimentState::Running.to_string(), "running");
3838 assert_eq!(ExperimentState::Concluded.to_string(), "concluded");
3839 assert_eq!(ExperimentState::Archived.to_string(), "archived");
3840 }
3841
3842 #[test]
3845 fn experiment_error_display() {
3846 assert!(
3847 ExperimentError::NotFound("x".to_owned())
3848 .to_string()
3849 .contains("not found")
3850 );
3851 assert!(
3852 ExperimentError::Archived("x".to_owned())
3853 .to_string()
3854 .contains("archived")
3855 );
3856 assert!(
3857 ExperimentError::ExcludedByGroup("x".to_owned(), "g".to_owned())
3858 .to_string()
3859 .contains("mutual exclusion")
3860 );
3861 assert!(
3862 ExperimentError::NoVariant("x".to_owned())
3863 .to_string()
3864 .contains("weights are zero")
3865 );
3866 }
3867
3868 #[test]
3871 fn service_debug_does_not_panic() {
3872 let svc = make_svc();
3873 let _ = format!("{svc:?}");
3874 }
3875
3876 #[test]
3877 fn upsert_logs_created_or_updated() {
3878 let svc = make_svc();
3879 let exp = fifty_fifty("exp");
3880 svc.create(exp.clone()).unwrap();
3881
3882 svc.create(exp).unwrap();
3884
3885 let hist = svc.history("exp", 10).unwrap();
3886 assert_eq!(hist.len(), 2);
3887 assert_eq!(hist[1].mutation, "created");
3888 assert_eq!(hist[0].mutation, "updated");
3889 }
3890
3891 #[test]
3892 fn upsert_rejects_deleting_variant_with_active_assignments() {
3893 let store = InMemoryExperimentStore::new();
3894 let name = "test_exp";
3895
3896 let config = ExperimentConfig {
3897 name: name.to_string(),
3898 description: None,
3899 state: ExperimentState::Running,
3900 variants: vec![
3901 VariantConfig {
3902 name: "control".to_string(),
3903 weight: 50,
3904 },
3905 VariantConfig {
3906 name: "treatment".to_string(),
3907 weight: 50,
3908 },
3909 ],
3910 winner: None,
3911 exclusion_group: None,
3912 updated_at_secs: 0,
3913 };
3914 store.upsert(config.clone()).unwrap();
3915
3916 store
3918 .record_assignment(Assignment {
3919 experiment: name.to_string(),
3920 actor: "user1".to_string(),
3921 variant: "treatment".to_string(),
3922 is_override: false,
3923 assigned_at_secs: 0,
3924 })
3925 .unwrap();
3926
3927 let mut new_config = config;
3929 new_config.variants = vec![VariantConfig {
3930 name: "control".to_string(),
3931 weight: 100,
3932 }];
3933
3934 let res = store.upsert(new_config);
3935 assert!(
3936 res.is_err(),
3937 "expected upsert to fail due to deleting active variant"
3938 );
3939 if let Err(ExperimentStoreError::Backend(msg)) = res {
3940 assert!(
3941 msg.contains("treatment"),
3942 "expected error message to mention 'treatment', got: {msg}"
3943 );
3944 } else {
3945 panic!("expected Backend error");
3946 }
3947 }
3948
3949 #[cfg(feature = "db")]
3957 #[test]
3958 fn pg_experiment_store_refuses_a_non_postgres_target() {
3959 use crate::config::DatabaseConfig;
3960
3961 let sqlite = DatabaseConfig {
3962 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
3963 ..Default::default()
3964 };
3965 assert!(
3966 pg::PgExperimentStore::from_database_config(&sqlite).is_none(),
3967 "a SQLite target has no Postgres experiment store"
3968 );
3969
3970 let unclassifiable = DatabaseConfig {
3972 primary_url: Some("/var/lib/app.db".to_owned()),
3973 ..Default::default()
3974 };
3975 assert!(
3976 pg::PgExperimentStore::from_database_config(&unclassifiable).is_none(),
3977 "an unclassifiable target has no Postgres experiment store"
3978 );
3979
3980 for url in [
3982 "postgres://localhost/app",
3983 "host=db user=app dbname=app sslmode=require",
3984 ] {
3985 let pg_config = DatabaseConfig {
3986 primary_url: Some(url.to_owned()),
3987 ..Default::default()
3988 };
3989 assert!(
3990 pg::PgExperimentStore::from_database_config(&pg_config).is_some(),
3991 "{url} is a Postgres target"
3992 );
3993 }
3994 }
3995
3996 #[cfg(feature = "db")]
3999 #[test]
4000 fn pg_experiment_store_new_refuses_a_non_postgres_target_at_first_use() {
4001 use crate::experiments::ExperimentStore as _;
4002
4003 let store = pg::PgExperimentStore::new("sqlite://app:hunter2@host/app.db");
4004 let err = store
4005 .get("exp")
4006 .expect_err("a SQLite target is refused")
4007 .to_string();
4008 assert!(
4009 err.contains("Postgres"),
4010 "the refusal names the cause: {err}"
4011 );
4012 assert!(!err.contains("hunter2"), "the refusal leaks: {err}");
4013
4014 let store = pg::PgExperimentStore::new("postgres://app:hunter2@[::1/db");
4016 let err = store
4017 .get("exp")
4018 .expect_err("a malformed target fails")
4019 .to_string();
4020 assert!(!err.contains("hunter2"), "the driver error leaks: {err}");
4021
4022 let store = pg::PgExperimentStore::new("postgres://app:p%ss@localhost:1/db");
4024 let err = store
4025 .get("exp")
4026 .expect_err("a bad escape fails")
4027 .to_string();
4028 assert!(!err.contains("p%ss"), "the driver error leaks: {err}");
4029 }
4030}