1use crate::{
5 defaults::Defaults,
6 error::{debug, warn, NimbusError, Result},
7 evaluator::evaluate_enrollment,
8 json, AvailableRandomizationUnits, Experiment, FeatureConfig, NimbusTargetingHelper,
9 SLUG_REPLACEMENT_PATTERN,
10};
11use serde_derive::*;
12use std::{
13 collections::{HashMap, HashSet},
14 fmt::{Display, Formatter, Result as FmtResult},
15 time::{Duration, SystemTime, UNIX_EPOCH},
16};
17
18pub(crate) const PREVIOUS_ENROLLMENTS_GC_TIME: Duration = Duration::from_secs(365 * 24 * 3600);
19
20#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
24pub enum EnrolledReason {
25 Qualified,
27 OptIn,
29}
30
31impl Display for EnrolledReason {
32 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
33 Display::fmt(
34 match self {
35 EnrolledReason::Qualified => "Qualified",
36 EnrolledReason::OptIn => "OptIn",
37 },
38 f,
39 )
40 }
41}
42
43#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
48pub enum NotEnrolledReason {
49 OptOut,
51 NotSelected,
53 NotTargeted,
55 EnrollmentsPaused,
57 FeatureConflict,
59}
60
61impl Display for NotEnrolledReason {
62 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
63 Display::fmt(
64 match self {
65 NotEnrolledReason::OptOut => "OptOut",
66 NotEnrolledReason::NotSelected => "NotSelected",
67 NotEnrolledReason::NotTargeted => "NotTargeted",
68 NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
69 NotEnrolledReason::FeatureConflict => "FeatureConflict",
70 },
71 f,
72 )
73 }
74}
75
76#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
81pub enum DisqualifiedReason {
82 Error,
84 OptOut,
86 NotTargeted,
88 NotSelected,
90}
91
92impl Display for DisqualifiedReason {
93 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
94 Display::fmt(
95 match self {
96 DisqualifiedReason::Error => "Error",
97 DisqualifiedReason::OptOut => "OptOut",
98 DisqualifiedReason::NotSelected => "NotSelected",
99 DisqualifiedReason::NotTargeted => "NotTargeted",
100 },
101 f,
102 )
103 }
104}
105
106#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
111pub struct ExperimentEnrollment {
112 pub slug: String,
113 pub status: EnrollmentStatus,
114}
115
116impl ExperimentEnrollment {
117 fn from_new_experiment(
120 is_user_participating: bool,
121 available_randomization_units: &AvailableRandomizationUnits,
122 experiment: &Experiment,
123 targeting_helper: &NimbusTargetingHelper,
124 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
125 ) -> Result<Self> {
126 Ok(if !is_user_participating {
127 Self {
128 slug: experiment.slug.clone(),
129 status: EnrollmentStatus::NotEnrolled {
130 reason: NotEnrolledReason::OptOut,
131 },
132 }
133 } else if experiment.is_enrollment_paused {
134 Self {
135 slug: experiment.slug.clone(),
136 status: EnrollmentStatus::NotEnrolled {
137 reason: NotEnrolledReason::EnrollmentsPaused,
138 },
139 }
140 } else {
141 let enrollment =
142 evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
143 debug!(
144 "Experiment '{}' is new - enrollment status is {:?}",
145 &enrollment.slug, &enrollment
146 );
147 if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
148 out_enrollment_events.push(enrollment.get_change_event())
149 }
150 enrollment
151 })
152 }
153
154 #[cfg_attr(not(feature = "stateful"), allow(unused))]
156 pub(crate) fn from_explicit_opt_in(
157 experiment: &Experiment,
158 branch_slug: &str,
159 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
160 ) -> Result<Self> {
161 if !experiment.has_branch(branch_slug) {
162 out_enrollment_events.push(EnrollmentChangeEvent {
163 experiment_slug: experiment.slug.to_string(),
164 branch_slug: branch_slug.to_string(),
165 reason: Some("does-not-exist".to_string()),
166 change: EnrollmentChangeEventType::EnrollFailed,
167 });
168
169 return Err(NimbusError::NoSuchBranch(
170 branch_slug.to_owned(),
171 experiment.slug.clone(),
172 ));
173 }
174 let enrollment = Self {
175 slug: experiment.slug.clone(),
176 status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
177 };
178 out_enrollment_events.push(enrollment.get_change_event());
179 Ok(enrollment)
180 }
181
182 #[allow(clippy::too_many_arguments)]
184 fn on_experiment_updated(
185 &self,
186 is_user_participating: bool,
187 available_randomization_units: &AvailableRandomizationUnits,
188 updated_experiment: &Experiment,
189 targeting_helper: &NimbusTargetingHelper,
190 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
191 ) -> Result<Self> {
192 Ok(match &self.status {
193 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
194 if !is_user_participating || updated_experiment.is_enrollment_paused {
195 self.clone()
196 } else {
197 let updated_enrollment = evaluate_enrollment(
198 available_randomization_units,
199 updated_experiment,
200 targeting_helper,
201 )?;
202 debug!(
203 "Experiment '{}' with enrollment {:?} is now {:?}",
204 &self.slug, &self, updated_enrollment
205 );
206 if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
207 out_enrollment_events.push(updated_enrollment.get_change_event());
208 }
209 updated_enrollment
210 }
211 }
212
213 EnrollmentStatus::Enrolled {
214 ref branch,
215 ref reason,
216 ..
217 } => {
218 if !is_user_participating {
219 debug!(
220 "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
221 &self.slug
222 );
223 let updated_enrollment =
224 self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
225 out_enrollment_events.push(updated_enrollment.get_change_event());
226 updated_enrollment
227 } else if !updated_experiment.has_branch(branch) {
228 let updated_enrollment =
230 self.disqualify_from_enrolled(DisqualifiedReason::Error);
231 out_enrollment_events.push(updated_enrollment.get_change_event());
232 updated_enrollment
233 } else if matches!(reason, EnrolledReason::OptIn) {
234 self.clone()
237 } else {
238 let evaluated_enrollment = evaluate_enrollment(
239 available_randomization_units,
240 updated_experiment,
241 targeting_helper,
242 )?;
243 match evaluated_enrollment.status {
244 EnrollmentStatus::Error { .. } => {
245 let updated_enrollment =
246 self.disqualify_from_enrolled(DisqualifiedReason::Error);
247 out_enrollment_events.push(updated_enrollment.get_change_event());
248 updated_enrollment
249 }
250 EnrollmentStatus::NotEnrolled {
251 reason: NotEnrolledReason::NotTargeted,
252 } => {
253 debug!("Existing experiment enrollment '{}' is now disqualified (targeting change)", &self.slug);
254 let updated_enrollment =
255 self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
256 out_enrollment_events.push(updated_enrollment.get_change_event());
257 updated_enrollment
258 }
259 EnrollmentStatus::NotEnrolled {
260 reason: NotEnrolledReason::NotSelected,
261 } => {
262 let updated_enrollment =
265 self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
266 out_enrollment_events.push(updated_enrollment.get_change_event());
267 updated_enrollment
268 }
269 EnrollmentStatus::NotEnrolled { .. }
270 | EnrollmentStatus::Enrolled { .. }
271 | EnrollmentStatus::Disqualified { .. }
272 | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
273 }
274 }
275 }
276 EnrollmentStatus::Disqualified {
277 ref branch, reason, ..
278 } => {
279 if !is_user_participating {
280 debug!(
281 "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
282 &self.slug
283 );
284 Self {
285 slug: self.slug.clone(),
286 status: EnrollmentStatus::Disqualified {
287 reason: DisqualifiedReason::OptOut,
288 branch: branch.clone(),
289 },
290 }
291 } else if updated_experiment.is_rollout
292 && matches!(
293 reason,
294 DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
295 )
296 {
297 let evaluated_enrollment = evaluate_enrollment(
298 available_randomization_units,
299 updated_experiment,
300 targeting_helper,
301 )?;
302 match evaluated_enrollment.status {
303 EnrollmentStatus::Enrolled { .. } => evaluated_enrollment,
304 _ => self.clone(),
305 }
306 } else {
307 self.clone()
308 }
309 }
310 EnrollmentStatus::WasEnrolled { .. } => self.clone(),
311 })
312 }
313
314 fn on_experiment_ended(
320 &self,
321 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
322 ) -> Option<Self> {
323 debug!(
324 "Experiment '{}' vanished while we had enrollment status of {:?}",
325 self.slug, self
326 );
327 let branch = match self.status {
328 EnrollmentStatus::Enrolled { ref branch, .. }
329 | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
330 EnrollmentStatus::NotEnrolled { .. }
331 | EnrollmentStatus::WasEnrolled { .. }
332 | EnrollmentStatus::Error { .. } => return None, };
334 let enrollment = Self {
335 slug: self.slug.clone(),
336 status: EnrollmentStatus::WasEnrolled {
337 branch: branch.to_owned(),
338 experiment_ended_at: now_secs(),
339 },
340 };
341 out_enrollment_events.push(enrollment.get_change_event());
342 Some(enrollment)
343 }
344
345 #[allow(clippy::unnecessary_wraps)]
347 #[cfg_attr(not(feature = "stateful"), allow(unused))]
348 pub(crate) fn on_explicit_opt_out(
349 &self,
350 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
351 ) -> ExperimentEnrollment {
352 match self.status {
353 EnrollmentStatus::Enrolled { .. } => {
354 let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
355 out_enrollment_events.push(enrollment.get_change_event());
356 enrollment
357 }
358 EnrollmentStatus::NotEnrolled { .. } => Self {
359 slug: self.slug.to_string(),
360 status: EnrollmentStatus::NotEnrolled {
361 reason: NotEnrolledReason::OptOut, },
363 },
364 EnrollmentStatus::Disqualified { .. }
365 | EnrollmentStatus::WasEnrolled { .. }
366 | EnrollmentStatus::Error { .. } => {
367 self.clone()
369 }
370 }
371 }
372
373 #[cfg_attr(not(feature = "stateful"), allow(unused))]
379 pub fn reset_telemetry_identifiers(
380 &self,
381 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
382 ) -> Self {
383 let updated = match self.status {
384 EnrollmentStatus::Enrolled { .. } => {
385 let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
386 out_enrollment_events.push(disqualified.get_change_event());
387 disqualified
388 }
389 EnrollmentStatus::NotEnrolled { .. }
390 | EnrollmentStatus::Disqualified { .. }
391 | EnrollmentStatus::WasEnrolled { .. }
392 | EnrollmentStatus::Error { .. } => self.clone(),
393 };
394 ExperimentEnrollment {
395 status: updated.status.clone(),
396 ..updated
397 }
398 }
399
400 fn maybe_garbage_collect(&self) -> Option<Self> {
403 if let EnrollmentStatus::WasEnrolled {
404 experiment_ended_at,
405 ..
406 } = self.status
407 {
408 let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
409 if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
410 return Some(self.clone());
411 }
412 }
413 debug!("Garbage collecting enrollment '{}'", self.slug);
414 None
415 }
416
417 fn get_change_event(&self) -> EnrollmentChangeEvent {
420 match &self.status {
421 EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
422 &self.slug,
423 branch,
424 None,
425 EnrollmentChangeEventType::Enrollment,
426 ),
427 EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
428 &self.slug,
429 branch,
430 None,
431 EnrollmentChangeEventType::Unenrollment,
432 ),
433 EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
434 &self.slug,
435 branch,
436 match reason {
437 DisqualifiedReason::NotSelected => Some("bucketing"),
438 DisqualifiedReason::NotTargeted => Some("targeting"),
439 DisqualifiedReason::OptOut => Some("optout"),
440 DisqualifiedReason::Error => Some("error"),
441 },
442 EnrollmentChangeEventType::Disqualification,
443 ),
444 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
445 unreachable!()
446 }
447 }
448 }
449
450 fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
452 match self.status {
453 EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
454 status: EnrollmentStatus::Disqualified {
455 reason,
456 branch: branch.to_owned(),
457 },
458 ..self.clone()
459 },
460 EnrollmentStatus::NotEnrolled { .. }
461 | EnrollmentStatus::Disqualified { .. }
462 | EnrollmentStatus::WasEnrolled { .. }
463 | EnrollmentStatus::Error { .. } => self.clone(),
464 }
465 }
466}
467
468#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
471pub enum EnrollmentStatus {
472 Enrolled {
473 reason: EnrolledReason,
474 branch: String,
475 },
476 NotEnrolled {
477 reason: NotEnrolledReason,
478 },
479 Disqualified {
480 reason: DisqualifiedReason,
481 branch: String,
482 },
483 WasEnrolled {
484 branch: String,
485 experiment_ended_at: u64, },
487 Error {
489 reason: String,
492 },
493}
494
495impl EnrollmentStatus {
496 pub fn name(&self) -> String {
497 match self {
498 EnrollmentStatus::Enrolled { .. } => "Enrolled",
499 EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
500 EnrollmentStatus::Disqualified { .. } => "Disqualified",
501 EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
502 EnrollmentStatus::Error { .. } => "Error",
503 }
504 .into()
505 }
506}
507
508impl EnrollmentStatus {
509 pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
512 EnrollmentStatus::Enrolled {
513 reason,
514 branch: branch.to_owned(),
515 }
516 }
517
518 pub fn is_enrolled(&self) -> bool {
521 matches!(self, EnrollmentStatus::Enrolled { .. })
522 }
523}
524
525pub(crate) trait ExperimentMetadata {
526 fn get_slug(&self) -> String;
527
528 fn is_rollout(&self) -> bool;
529}
530
531pub(crate) struct EnrollmentsEvolver<'a> {
532 available_randomization_units: &'a AvailableRandomizationUnits,
533 targeting_helper: &'a mut NimbusTargetingHelper,
534 coenrolling_feature_ids: &'a HashSet<&'a str>,
535}
536
537impl<'a> EnrollmentsEvolver<'a> {
538 pub(crate) fn new(
539 available_randomization_units: &'a AvailableRandomizationUnits,
540 targeting_helper: &'a mut NimbusTargetingHelper,
541 coenrolling_feature_ids: &'a HashSet<&str>,
542 ) -> Self {
543 Self {
544 available_randomization_units,
545 targeting_helper,
546 coenrolling_feature_ids,
547 }
548 }
549
550 pub(crate) fn evolve_enrollments<E>(
551 &mut self,
552 is_user_participating: bool,
553 prev_experiments: &[E],
554 next_experiments: &[Experiment],
555 prev_enrollments: &[ExperimentEnrollment],
556 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
557 where
558 E: ExperimentMetadata + Clone,
559 {
560 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
561 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
562
563 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
566 prev_experiments,
567 prev_enrollments,
568 ExperimentMetadata::is_rollout,
569 );
570 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
571
572 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
573 is_user_participating,
574 &prev_rollouts,
575 &next_rollouts,
576 &ro_enrollments,
577 )?;
578
579 enrollments.extend(next_ro_enrollments);
580 events.extend(ro_events);
581
582 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
583
584 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
589 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
590 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
591 .iter()
592 .filter(|e| !ro_slugs.contains(&e.slug))
593 .map(|e| e.to_owned())
594 .collect();
595
596 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
597 is_user_participating,
598 &prev_experiments,
599 &next_experiments,
600 &prev_enrollments,
601 )?;
602
603 enrollments.extend(next_exp_enrollments);
604 events.extend(exp_events);
605
606 Ok((enrollments, events))
607 }
608
609 pub(crate) fn evolve_enrollment_recipes<E>(
612 &mut self,
613 is_user_participating: bool,
614 prev_experiments: &[E],
615 next_experiments: &[Experiment],
616 prev_enrollments: &[ExperimentEnrollment],
617 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
618 where
619 E: ExperimentMetadata + Clone,
620 {
621 let mut enrollment_events = vec![];
622 let prev_experiments_map = map_experiments(prev_experiments);
623 let next_experiments_map = map_experiments(next_experiments);
624 let prev_enrollments_map = map_enrollments(prev_enrollments);
625
626 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
629 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
630
631 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
632
633 for prev_enrollment in prev_enrollments {
640 if matches!(
641 prev_enrollment.status,
642 EnrollmentStatus::NotEnrolled {
643 reason: NotEnrolledReason::FeatureConflict
644 }
645 ) {
646 continue;
647 }
648 let slug = &prev_enrollment.slug;
649
650 let next_enrollment = match self.evolve_enrollment(
651 is_user_participating,
652 prev_experiments_map.get(slug).copied(),
653 next_experiments_map.get(slug).copied(),
654 Some(prev_enrollment),
655 &mut enrollment_events,
656 ) {
657 Ok(enrollment) => enrollment,
658 Err(e) => {
659 warn!("{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
665 None
666 }
667 };
668
669 #[cfg(feature = "stateful")]
670 if let Some(ref enrollment) = next_enrollment.clone() {
671 if self.targeting_helper.update_enrollment(enrollment) {
672 debug!("Enrollment updated for {}", enrollment.slug);
673 } else {
674 debug!("Enrollment unchanged for {}", enrollment.slug);
675 }
676 }
677
678 self.reserve_enrolled_features(
679 next_enrollment,
680 &next_experiments_map,
681 &mut enrolled_features,
682 &mut coenrolling_features,
683 &mut next_enrollments,
684 );
685 }
686
687 let next_experiments = sort_experiments_by_published_date(next_experiments);
690 for next_experiment in next_experiments {
691 let slug = &next_experiment.slug;
692
693 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
699 .get_feature_ids()
700 .iter()
701 .filter_map(|id| enrolled_features.get(id))
702 .collect();
703 if !needed_features_in_use.is_empty() {
704 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
705 if is_our_experiment {
706 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
710 } else {
713 next_enrollments.push(ExperimentEnrollment {
716 slug: slug.clone(),
717 status: EnrollmentStatus::NotEnrolled {
718 reason: NotEnrolledReason::FeatureConflict,
719 },
720 });
721
722 enrollment_events.push(EnrollmentChangeEvent {
723 experiment_slug: slug.clone(),
724 branch_slug: "N/A".to_string(),
725 reason: Some("feature-conflict".to_string()),
726 change: EnrollmentChangeEventType::EnrollFailed,
727 })
728 }
729 continue;
735 }
736
737 let prev_enrollment = prev_enrollments_map.get(slug).copied();
742
743 if prev_enrollment.is_none()
744 || matches!(
745 prev_enrollment.unwrap().status,
746 EnrollmentStatus::NotEnrolled {
747 reason: NotEnrolledReason::FeatureConflict
748 }
749 )
750 {
751 let next_enrollment = match self.evolve_enrollment(
752 is_user_participating,
753 prev_experiments_map.get(slug).copied(),
754 Some(next_experiment),
755 prev_enrollment,
756 &mut enrollment_events,
757 ) {
758 Ok(enrollment) => enrollment,
759 Err(e) => {
760 warn!("{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
766 None
767 }
768 };
769
770 #[cfg(feature = "stateful")]
771 if let Some(ref enrollment) = next_enrollment.clone() {
772 if self.targeting_helper.update_enrollment(enrollment) {
773 debug!("Enrollment updated for {}", enrollment.slug);
774 } else {
775 debug!("Enrollment unchanged for {}", enrollment.slug);
776 }
777 }
778
779 self.reserve_enrolled_features(
780 next_enrollment,
781 &next_experiments_map,
782 &mut enrolled_features,
783 &mut coenrolling_features,
784 &mut next_enrollments,
785 );
786 }
787 }
788
789 enrolled_features.extend(coenrolling_features);
790
791 let updated_enrolled_features = map_features(
795 &next_enrollments,
796 &next_experiments_map,
797 self.coenrolling_feature_ids,
798 );
799 if enrolled_features != updated_enrolled_features {
800 Err(NimbusError::InternalError(
801 "Next enrollment calculation error",
802 ))
803 } else {
804 Ok((next_enrollments, enrollment_events))
805 }
806 }
807
808 fn reserve_enrolled_features(
810 &self,
811 latest_enrollment: Option<ExperimentEnrollment>,
812 experiments: &HashMap<String, &Experiment>,
813 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
814 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
815 enrollments: &mut Vec<ExperimentEnrollment>,
816 ) {
817 if let Some(enrollment) = latest_enrollment {
818 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
822 populate_feature_maps(
823 enrolled_feature,
824 self.coenrolling_feature_ids,
825 enrolled_features,
826 coenrolling_features,
827 );
828 }
829 enrollments.push(enrollment);
831 }
832 }
833
834 pub(crate) fn evolve_enrollment<E>(
844 &mut self,
845 is_user_participating: bool,
846 prev_experiment: Option<&E>,
847 next_experiment: Option<&Experiment>,
848 prev_enrollment: Option<&ExperimentEnrollment>,
849 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, ) -> Result<Option<ExperimentEnrollment>>
851 where
852 E: ExperimentMetadata + Clone,
853 {
854 let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
855 enrollment.status.is_enrolled()
856 } else {
857 false
858 };
859
860 let targeting_helper = self
865 .targeting_helper
866 .put("is_already_enrolled", is_already_enrolled);
867
868 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
869 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
871 is_user_participating,
872 self.available_randomization_units,
873 experiment,
874 &targeting_helper,
875 out_enrollment_events,
876 )?),
877 (Some(_), None, Some(enrollment)) => {
879 enrollment.on_experiment_ended(out_enrollment_events)
880 }
881 (Some(_), Some(experiment), Some(enrollment)) => {
883 Some(enrollment.on_experiment_updated(
884 is_user_participating,
885 self.available_randomization_units,
886 experiment,
887 &targeting_helper,
888 out_enrollment_events,
889 )?)
890 }
891 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
892 (None, Some(_), Some(_)) => {
893 return Err(NimbusError::InternalError(
894 "New experiment but enrollment already exists.",
895 ))
896 }
897 (Some(_), None, None) | (Some(_), Some(_), None) => {
898 return Err(NimbusError::InternalError(
899 "Experiment in the db did not have an associated enrollment record.",
900 ))
901 }
902 (None, None, None) => {
903 return Err(NimbusError::InternalError(
904 "evolve_experiment called with nothing that could evolve or be evolved",
905 ))
906 }
907 })
908 }
909}
910
911fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
912where
913 E: ExperimentMetadata + Clone,
914{
915 let mut map_experiments = HashMap::with_capacity(experiments.len());
916 for e in experiments {
917 map_experiments.insert(e.get_slug(), e);
918 }
919 map_experiments
920}
921
922pub fn map_enrollments(
923 enrollments: &[ExperimentEnrollment],
924) -> HashMap<String, &ExperimentEnrollment> {
925 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
926 for e in enrollments {
927 map_enrollments.insert(e.slug.clone(), e);
928 }
929 map_enrollments
930}
931
932pub(crate) fn filter_experiments_and_enrollments<E>(
933 experiments: &[E],
934 enrollments: &[ExperimentEnrollment],
935 filter_fn: fn(&E) -> bool,
936) -> (Vec<E>, Vec<ExperimentEnrollment>)
937where
938 E: ExperimentMetadata + Clone,
939{
940 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
941
942 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
943
944 let enrollments: Vec<ExperimentEnrollment> = enrollments
945 .iter()
946 .filter(|e| slugs.contains(&e.slug))
947 .map(|e| e.to_owned())
948 .collect();
949
950 (experiments, enrollments)
951}
952
953fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
954where
955 E: ExperimentMetadata + Clone,
956{
957 experiments
958 .iter()
959 .filter(|e| filter_fn(e))
960 .cloned()
961 .collect()
962}
963
964pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
965 let mut experiments: Vec<_> = experiments.iter().collect();
966 experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
967 experiments
968}
969
970fn map_features(
973 enrollments: &[ExperimentEnrollment],
974 experiments: &HashMap<String, &Experiment>,
975 coenrolling_ids: &HashSet<&str>,
976) -> HashMap<String, EnrolledFeatureConfig> {
977 let mut colliding_features = HashMap::with_capacity(enrollments.len());
978 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
979 for enrolled_feature_config in enrollments
980 .iter()
981 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
982 {
983 populate_feature_maps(
984 enrolled_feature_config,
985 coenrolling_ids,
986 &mut colliding_features,
987 &mut coenrolling_features,
988 );
989 }
990 colliding_features.extend(coenrolling_features.drain());
991
992 colliding_features
993}
994
995pub fn map_features_by_feature_id(
996 enrollments: &[ExperimentEnrollment],
997 experiments: &[Experiment],
998 coenrolling_ids: &HashSet<&str>,
999) -> HashMap<String, EnrolledFeatureConfig> {
1000 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1001 experiments,
1002 enrollments,
1003 ExperimentMetadata::is_rollout,
1004 );
1005 let (experiments, exp_enrollments) =
1006 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1007
1008 let features_under_rollout = map_features(
1009 &ro_enrollments,
1010 &map_experiments(&rollouts),
1011 coenrolling_ids,
1012 );
1013 let features_under_experiment = map_features(
1014 &exp_enrollments,
1015 &map_experiments(&experiments),
1016 coenrolling_ids,
1017 );
1018
1019 features_under_experiment
1020 .defaults(&features_under_rollout)
1021 .unwrap()
1022}
1023
1024pub(crate) fn populate_feature_maps(
1025 enrolled_feature: EnrolledFeatureConfig,
1026 coenrolling_feature_ids: &HashSet<&str>,
1027 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1028 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1029) {
1030 let feature_id = &enrolled_feature.feature_id;
1031 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1032 colliding_features.insert(feature_id.clone(), enrolled_feature);
1035 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1036 let merged = enrolled_feature
1040 .defaults(existing)
1041 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1042
1043 let merged = EnrolledFeatureConfig {
1046 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1048 branch: None,
1049 ..merged
1050 };
1051 coenrolling_features.insert(feature_id.clone(), merged);
1052 } else {
1053 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1055 }
1056}
1057
1058fn get_enrolled_feature_configs(
1059 enrollment: &ExperimentEnrollment,
1060 experiments: &HashMap<String, &Experiment>,
1061) -> Vec<EnrolledFeatureConfig> {
1062 let branch_slug = match &enrollment.status {
1064 EnrollmentStatus::Enrolled { branch, .. } => branch,
1065 _ => return Vec::new(),
1066 };
1067
1068 let experiment_slug = &enrollment.slug;
1069
1070 let experiment = match experiments.get(experiment_slug).copied() {
1071 Some(exp) => exp,
1072 _ => return Vec::new(),
1073 };
1074
1075 let mut branch_features = match &experiment.get_branch(branch_slug) {
1078 Some(branch) => branch.get_feature_configs(),
1079 _ => Default::default(),
1080 };
1081
1082 branch_features.iter_mut().for_each(|f| {
1083 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1084 });
1085
1086 let branch_feature_ids = &branch_features
1087 .iter()
1088 .map(|f| &f.feature_id)
1089 .collect::<HashSet<_>>();
1090
1091 let non_branch_features: Vec<FeatureConfig> = experiment
1095 .get_feature_ids()
1096 .into_iter()
1097 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1098 .map(|feature_id| FeatureConfig {
1099 feature_id,
1100 ..Default::default()
1101 })
1102 .collect();
1103
1104 branch_features
1107 .iter()
1108 .chain(non_branch_features.iter())
1109 .map(|f| EnrolledFeatureConfig {
1110 feature: f.to_owned(),
1111 slug: experiment_slug.clone(),
1112 branch: if !experiment.is_rollout() {
1113 Some(branch_slug.clone())
1114 } else {
1115 None
1116 },
1117 feature_id: f.feature_id.clone(),
1118 })
1119 .collect()
1120}
1121
1122#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1126#[serde(rename_all = "camelCase")]
1127pub struct EnrolledFeatureConfig {
1128 pub feature: FeatureConfig,
1129 pub slug: String,
1130 pub branch: Option<String>,
1131 pub feature_id: String,
1132}
1133
1134impl Defaults for EnrolledFeatureConfig {
1135 fn defaults(&self, fallback: &Self) -> Result<Self> {
1136 if self.feature_id != fallback.feature_id {
1137 Err(NimbusError::InternalError(
1139 "Cannot merge enrolled feature configs from different features",
1140 ))
1141 } else {
1142 Ok(Self {
1143 slug: self.slug.to_owned(),
1144 feature_id: self.feature_id.to_owned(),
1145 feature: self.feature.defaults(&fallback.feature)?,
1147 branch: self.branch.to_owned(),
1151 })
1152 }
1153 }
1154}
1155
1156impl ExperimentMetadata for EnrolledFeatureConfig {
1157 fn get_slug(&self) -> String {
1158 self.slug.clone()
1159 }
1160
1161 fn is_rollout(&self) -> bool {
1162 self.branch.is_none()
1163 }
1164}
1165
1166#[derive(Debug, Clone, PartialEq, Eq)]
1167pub struct EnrolledFeature {
1168 pub slug: String,
1169 pub branch: Option<String>,
1170 pub feature_id: String,
1171}
1172
1173impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1174 fn from(value: &EnrolledFeatureConfig) -> Self {
1175 Self {
1176 slug: value.slug.clone(),
1177 branch: value.branch.clone(),
1178 feature_id: value.feature_id.clone(),
1179 }
1180 }
1181}
1182
1183#[derive(Serialize, Deserialize, Debug, Clone)]
1184pub struct EnrollmentChangeEvent {
1185 pub experiment_slug: String,
1186 pub branch_slug: String,
1187 pub reason: Option<String>,
1188 pub change: EnrollmentChangeEventType,
1189}
1190
1191impl EnrollmentChangeEvent {
1192 pub(crate) fn new(
1193 slug: &str,
1194 branch: &str,
1195 reason: Option<&str>,
1196 change: EnrollmentChangeEventType,
1197 ) -> Self {
1198 Self {
1199 experiment_slug: slug.to_owned(),
1200 branch_slug: branch.to_owned(),
1201 reason: reason.map(|s| s.to_owned()),
1202 change,
1203 }
1204 }
1205}
1206
1207#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1208pub enum EnrollmentChangeEventType {
1209 Enrollment,
1210 EnrollFailed,
1211 Disqualification,
1212 Unenrollment,
1213 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1214 UnenrollFailed,
1215}
1216
1217pub(crate) fn now_secs() -> u64 {
1218 SystemTime::now()
1219 .duration_since(UNIX_EPOCH)
1220 .expect("Current date before Unix Epoch.")
1221 .as_secs()
1222}