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(Serialize, Deserialize, Debug, Clone)]
79pub struct Participation {
80 pub in_experiments: bool,
81 pub in_rollouts: bool,
82}
83
84impl Default for Participation {
85 fn default() -> Self {
86 Self {
87 in_experiments: true,
88 in_rollouts: true,
89 }
90 }
91}
92
93#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
98pub enum DisqualifiedReason {
99 Error,
101 OptOut,
103 NotTargeted,
105 NotSelected,
107 #[cfg(feature = "stateful")]
109 PrefUnenrollReason { reason: PrefUnenrollReason },
110}
111
112impl Display for DisqualifiedReason {
113 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
114 Display::fmt(
115 match self {
116 DisqualifiedReason::Error => "Error",
117 DisqualifiedReason::OptOut => "OptOut",
118 DisqualifiedReason::NotSelected => "NotSelected",
119 DisqualifiedReason::NotTargeted => "NotTargeted",
120 #[cfg(feature = "stateful")]
121 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
122 PrefUnenrollReason::Changed => "PrefChanged",
123 PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
124 },
125 },
126 f,
127 )
128 }
129}
130
131#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
136pub struct ExperimentEnrollment {
137 pub slug: String,
138 pub status: EnrollmentStatus,
139}
140
141impl ExperimentEnrollment {
142 fn from_new_experiment(
145 is_user_participating: bool,
146 available_randomization_units: &AvailableRandomizationUnits,
147 experiment: &Experiment,
148 targeting_helper: &NimbusTargetingHelper,
149 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
150 ) -> Result<Self> {
151 Ok(if !is_user_participating {
152 Self {
153 slug: experiment.slug.clone(),
154 status: EnrollmentStatus::NotEnrolled {
155 reason: NotEnrolledReason::OptOut,
156 },
157 }
158 } else if experiment.is_enrollment_paused {
159 Self {
160 slug: experiment.slug.clone(),
161 status: EnrollmentStatus::NotEnrolled {
162 reason: NotEnrolledReason::EnrollmentsPaused,
163 },
164 }
165 } else {
166 let enrollment =
167 evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
168 debug!(
169 "Experiment '{}' is new - enrollment status is {:?}",
170 &enrollment.slug, &enrollment
171 );
172 if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
173 out_enrollment_events.push(enrollment.get_change_event())
174 }
175 enrollment
176 })
177 }
178
179 #[cfg_attr(not(feature = "stateful"), allow(unused))]
181 pub(crate) fn from_explicit_opt_in(
182 experiment: &Experiment,
183 branch_slug: &str,
184 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
185 ) -> Result<Self> {
186 if !experiment.has_branch(branch_slug) {
187 out_enrollment_events.push(EnrollmentChangeEvent {
188 experiment_slug: experiment.slug.to_string(),
189 branch_slug: branch_slug.to_string(),
190 reason: Some("does-not-exist".to_string()),
191 change: EnrollmentChangeEventType::EnrollFailed,
192 });
193
194 return Err(NimbusError::NoSuchBranch(
195 branch_slug.to_owned(),
196 experiment.slug.clone(),
197 ));
198 }
199 let enrollment = Self {
200 slug: experiment.slug.clone(),
201 status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
202 };
203 out_enrollment_events.push(enrollment.get_change_event());
204 Ok(enrollment)
205 }
206
207 #[allow(clippy::too_many_arguments)]
209 fn on_experiment_updated(
210 &self,
211 is_user_participating: bool,
212 available_randomization_units: &AvailableRandomizationUnits,
213 updated_experiment: &Experiment,
214 targeting_helper: &NimbusTargetingHelper,
215 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
216 ) -> Result<Self> {
217 Ok(match &self.status {
218 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
219 if !is_user_participating || updated_experiment.is_enrollment_paused {
220 self.clone()
221 } else {
222 let updated_enrollment = evaluate_enrollment(
223 available_randomization_units,
224 updated_experiment,
225 targeting_helper,
226 )?;
227 debug!(
228 "Experiment '{}' with enrollment {:?} is now {:?}",
229 &self.slug, &self, updated_enrollment
230 );
231 if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
232 out_enrollment_events.push(updated_enrollment.get_change_event());
233 }
234 updated_enrollment
235 }
236 }
237
238 EnrollmentStatus::Enrolled {
239 ref branch,
240 ref reason,
241 ..
242 } => {
243 if !is_user_participating {
244 debug!(
245 "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
246 &self.slug
247 );
248 let updated_enrollment =
249 self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
250 out_enrollment_events.push(updated_enrollment.get_change_event());
251 updated_enrollment
252 } else if !updated_experiment.has_branch(branch) {
253 let updated_enrollment =
255 self.disqualify_from_enrolled(DisqualifiedReason::Error);
256 out_enrollment_events.push(updated_enrollment.get_change_event());
257 updated_enrollment
258 } else if matches!(reason, EnrolledReason::OptIn) {
259 self.clone()
262 } else {
263 let evaluated_enrollment = evaluate_enrollment(
264 available_randomization_units,
265 updated_experiment,
266 targeting_helper,
267 )?;
268 match evaluated_enrollment.status {
269 EnrollmentStatus::Error { .. } => {
270 let updated_enrollment =
271 self.disqualify_from_enrolled(DisqualifiedReason::Error);
272 out_enrollment_events.push(updated_enrollment.get_change_event());
273 updated_enrollment
274 }
275 EnrollmentStatus::NotEnrolled {
276 reason: NotEnrolledReason::NotTargeted,
277 } => {
278 debug!("Existing experiment enrollment '{}' is now disqualified (targeting change)", &self.slug);
279 let updated_enrollment =
280 self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
281 out_enrollment_events.push(updated_enrollment.get_change_event());
282 updated_enrollment
283 }
284 EnrollmentStatus::NotEnrolled {
285 reason: NotEnrolledReason::NotSelected,
286 } => {
287 let updated_enrollment =
290 self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
291 out_enrollment_events.push(updated_enrollment.get_change_event());
292 updated_enrollment
293 }
294 EnrollmentStatus::NotEnrolled { .. }
295 | EnrollmentStatus::Enrolled { .. }
296 | EnrollmentStatus::Disqualified { .. }
297 | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
298 }
299 }
300 }
301 EnrollmentStatus::Disqualified {
302 ref branch, reason, ..
303 } => {
304 if !is_user_participating {
305 debug!(
306 "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
307 &self.slug
308 );
309 Self {
310 slug: self.slug.clone(),
311 status: EnrollmentStatus::Disqualified {
312 reason: DisqualifiedReason::OptOut,
313 branch: branch.clone(),
314 },
315 }
316 } else if updated_experiment.is_rollout
317 && matches!(
318 reason,
319 DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
320 )
321 {
322 let evaluated_enrollment = evaluate_enrollment(
323 available_randomization_units,
324 updated_experiment,
325 targeting_helper,
326 )?;
327 match evaluated_enrollment.status {
328 EnrollmentStatus::Enrolled { .. } => evaluated_enrollment,
329 _ => self.clone(),
330 }
331 } else {
332 self.clone()
333 }
334 }
335 EnrollmentStatus::WasEnrolled { .. } => self.clone(),
336 })
337 }
338
339 fn on_experiment_ended(
345 &self,
346 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
347 ) -> Option<Self> {
348 debug!(
349 "Experiment '{}' vanished while we had enrollment status of {:?}",
350 self.slug, self
351 );
352 let branch = match self.status {
353 EnrollmentStatus::Enrolled { ref branch, .. }
354 | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
355 EnrollmentStatus::NotEnrolled { .. }
356 | EnrollmentStatus::WasEnrolled { .. }
357 | EnrollmentStatus::Error { .. } => return None, };
359 let enrollment = Self {
360 slug: self.slug.clone(),
361 status: EnrollmentStatus::WasEnrolled {
362 branch: branch.to_owned(),
363 experiment_ended_at: now_secs(),
364 },
365 };
366 out_enrollment_events.push(enrollment.get_change_event());
367 Some(enrollment)
368 }
369
370 #[allow(clippy::unnecessary_wraps)]
372 #[cfg_attr(not(feature = "stateful"), allow(unused))]
373 pub(crate) fn on_explicit_opt_out(
374 &self,
375 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
376 ) -> ExperimentEnrollment {
377 match self.status {
378 EnrollmentStatus::Enrolled { .. } => {
379 let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
380 out_enrollment_events.push(enrollment.get_change_event());
381 enrollment
382 }
383 EnrollmentStatus::NotEnrolled { .. } => Self {
384 slug: self.slug.to_string(),
385 status: EnrollmentStatus::NotEnrolled {
386 reason: NotEnrolledReason::OptOut, },
388 },
389 EnrollmentStatus::Disqualified { .. }
390 | EnrollmentStatus::WasEnrolled { .. }
391 | EnrollmentStatus::Error { .. } => {
392 self.clone()
394 }
395 }
396 }
397
398 #[cfg(feature = "stateful")]
399 pub(crate) fn on_pref_unenroll(
400 &self,
401 pref_unenroll_reason: PrefUnenrollReason,
402 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
403 ) -> ExperimentEnrollment {
404 match self.status {
405 EnrollmentStatus::Enrolled { .. } => {
406 let enrollment =
407 self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
408 reason: pref_unenroll_reason,
409 });
410 out_enrollment_events.push(enrollment.get_change_event());
411 enrollment
412 }
413 _ => self.clone(),
414 }
415 }
416
417 #[cfg_attr(not(feature = "stateful"), allow(unused))]
423 pub fn reset_telemetry_identifiers(
424 &self,
425 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
426 ) -> Self {
427 let updated = match self.status {
428 EnrollmentStatus::Enrolled { .. } => {
429 let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
430 out_enrollment_events.push(disqualified.get_change_event());
431 disqualified
432 }
433 EnrollmentStatus::NotEnrolled { .. }
434 | EnrollmentStatus::Disqualified { .. }
435 | EnrollmentStatus::WasEnrolled { .. }
436 | EnrollmentStatus::Error { .. } => self.clone(),
437 };
438 ExperimentEnrollment {
439 status: updated.status.clone(),
440 ..updated
441 }
442 }
443
444 fn maybe_garbage_collect(&self) -> Option<Self> {
447 if let EnrollmentStatus::WasEnrolled {
448 experiment_ended_at,
449 ..
450 } = self.status
451 {
452 let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
453 if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
454 return Some(self.clone());
455 }
456 }
457 debug!("Garbage collecting enrollment '{}'", self.slug);
458 None
459 }
460
461 fn get_change_event(&self) -> EnrollmentChangeEvent {
464 match &self.status {
465 EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
466 &self.slug,
467 branch,
468 None,
469 EnrollmentChangeEventType::Enrollment,
470 ),
471 EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
472 &self.slug,
473 branch,
474 None,
475 EnrollmentChangeEventType::Unenrollment,
476 ),
477 EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
478 &self.slug,
479 branch,
480 match reason {
481 DisqualifiedReason::NotSelected => Some("bucketing"),
482 DisqualifiedReason::NotTargeted => Some("targeting"),
483 DisqualifiedReason::OptOut => Some("optout"),
484 DisqualifiedReason::Error => Some("error"),
485 #[cfg(feature = "stateful")]
486 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
487 PrefUnenrollReason::Changed => Some("pref_changed"),
488 PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"),
489 },
490 },
491 EnrollmentChangeEventType::Disqualification,
492 ),
493 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
494 unreachable!()
495 }
496 }
497 }
498
499 fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
501 match self.status {
502 EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
503 status: EnrollmentStatus::Disqualified {
504 reason,
505 branch: branch.to_owned(),
506 },
507 ..self.clone()
508 },
509 EnrollmentStatus::NotEnrolled { .. }
510 | EnrollmentStatus::Disqualified { .. }
511 | EnrollmentStatus::WasEnrolled { .. }
512 | EnrollmentStatus::Error { .. } => self.clone(),
513 }
514 }
515}
516
517#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
520pub enum EnrollmentStatus {
521 Enrolled {
522 reason: EnrolledReason,
523 branch: String,
524 },
525 NotEnrolled {
526 reason: NotEnrolledReason,
527 },
528 Disqualified {
529 reason: DisqualifiedReason,
530 branch: String,
531 },
532 WasEnrolled {
533 branch: String,
534 experiment_ended_at: u64, },
536 Error {
538 reason: String,
541 },
542}
543
544impl EnrollmentStatus {
545 pub fn name(&self) -> String {
546 match self {
547 EnrollmentStatus::Enrolled { .. } => "Enrolled",
548 EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
549 EnrollmentStatus::Disqualified { .. } => "Disqualified",
550 EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
551 EnrollmentStatus::Error { .. } => "Error",
552 }
553 .into()
554 }
555}
556
557impl EnrollmentStatus {
558 pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
561 EnrollmentStatus::Enrolled {
562 reason,
563 branch: branch.to_owned(),
564 }
565 }
566
567 pub fn is_enrolled(&self) -> bool {
570 matches!(self, EnrollmentStatus::Enrolled { .. })
571 }
572}
573
574pub(crate) trait ExperimentMetadata {
575 fn get_slug(&self) -> String;
576
577 fn is_rollout(&self) -> bool;
578}
579
580pub(crate) struct EnrollmentsEvolver<'a> {
581 available_randomization_units: &'a AvailableRandomizationUnits,
582 targeting_helper: &'a mut NimbusTargetingHelper,
583 coenrolling_feature_ids: &'a HashSet<&'a str>,
584}
585
586impl<'a> EnrollmentsEvolver<'a> {
587 pub(crate) fn new(
588 available_randomization_units: &'a AvailableRandomizationUnits,
589 targeting_helper: &'a mut NimbusTargetingHelper,
590 coenrolling_feature_ids: &'a HashSet<&str>,
591 ) -> Self {
592 Self {
593 available_randomization_units,
594 targeting_helper,
595 coenrolling_feature_ids,
596 }
597 }
598
599 pub(crate) fn evolve_enrollments<E>(
600 &mut self,
601 participation: Participation,
602 prev_experiments: &[E],
603 next_experiments: &[Experiment],
604 prev_enrollments: &[ExperimentEnrollment],
605 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
606 where
607 E: ExperimentMetadata + Clone,
608 {
609 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
610 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
611
612 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
615 prev_experiments,
616 prev_enrollments,
617 ExperimentMetadata::is_rollout,
618 );
619 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
620
621 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
622 participation.in_rollouts,
623 &prev_rollouts,
624 &next_rollouts,
625 &ro_enrollments,
626 )?;
627
628 enrollments.extend(next_ro_enrollments);
629 events.extend(ro_events);
630
631 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
632
633 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
638 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
639 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
640 .iter()
641 .filter(|e| !ro_slugs.contains(&e.slug))
642 .map(|e| e.to_owned())
643 .collect();
644
645 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
646 participation.in_experiments,
647 &prev_experiments,
648 &next_experiments,
649 &prev_enrollments,
650 )?;
651
652 enrollments.extend(next_exp_enrollments);
653 events.extend(exp_events);
654
655 Ok((enrollments, events))
656 }
657
658 pub(crate) fn evolve_enrollment_recipes<E>(
661 &mut self,
662 is_user_participating: bool,
663 prev_experiments: &[E],
664 next_experiments: &[Experiment],
665 prev_enrollments: &[ExperimentEnrollment],
666 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
667 where
668 E: ExperimentMetadata + Clone,
669 {
670 let mut enrollment_events = vec![];
671 let prev_experiments_map = map_experiments(prev_experiments);
672 let next_experiments_map = map_experiments(next_experiments);
673 let prev_enrollments_map = map_enrollments(prev_enrollments);
674
675 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
678 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
679
680 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
681
682 for prev_enrollment in prev_enrollments {
689 if matches!(
690 prev_enrollment.status,
691 EnrollmentStatus::NotEnrolled {
692 reason: NotEnrolledReason::FeatureConflict
693 }
694 ) {
695 continue;
696 }
697 let slug = &prev_enrollment.slug;
698
699 let next_enrollment = match self.evolve_enrollment(
700 is_user_participating,
701 prev_experiments_map.get(slug).copied(),
702 next_experiments_map.get(slug).copied(),
703 Some(prev_enrollment),
704 &mut enrollment_events,
705 ) {
706 Ok(enrollment) => enrollment,
707 Err(e) => {
708 warn!("{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
714 None
715 }
716 };
717
718 #[cfg(feature = "stateful")]
719 if let Some(ref enrollment) = next_enrollment.clone() {
720 if self.targeting_helper.update_enrollment(enrollment) {
721 debug!("Enrollment updated for {}", enrollment.slug);
722 } else {
723 debug!("Enrollment unchanged for {}", enrollment.slug);
724 }
725 }
726
727 self.reserve_enrolled_features(
728 next_enrollment,
729 &next_experiments_map,
730 &mut enrolled_features,
731 &mut coenrolling_features,
732 &mut next_enrollments,
733 );
734 }
735
736 let next_experiments = sort_experiments_by_published_date(next_experiments);
739 for next_experiment in next_experiments {
740 let slug = &next_experiment.slug;
741
742 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
748 .get_feature_ids()
749 .iter()
750 .filter_map(|id| enrolled_features.get(id))
751 .collect();
752 if !needed_features_in_use.is_empty() {
753 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
754 if is_our_experiment {
755 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
759 } else {
762 next_enrollments.push(ExperimentEnrollment {
765 slug: slug.clone(),
766 status: EnrollmentStatus::NotEnrolled {
767 reason: NotEnrolledReason::FeatureConflict,
768 },
769 });
770
771 enrollment_events.push(EnrollmentChangeEvent {
772 experiment_slug: slug.clone(),
773 branch_slug: "N/A".to_string(),
774 reason: Some("feature-conflict".to_string()),
775 change: EnrollmentChangeEventType::EnrollFailed,
776 })
777 }
778 continue;
784 }
785
786 let prev_enrollment = prev_enrollments_map.get(slug).copied();
791
792 if prev_enrollment.is_none()
793 || matches!(
794 prev_enrollment.unwrap().status,
795 EnrollmentStatus::NotEnrolled {
796 reason: NotEnrolledReason::FeatureConflict
797 }
798 )
799 {
800 let next_enrollment = match self.evolve_enrollment(
801 is_user_participating,
802 prev_experiments_map.get(slug).copied(),
803 Some(next_experiment),
804 prev_enrollment,
805 &mut enrollment_events,
806 ) {
807 Ok(enrollment) => enrollment,
808 Err(e) => {
809 warn!("{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
815 None
816 }
817 };
818
819 #[cfg(feature = "stateful")]
820 if let Some(ref enrollment) = next_enrollment.clone() {
821 if self.targeting_helper.update_enrollment(enrollment) {
822 debug!("Enrollment updated for {}", enrollment.slug);
823 } else {
824 debug!("Enrollment unchanged for {}", enrollment.slug);
825 }
826 }
827
828 self.reserve_enrolled_features(
829 next_enrollment,
830 &next_experiments_map,
831 &mut enrolled_features,
832 &mut coenrolling_features,
833 &mut next_enrollments,
834 );
835 }
836 }
837
838 enrolled_features.extend(coenrolling_features);
839
840 let updated_enrolled_features = map_features(
844 &next_enrollments,
845 &next_experiments_map,
846 self.coenrolling_feature_ids,
847 );
848 if enrolled_features != updated_enrolled_features {
849 Err(NimbusError::InternalError(
850 "Next enrollment calculation error",
851 ))
852 } else {
853 Ok((next_enrollments, enrollment_events))
854 }
855 }
856
857 fn reserve_enrolled_features(
859 &self,
860 latest_enrollment: Option<ExperimentEnrollment>,
861 experiments: &HashMap<String, &Experiment>,
862 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
863 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
864 enrollments: &mut Vec<ExperimentEnrollment>,
865 ) {
866 if let Some(enrollment) = latest_enrollment {
867 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
871 populate_feature_maps(
872 enrolled_feature,
873 self.coenrolling_feature_ids,
874 enrolled_features,
875 coenrolling_features,
876 );
877 }
878 enrollments.push(enrollment);
880 }
881 }
882
883 pub(crate) fn evolve_enrollment<E>(
893 &mut self,
894 is_user_participating: bool,
895 prev_experiment: Option<&E>,
896 next_experiment: Option<&Experiment>,
897 prev_enrollment: Option<&ExperimentEnrollment>,
898 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, ) -> Result<Option<ExperimentEnrollment>>
900 where
901 E: ExperimentMetadata + Clone,
902 {
903 let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
904 enrollment.status.is_enrolled()
905 } else {
906 false
907 };
908
909 let targeting_helper = self
914 .targeting_helper
915 .put("is_already_enrolled", is_already_enrolled);
916
917 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
918 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
920 is_user_participating,
921 self.available_randomization_units,
922 experiment,
923 &targeting_helper,
924 out_enrollment_events,
925 )?),
926 (Some(_), None, Some(enrollment)) => {
928 enrollment.on_experiment_ended(out_enrollment_events)
929 }
930 (Some(_), Some(experiment), Some(enrollment)) => {
932 Some(enrollment.on_experiment_updated(
933 is_user_participating,
934 self.available_randomization_units,
935 experiment,
936 &targeting_helper,
937 out_enrollment_events,
938 )?)
939 }
940 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
941 (None, Some(_), Some(_)) => {
942 return Err(NimbusError::InternalError(
943 "New experiment but enrollment already exists.",
944 ))
945 }
946 (Some(_), None, None) | (Some(_), Some(_), None) => {
947 return Err(NimbusError::InternalError(
948 "Experiment in the db did not have an associated enrollment record.",
949 ))
950 }
951 (None, None, None) => {
952 return Err(NimbusError::InternalError(
953 "evolve_experiment called with nothing that could evolve or be evolved",
954 ))
955 }
956 })
957 }
958}
959
960fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
961where
962 E: ExperimentMetadata + Clone,
963{
964 let mut map_experiments = HashMap::with_capacity(experiments.len());
965 for e in experiments {
966 map_experiments.insert(e.get_slug(), e);
967 }
968 map_experiments
969}
970
971pub fn map_enrollments(
972 enrollments: &[ExperimentEnrollment],
973) -> HashMap<String, &ExperimentEnrollment> {
974 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
975 for e in enrollments {
976 map_enrollments.insert(e.slug.clone(), e);
977 }
978 map_enrollments
979}
980
981pub(crate) fn filter_experiments_and_enrollments<E>(
982 experiments: &[E],
983 enrollments: &[ExperimentEnrollment],
984 filter_fn: fn(&E) -> bool,
985) -> (Vec<E>, Vec<ExperimentEnrollment>)
986where
987 E: ExperimentMetadata + Clone,
988{
989 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
990
991 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
992
993 let enrollments: Vec<ExperimentEnrollment> = enrollments
994 .iter()
995 .filter(|e| slugs.contains(&e.slug))
996 .map(|e| e.to_owned())
997 .collect();
998
999 (experiments, enrollments)
1000}
1001
1002fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1003where
1004 E: ExperimentMetadata + Clone,
1005{
1006 experiments
1007 .iter()
1008 .filter(|e| filter_fn(e))
1009 .cloned()
1010 .collect()
1011}
1012
1013pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1014 let mut experiments: Vec<_> = experiments.iter().collect();
1015 experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
1016 experiments
1017}
1018
1019fn map_features(
1022 enrollments: &[ExperimentEnrollment],
1023 experiments: &HashMap<String, &Experiment>,
1024 coenrolling_ids: &HashSet<&str>,
1025) -> HashMap<String, EnrolledFeatureConfig> {
1026 let mut colliding_features = HashMap::with_capacity(enrollments.len());
1027 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1028 for enrolled_feature_config in enrollments
1029 .iter()
1030 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1031 {
1032 populate_feature_maps(
1033 enrolled_feature_config,
1034 coenrolling_ids,
1035 &mut colliding_features,
1036 &mut coenrolling_features,
1037 );
1038 }
1039 colliding_features.extend(coenrolling_features.drain());
1040
1041 colliding_features
1042}
1043
1044pub fn map_features_by_feature_id(
1045 enrollments: &[ExperimentEnrollment],
1046 experiments: &[Experiment],
1047 coenrolling_ids: &HashSet<&str>,
1048) -> HashMap<String, EnrolledFeatureConfig> {
1049 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1050 experiments,
1051 enrollments,
1052 ExperimentMetadata::is_rollout,
1053 );
1054 let (experiments, exp_enrollments) =
1055 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1056
1057 let features_under_rollout = map_features(
1058 &ro_enrollments,
1059 &map_experiments(&rollouts),
1060 coenrolling_ids,
1061 );
1062 let features_under_experiment = map_features(
1063 &exp_enrollments,
1064 &map_experiments(&experiments),
1065 coenrolling_ids,
1066 );
1067
1068 features_under_experiment
1069 .defaults(&features_under_rollout)
1070 .unwrap()
1071}
1072
1073pub(crate) fn populate_feature_maps(
1074 enrolled_feature: EnrolledFeatureConfig,
1075 coenrolling_feature_ids: &HashSet<&str>,
1076 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1077 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1078) {
1079 let feature_id = &enrolled_feature.feature_id;
1080 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1081 colliding_features.insert(feature_id.clone(), enrolled_feature);
1084 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1085 let merged = enrolled_feature
1089 .defaults(existing)
1090 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1091
1092 let merged = EnrolledFeatureConfig {
1095 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1097 branch: None,
1098 ..merged
1099 };
1100 coenrolling_features.insert(feature_id.clone(), merged);
1101 } else {
1102 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1104 }
1105}
1106
1107fn get_enrolled_feature_configs(
1108 enrollment: &ExperimentEnrollment,
1109 experiments: &HashMap<String, &Experiment>,
1110) -> Vec<EnrolledFeatureConfig> {
1111 let branch_slug = match &enrollment.status {
1113 EnrollmentStatus::Enrolled { branch, .. } => branch,
1114 _ => return Vec::new(),
1115 };
1116
1117 let experiment_slug = &enrollment.slug;
1118
1119 let experiment = match experiments.get(experiment_slug).copied() {
1120 Some(exp) => exp,
1121 _ => return Vec::new(),
1122 };
1123
1124 let mut branch_features = match &experiment.get_branch(branch_slug) {
1127 Some(branch) => branch.get_feature_configs(),
1128 _ => Default::default(),
1129 };
1130
1131 branch_features.iter_mut().for_each(|f| {
1132 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1133 });
1134
1135 let branch_feature_ids = &branch_features
1136 .iter()
1137 .map(|f| &f.feature_id)
1138 .collect::<HashSet<_>>();
1139
1140 let non_branch_features: Vec<FeatureConfig> = experiment
1144 .get_feature_ids()
1145 .into_iter()
1146 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1147 .map(|feature_id| FeatureConfig {
1148 feature_id,
1149 ..Default::default()
1150 })
1151 .collect();
1152
1153 branch_features
1156 .iter()
1157 .chain(non_branch_features.iter())
1158 .map(|f| EnrolledFeatureConfig {
1159 feature: f.to_owned(),
1160 slug: experiment_slug.clone(),
1161 branch: if !experiment.is_rollout() {
1162 Some(branch_slug.clone())
1163 } else {
1164 None
1165 },
1166 feature_id: f.feature_id.clone(),
1167 })
1168 .collect()
1169}
1170
1171#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1175#[serde(rename_all = "camelCase")]
1176pub struct EnrolledFeatureConfig {
1177 pub feature: FeatureConfig,
1178 pub slug: String,
1179 pub branch: Option<String>,
1180 pub feature_id: String,
1181}
1182
1183impl Defaults for EnrolledFeatureConfig {
1184 fn defaults(&self, fallback: &Self) -> Result<Self> {
1185 if self.feature_id != fallback.feature_id {
1186 Err(NimbusError::InternalError(
1188 "Cannot merge enrolled feature configs from different features",
1189 ))
1190 } else {
1191 Ok(Self {
1192 slug: self.slug.to_owned(),
1193 feature_id: self.feature_id.to_owned(),
1194 feature: self.feature.defaults(&fallback.feature)?,
1196 branch: self.branch.to_owned(),
1200 })
1201 }
1202 }
1203}
1204
1205impl ExperimentMetadata for EnrolledFeatureConfig {
1206 fn get_slug(&self) -> String {
1207 self.slug.clone()
1208 }
1209
1210 fn is_rollout(&self) -> bool {
1211 self.branch.is_none()
1212 }
1213}
1214
1215#[derive(Debug, Clone, PartialEq, Eq)]
1216pub struct EnrolledFeature {
1217 pub slug: String,
1218 pub branch: Option<String>,
1219 pub feature_id: String,
1220}
1221
1222impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1223 fn from(value: &EnrolledFeatureConfig) -> Self {
1224 Self {
1225 slug: value.slug.clone(),
1226 branch: value.branch.clone(),
1227 feature_id: value.feature_id.clone(),
1228 }
1229 }
1230}
1231
1232#[derive(Serialize, Deserialize, Debug, Clone)]
1233pub struct EnrollmentChangeEvent {
1234 pub experiment_slug: String,
1235 pub branch_slug: String,
1236 pub reason: Option<String>,
1237 pub change: EnrollmentChangeEventType,
1238}
1239
1240impl EnrollmentChangeEvent {
1241 pub(crate) fn new(
1242 slug: &str,
1243 branch: &str,
1244 reason: Option<&str>,
1245 change: EnrollmentChangeEventType,
1246 ) -> Self {
1247 Self {
1248 experiment_slug: slug.to_owned(),
1249 branch_slug: branch.to_owned(),
1250 reason: reason.map(|s| s.to_owned()),
1251 change,
1252 }
1253 }
1254}
1255
1256#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1257pub enum EnrollmentChangeEventType {
1258 Enrollment,
1259 EnrollFailed,
1260 Disqualification,
1261 Unenrollment,
1262 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1263 UnenrollFailed,
1264}
1265
1266pub(crate) fn now_secs() -> u64 {
1267 SystemTime::now()
1268 .duration_since(UNIX_EPOCH)
1269 .expect("Current date before Unix Epoch.")
1270 .as_secs()
1271}