use crate::{
defaults::Defaults,
error::{NimbusError, Result},
evaluator::evaluate_enrollment,
json, AvailableRandomizationUnits, Experiment, FeatureConfig, NimbusTargetingHelper,
SLUG_REPLACEMENT_PATTERN,
};
use serde_derive::*;
use std::{
collections::{HashMap, HashSet},
fmt::{Display, Formatter, Result as FmtResult},
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub(crate) const PREVIOUS_ENROLLMENTS_GC_TIME: Duration = Duration::from_secs(365 * 24 * 3600);
#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
pub enum EnrolledReason {
Qualified,
OptIn,
}
impl Display for EnrolledReason {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
Display::fmt(
match self {
EnrolledReason::Qualified => "Qualified",
EnrolledReason::OptIn => "OptIn",
},
f,
)
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
pub enum NotEnrolledReason {
OptOut,
NotSelected,
NotTargeted,
EnrollmentsPaused,
FeatureConflict,
}
impl Display for NotEnrolledReason {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
Display::fmt(
match self {
NotEnrolledReason::OptOut => "OptOut",
NotEnrolledReason::NotSelected => "NotSelected",
NotEnrolledReason::NotTargeted => "NotTargeted",
NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
NotEnrolledReason::FeatureConflict => "FeatureConflict",
},
f,
)
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
pub enum DisqualifiedReason {
Error,
OptOut,
NotTargeted,
NotSelected,
}
impl Display for DisqualifiedReason {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
Display::fmt(
match self {
DisqualifiedReason::Error => "Error",
DisqualifiedReason::OptOut => "OptOut",
DisqualifiedReason::NotSelected => "NotSelected",
DisqualifiedReason::NotTargeted => "NotTargeted",
},
f,
)
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
pub struct ExperimentEnrollment {
pub slug: String,
pub status: EnrollmentStatus,
}
impl ExperimentEnrollment {
fn from_new_experiment(
is_user_participating: bool,
available_randomization_units: &AvailableRandomizationUnits,
experiment: &Experiment,
targeting_helper: &NimbusTargetingHelper,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Result<Self> {
Ok(if !is_user_participating {
Self {
slug: experiment.slug.clone(),
status: EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::OptOut,
},
}
} else if experiment.is_enrollment_paused {
Self {
slug: experiment.slug.clone(),
status: EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::EnrollmentsPaused,
},
}
} else {
let enrollment =
evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
log::debug!(
"Experiment '{}' is new - enrollment status is {:?}",
&enrollment.slug,
&enrollment
);
if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
out_enrollment_events.push(enrollment.get_change_event())
}
enrollment
})
}
#[cfg_attr(not(feature = "stateful"), allow(unused))]
pub(crate) fn from_explicit_opt_in(
experiment: &Experiment,
branch_slug: &str,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Result<Self> {
if !experiment.has_branch(branch_slug) {
out_enrollment_events.push(EnrollmentChangeEvent {
experiment_slug: experiment.slug.to_string(),
branch_slug: branch_slug.to_string(),
reason: Some("does-not-exist".to_string()),
change: EnrollmentChangeEventType::EnrollFailed,
});
return Err(NimbusError::NoSuchBranch(
branch_slug.to_owned(),
experiment.slug.clone(),
));
}
let enrollment = Self {
slug: experiment.slug.clone(),
status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
};
out_enrollment_events.push(enrollment.get_change_event());
Ok(enrollment)
}
#[allow(clippy::too_many_arguments)]
fn on_experiment_updated(
&self,
is_user_participating: bool,
available_randomization_units: &AvailableRandomizationUnits,
updated_experiment: &Experiment,
targeting_helper: &NimbusTargetingHelper,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Result<Self> {
Ok(match &self.status {
EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
if !is_user_participating || updated_experiment.is_enrollment_paused {
self.clone()
} else {
let updated_enrollment = evaluate_enrollment(
available_randomization_units,
updated_experiment,
targeting_helper,
)?;
log::debug!(
"Experiment '{}' with enrollment {:?} is now {:?}",
&self.slug,
&self,
updated_enrollment
);
if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
out_enrollment_events.push(updated_enrollment.get_change_event());
}
updated_enrollment
}
}
EnrollmentStatus::Enrolled {
ref branch,
ref reason,
..
} => {
if !is_user_participating {
log::debug!(
"Existing experiment enrollment '{}' is now disqualified (global opt-out)",
&self.slug
);
let updated_enrollment =
self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
out_enrollment_events.push(updated_enrollment.get_change_event());
updated_enrollment
} else if !updated_experiment.has_branch(branch) {
let updated_enrollment =
self.disqualify_from_enrolled(DisqualifiedReason::Error);
out_enrollment_events.push(updated_enrollment.get_change_event());
updated_enrollment
} else if matches!(reason, EnrolledReason::OptIn) {
self.clone()
} else {
let evaluated_enrollment = evaluate_enrollment(
available_randomization_units,
updated_experiment,
targeting_helper,
)?;
match evaluated_enrollment.status {
EnrollmentStatus::Error { .. } => {
let updated_enrollment =
self.disqualify_from_enrolled(DisqualifiedReason::Error);
out_enrollment_events.push(updated_enrollment.get_change_event());
updated_enrollment
}
EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotTargeted,
} => {
log::debug!("Existing experiment enrollment '{}' is now disqualified (targeting change)", &self.slug);
let updated_enrollment =
self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
out_enrollment_events.push(updated_enrollment.get_change_event());
updated_enrollment
}
EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotSelected,
} => {
let updated_enrollment =
self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
out_enrollment_events.push(updated_enrollment.get_change_event());
updated_enrollment
}
EnrollmentStatus::NotEnrolled { .. }
| EnrollmentStatus::Enrolled { .. }
| EnrollmentStatus::Disqualified { .. }
| EnrollmentStatus::WasEnrolled { .. } => self.clone(),
}
}
}
EnrollmentStatus::Disqualified {
ref branch, reason, ..
} => {
if !is_user_participating {
log::debug!(
"Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
&self.slug
);
Self {
slug: self.slug.clone(),
status: EnrollmentStatus::Disqualified {
reason: DisqualifiedReason::OptOut,
branch: branch.clone(),
},
}
} else if updated_experiment.is_rollout
&& matches!(
reason,
DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
)
{
let evaluated_enrollment = evaluate_enrollment(
available_randomization_units,
updated_experiment,
targeting_helper,
)?;
match evaluated_enrollment.status {
EnrollmentStatus::Enrolled { .. } => evaluated_enrollment,
_ => self.clone(),
}
} else {
self.clone()
}
}
EnrollmentStatus::WasEnrolled { .. } => self.clone(),
})
}
fn on_experiment_ended(
&self,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Option<Self> {
log::debug!(
"Experiment '{}' vanished while we had enrollment status of {:?}",
self.slug,
self
);
let branch = match self.status {
EnrollmentStatus::Enrolled { ref branch, .. }
| EnrollmentStatus::Disqualified { ref branch, .. } => branch,
EnrollmentStatus::NotEnrolled { .. }
| EnrollmentStatus::WasEnrolled { .. }
| EnrollmentStatus::Error { .. } => return None, };
let enrollment = Self {
slug: self.slug.clone(),
status: EnrollmentStatus::WasEnrolled {
branch: branch.to_owned(),
experiment_ended_at: now_secs(),
},
};
out_enrollment_events.push(enrollment.get_change_event());
Some(enrollment)
}
#[allow(clippy::unnecessary_wraps)]
#[cfg_attr(not(feature = "stateful"), allow(unused))]
pub(crate) fn on_explicit_opt_out(
&self,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> ExperimentEnrollment {
match self.status {
EnrollmentStatus::Enrolled { .. } => {
let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
out_enrollment_events.push(enrollment.get_change_event());
enrollment
}
EnrollmentStatus::NotEnrolled { .. } => Self {
slug: self.slug.to_string(),
status: EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::OptOut, },
},
EnrollmentStatus::Disqualified { .. }
| EnrollmentStatus::WasEnrolled { .. }
| EnrollmentStatus::Error { .. } => {
self.clone()
}
}
}
#[cfg_attr(not(feature = "stateful"), allow(unused))]
pub fn reset_telemetry_identifiers(
&self,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Self {
let updated = match self.status {
EnrollmentStatus::Enrolled { .. } => {
let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
out_enrollment_events.push(disqualified.get_change_event());
disqualified
}
EnrollmentStatus::NotEnrolled { .. }
| EnrollmentStatus::Disqualified { .. }
| EnrollmentStatus::WasEnrolled { .. }
| EnrollmentStatus::Error { .. } => self.clone(),
};
ExperimentEnrollment {
status: updated.status.clone(),
..updated
}
}
fn maybe_garbage_collect(&self) -> Option<Self> {
if let EnrollmentStatus::WasEnrolled {
experiment_ended_at,
..
} = self.status
{
let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
return Some(self.clone());
}
}
log::debug!("Garbage collecting enrollment '{}'", self.slug);
None
}
fn get_change_event(&self) -> EnrollmentChangeEvent {
match &self.status {
EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
&self.slug,
branch,
None,
EnrollmentChangeEventType::Enrollment,
),
EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
&self.slug,
branch,
None,
EnrollmentChangeEventType::Unenrollment,
),
EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
&self.slug,
branch,
match reason {
DisqualifiedReason::NotSelected => Some("bucketing"),
DisqualifiedReason::NotTargeted => Some("targeting"),
DisqualifiedReason::OptOut => Some("optout"),
DisqualifiedReason::Error => Some("error"),
},
EnrollmentChangeEventType::Disqualification,
),
EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
unreachable!()
}
}
}
fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
match self.status {
EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
status: EnrollmentStatus::Disqualified {
reason,
branch: branch.to_owned(),
},
..self.clone()
},
EnrollmentStatus::NotEnrolled { .. }
| EnrollmentStatus::Disqualified { .. }
| EnrollmentStatus::WasEnrolled { .. }
| EnrollmentStatus::Error { .. } => self.clone(),
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
pub enum EnrollmentStatus {
Enrolled {
reason: EnrolledReason,
branch: String,
},
NotEnrolled {
reason: NotEnrolledReason,
},
Disqualified {
reason: DisqualifiedReason,
branch: String,
},
WasEnrolled {
branch: String,
experiment_ended_at: u64, },
Error {
reason: String,
},
}
impl EnrollmentStatus {
pub fn name(&self) -> String {
match self {
EnrollmentStatus::Enrolled { .. } => "Enrolled",
EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
EnrollmentStatus::Disqualified { .. } => "Disqualified",
EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
EnrollmentStatus::Error { .. } => "Error",
}
.into()
}
}
impl EnrollmentStatus {
pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
EnrollmentStatus::Enrolled {
reason,
branch: branch.to_owned(),
}
}
pub fn is_enrolled(&self) -> bool {
matches!(self, EnrollmentStatus::Enrolled { .. })
}
}
pub(crate) trait ExperimentMetadata {
fn get_slug(&self) -> String;
fn is_rollout(&self) -> bool;
}
pub(crate) struct EnrollmentsEvolver<'a> {
available_randomization_units: &'a AvailableRandomizationUnits,
targeting_helper: &'a mut NimbusTargetingHelper,
coenrolling_feature_ids: &'a HashSet<&'a str>,
}
impl<'a> EnrollmentsEvolver<'a> {
pub(crate) fn new(
available_randomization_units: &'a AvailableRandomizationUnits,
targeting_helper: &'a mut NimbusTargetingHelper,
coenrolling_feature_ids: &'a HashSet<&str>,
) -> Self {
Self {
available_randomization_units,
targeting_helper,
coenrolling_feature_ids,
}
}
pub(crate) fn evolve_enrollments<E>(
&mut self,
is_user_participating: bool,
prev_experiments: &[E],
next_experiments: &[Experiment],
prev_enrollments: &[ExperimentEnrollment],
) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
where
E: ExperimentMetadata + Clone,
{
let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
let mut events: Vec<EnrollmentChangeEvent> = Default::default();
let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
prev_experiments,
prev_enrollments,
ExperimentMetadata::is_rollout,
);
let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
is_user_participating,
&prev_rollouts,
&next_rollouts,
&ro_enrollments,
)?;
enrollments.extend(next_ro_enrollments);
events.extend(ro_events);
let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
.iter()
.filter(|e| !ro_slugs.contains(&e.slug))
.map(|e| e.to_owned())
.collect();
let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
is_user_participating,
&prev_experiments,
&next_experiments,
&prev_enrollments,
)?;
enrollments.extend(next_exp_enrollments);
events.extend(exp_events);
Ok((enrollments, events))
}
pub(crate) fn evolve_enrollment_recipes<E>(
&mut self,
is_user_participating: bool,
prev_experiments: &[E],
next_experiments: &[Experiment],
prev_enrollments: &[ExperimentEnrollment],
) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
where
E: ExperimentMetadata + Clone,
{
let mut enrollment_events = vec![];
let prev_experiments_map = map_experiments(prev_experiments);
let next_experiments_map = map_experiments(next_experiments);
let prev_enrollments_map = map_enrollments(prev_enrollments);
let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
let mut next_enrollments = Vec::with_capacity(next_experiments.len());
for prev_enrollment in prev_enrollments {
if matches!(
prev_enrollment.status,
EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::FeatureConflict
}
) {
continue;
}
let slug = &prev_enrollment.slug;
let next_enrollment = match self.evolve_enrollment(
is_user_participating,
prev_experiments_map.get(slug).copied(),
next_experiments_map.get(slug).copied(),
Some(prev_enrollment),
&mut enrollment_events,
) {
Ok(enrollment) => enrollment,
Err(e) => {
log::warn!("{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
None
}
};
#[cfg(feature = "stateful")]
if let Some(ref enrollment) = next_enrollment.clone() {
if self.targeting_helper.update_enrollment(enrollment) {
log::debug!("Enrollment updated for {}", enrollment.slug);
} else {
log::debug!("Enrollment unchanged for {}", enrollment.slug);
}
}
self.reserve_enrolled_features(
next_enrollment,
&next_experiments_map,
&mut enrolled_features,
&mut coenrolling_features,
&mut next_enrollments,
);
}
let next_experiments = sort_experiments_by_published_date(next_experiments);
for next_experiment in next_experiments {
let slug = &next_experiment.slug;
let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
.get_feature_ids()
.iter()
.filter_map(|id| enrolled_features.get(id))
.collect();
if !needed_features_in_use.is_empty() {
let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
if is_our_experiment {
assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
} else {
next_enrollments.push(ExperimentEnrollment {
slug: slug.clone(),
status: EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::FeatureConflict,
},
});
enrollment_events.push(EnrollmentChangeEvent {
experiment_slug: slug.clone(),
branch_slug: "N/A".to_string(),
reason: Some("feature-conflict".to_string()),
change: EnrollmentChangeEventType::EnrollFailed,
})
}
continue;
}
let prev_enrollment = prev_enrollments_map.get(slug).copied();
if prev_enrollment.is_none()
|| matches!(
prev_enrollment.unwrap().status,
EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::FeatureConflict
}
)
{
let next_enrollment = match self.evolve_enrollment(
is_user_participating,
prev_experiments_map.get(slug).copied(),
Some(next_experiment),
prev_enrollment,
&mut enrollment_events,
) {
Ok(enrollment) => enrollment,
Err(e) => {
log::warn!("{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
None
}
};
#[cfg(feature = "stateful")]
if let Some(ref enrollment) = next_enrollment.clone() {
if self.targeting_helper.update_enrollment(enrollment) {
log::debug!("Enrollment updated for {}", enrollment.slug);
} else {
log::debug!("Enrollment unchanged for {}", enrollment.slug);
}
}
self.reserve_enrolled_features(
next_enrollment,
&next_experiments_map,
&mut enrolled_features,
&mut coenrolling_features,
&mut next_enrollments,
);
}
}
enrolled_features.extend(coenrolling_features);
let updated_enrolled_features = map_features(
&next_enrollments,
&next_experiments_map,
self.coenrolling_feature_ids,
);
if enrolled_features != updated_enrolled_features {
Err(NimbusError::InternalError(
"Next enrollment calculation error",
))
} else {
Ok((next_enrollments, enrollment_events))
}
}
fn reserve_enrolled_features(
&self,
latest_enrollment: Option<ExperimentEnrollment>,
experiments: &HashMap<String, &Experiment>,
enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
enrollments: &mut Vec<ExperimentEnrollment>,
) {
if let Some(enrollment) = latest_enrollment {
for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
populate_feature_maps(
enrolled_feature,
self.coenrolling_feature_ids,
enrolled_features,
coenrolling_features,
);
}
enrollments.push(enrollment);
}
}
pub(crate) fn evolve_enrollment<E>(
&mut self,
is_user_participating: bool,
prev_experiment: Option<&E>,
next_experiment: Option<&Experiment>,
prev_enrollment: Option<&ExperimentEnrollment>,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, ) -> Result<Option<ExperimentEnrollment>>
where
E: ExperimentMetadata + Clone,
{
let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
enrollment.status.is_enrolled()
} else {
false
};
let targeting_helper = self
.targeting_helper
.put("is_already_enrolled", is_already_enrolled);
Ok(match (prev_experiment, next_experiment, prev_enrollment) {
(None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
is_user_participating,
self.available_randomization_units,
experiment,
&targeting_helper,
out_enrollment_events,
)?),
(Some(_), None, Some(enrollment)) => {
enrollment.on_experiment_ended(out_enrollment_events)
}
(Some(_), Some(experiment), Some(enrollment)) => {
Some(enrollment.on_experiment_updated(
is_user_participating,
self.available_randomization_units,
experiment,
&targeting_helper,
out_enrollment_events,
)?)
}
(None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
(None, Some(_), Some(_)) => {
return Err(NimbusError::InternalError(
"New experiment but enrollment already exists.",
))
}
(Some(_), None, None) | (Some(_), Some(_), None) => {
return Err(NimbusError::InternalError(
"Experiment in the db did not have an associated enrollment record.",
))
}
(None, None, None) => {
return Err(NimbusError::InternalError(
"evolve_experiment called with nothing that could evolve or be evolved",
))
}
})
}
}
fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
where
E: ExperimentMetadata + Clone,
{
let mut map_experiments = HashMap::with_capacity(experiments.len());
for e in experiments {
map_experiments.insert(e.get_slug(), e);
}
map_experiments
}
pub fn map_enrollments(
enrollments: &[ExperimentEnrollment],
) -> HashMap<String, &ExperimentEnrollment> {
let mut map_enrollments = HashMap::with_capacity(enrollments.len());
for e in enrollments {
map_enrollments.insert(e.slug.clone(), e);
}
map_enrollments
}
pub(crate) fn filter_experiments_and_enrollments<E>(
experiments: &[E],
enrollments: &[ExperimentEnrollment],
filter_fn: fn(&E) -> bool,
) -> (Vec<E>, Vec<ExperimentEnrollment>)
where
E: ExperimentMetadata + Clone,
{
let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
let enrollments: Vec<ExperimentEnrollment> = enrollments
.iter()
.filter(|e| slugs.contains(&e.slug))
.map(|e| e.to_owned())
.collect();
(experiments, enrollments)
}
fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
where
E: ExperimentMetadata + Clone,
{
experiments
.iter()
.filter(|e| filter_fn(e))
.cloned()
.collect()
}
pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
let mut experiments: Vec<_> = experiments.iter().collect();
experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
experiments
}
fn map_features(
enrollments: &[ExperimentEnrollment],
experiments: &HashMap<String, &Experiment>,
coenrolling_ids: &HashSet<&str>,
) -> HashMap<String, EnrolledFeatureConfig> {
let mut colliding_features = HashMap::with_capacity(enrollments.len());
let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
for enrolled_feature_config in enrollments
.iter()
.flat_map(|e| get_enrolled_feature_configs(e, experiments))
{
populate_feature_maps(
enrolled_feature_config,
coenrolling_ids,
&mut colliding_features,
&mut coenrolling_features,
);
}
colliding_features.extend(coenrolling_features.drain());
colliding_features
}
pub fn map_features_by_feature_id(
enrollments: &[ExperimentEnrollment],
experiments: &[Experiment],
coenrolling_ids: &HashSet<&str>,
) -> HashMap<String, EnrolledFeatureConfig> {
let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
experiments,
enrollments,
ExperimentMetadata::is_rollout,
);
let (experiments, exp_enrollments) =
filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
let features_under_rollout = map_features(
&ro_enrollments,
&map_experiments(&rollouts),
coenrolling_ids,
);
let features_under_experiment = map_features(
&exp_enrollments,
&map_experiments(&experiments),
coenrolling_ids,
);
features_under_experiment
.defaults(&features_under_rollout)
.unwrap()
}
pub(crate) fn populate_feature_maps(
enrolled_feature: EnrolledFeatureConfig,
coenrolling_feature_ids: &HashSet<&str>,
colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
) {
let feature_id = &enrolled_feature.feature_id;
if !coenrolling_feature_ids.contains(feature_id.as_str()) {
colliding_features.insert(feature_id.clone(), enrolled_feature);
} else if let Some(existing) = coenrolling_features.get(feature_id) {
let merged = enrolled_feature
.defaults(existing)
.expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
let merged = EnrolledFeatureConfig {
slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
branch: None,
..merged
};
coenrolling_features.insert(feature_id.clone(), merged);
} else {
coenrolling_features.insert(feature_id.clone(), enrolled_feature);
}
}
fn get_enrolled_feature_configs(
enrollment: &ExperimentEnrollment,
experiments: &HashMap<String, &Experiment>,
) -> Vec<EnrolledFeatureConfig> {
let branch_slug = match &enrollment.status {
EnrollmentStatus::Enrolled { branch, .. } => branch,
_ => return Vec::new(),
};
let experiment_slug = &enrollment.slug;
let experiment = match experiments.get(experiment_slug).copied() {
Some(exp) => exp,
_ => return Vec::new(),
};
let mut branch_features = match &experiment.get_branch(branch_slug) {
Some(branch) => branch.get_feature_configs(),
_ => Default::default(),
};
branch_features.iter_mut().for_each(|f| {
json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
});
let branch_feature_ids = &branch_features
.iter()
.map(|f| &f.feature_id)
.collect::<HashSet<_>>();
let non_branch_features: Vec<FeatureConfig> = experiment
.get_feature_ids()
.into_iter()
.filter(|feature_id| !branch_feature_ids.contains(feature_id))
.map(|feature_id| FeatureConfig {
feature_id,
..Default::default()
})
.collect();
branch_features
.iter()
.chain(non_branch_features.iter())
.map(|f| EnrolledFeatureConfig {
feature: f.to_owned(),
slug: experiment_slug.clone(),
branch: if !experiment.is_rollout() {
Some(branch_slug.clone())
} else {
None
},
feature_id: f.feature_id.clone(),
})
.collect()
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EnrolledFeatureConfig {
pub feature: FeatureConfig,
pub slug: String,
pub branch: Option<String>,
pub feature_id: String,
}
impl Defaults for EnrolledFeatureConfig {
fn defaults(&self, fallback: &Self) -> Result<Self> {
if self.feature_id != fallback.feature_id {
Err(NimbusError::InternalError(
"Cannot merge enrolled feature configs from different features",
))
} else {
Ok(Self {
slug: self.slug.to_owned(),
feature_id: self.feature_id.to_owned(),
feature: self.feature.defaults(&fallback.feature)?,
branch: self.branch.to_owned(),
})
}
}
}
impl ExperimentMetadata for EnrolledFeatureConfig {
fn get_slug(&self) -> String {
self.slug.clone()
}
fn is_rollout(&self) -> bool {
self.branch.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnrolledFeature {
pub slug: String,
pub branch: Option<String>,
pub feature_id: String,
}
impl From<&EnrolledFeatureConfig> for EnrolledFeature {
fn from(value: &EnrolledFeatureConfig) -> Self {
Self {
slug: value.slug.clone(),
branch: value.branch.clone(),
feature_id: value.feature_id.clone(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EnrollmentChangeEvent {
pub experiment_slug: String,
pub branch_slug: String,
pub reason: Option<String>,
pub change: EnrollmentChangeEventType,
}
impl EnrollmentChangeEvent {
pub(crate) fn new(
slug: &str,
branch: &str,
reason: Option<&str>,
change: EnrollmentChangeEventType,
) -> Self {
Self {
experiment_slug: slug.to_owned(),
branch_slug: branch.to_owned(),
reason: reason.map(|s| s.to_owned()),
change,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum EnrollmentChangeEventType {
Enrollment,
EnrollFailed,
Disqualification,
Unenrollment,
#[cfg_attr(not(feature = "stateful"), allow(unused))]
UnenrollFailed,
}
pub(crate) fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Current date before Unix Epoch.")
.as_secs()
}