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