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