1use std::collections::{HashMap, HashSet};
6use std::fmt::{self, Display};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde_derive::{Deserialize, Serialize};
10
11use crate::defaults::Defaults;
12use crate::error::{NimbusError, Result, debug, warn};
13use crate::evaluator::evaluate_enrollment;
14use crate::json;
15#[cfg(feature = "stateful")]
16use crate::stateful::gecko_prefs::{GeckoPrefStore, OriginalGeckoPref, PrefUnenrollReason};
17use crate::{
18 AvailableRandomizationUnits, Experiment, FeatureConfig, NimbusTargetingHelper,
19 SLUG_REPLACEMENT_PATTERN,
20};
21
22pub(crate) const PREVIOUS_ENROLLMENTS_GC_TIME: Duration = Duration::from_secs(365 * 24 * 3600);
23
24#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
28pub enum EnrolledReason {
29 Qualified,
31 OptIn,
33}
34
35impl Display for EnrolledReason {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 Display::fmt(
38 match self {
39 EnrolledReason::Qualified => "Qualified",
40 EnrolledReason::OptIn => "OptIn",
41 },
42 f,
43 )
44 }
45}
46
47#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
52pub enum NotEnrolledReason {
53 DifferentAppName,
55 DifferentChannel,
57 EnrollmentsPaused,
59 FeatureConflict,
61 NotSelected,
63 NotTargeted,
65 OptOut,
67}
68
69impl Display for NotEnrolledReason {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 Display::fmt(
72 match self {
73 NotEnrolledReason::DifferentAppName => "DifferentAppName",
74 NotEnrolledReason::DifferentChannel => "DifferentChannel",
75 NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
76 NotEnrolledReason::FeatureConflict => "FeatureConflict",
77 NotEnrolledReason::NotSelected => "NotSelected",
78 NotEnrolledReason::NotTargeted => "NotTargeted",
79 NotEnrolledReason::OptOut => "OptOut",
80 },
81 f,
82 )
83 }
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone)]
87pub struct Participation {
88 pub in_experiments: bool,
89 pub in_rollouts: bool,
90}
91
92impl Default for Participation {
93 fn default() -> Self {
94 Self {
95 in_experiments: true,
96 in_rollouts: true,
97 }
98 }
99}
100
101#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
106pub enum DisqualifiedReason {
107 Error,
109 OptOut,
111 NotTargeted,
113 NotSelected,
115 #[cfg(feature = "stateful")]
117 PrefUnenrollReason { reason: PrefUnenrollReason },
118}
119
120impl Display for DisqualifiedReason {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 Display::fmt(
123 match self {
124 DisqualifiedReason::Error => "Error",
125 DisqualifiedReason::OptOut => "OptOut",
126 DisqualifiedReason::NotSelected => "NotSelected",
127 DisqualifiedReason::NotTargeted => "NotTargeted",
128 #[cfg(feature = "stateful")]
129 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
130 PrefUnenrollReason::Changed => "PrefChanged",
131 PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
132 },
133 },
134 f,
135 )
136 }
137}
138
139#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
143#[cfg(feature = "stateful")]
144pub struct PreviousGeckoPrefState {
145 pub original_value: OriginalGeckoPref,
146 pub feature_id: String,
147 pub variable: String,
148}
149
150#[cfg(feature = "stateful")]
151impl PreviousGeckoPrefState {
152 pub(crate) fn on_revert_all_to_prev_gecko_pref_states(
153 prev_gecko_pref_states: &[Self],
154 gecko_pref_store: Option<&GeckoPrefStore>,
155 ) {
156 if let Some(store) = gecko_pref_store {
157 let original_values: Vec<_> = prev_gecko_pref_states
158 .iter()
159 .map(|state| state.original_value.clone())
160 .collect();
161 store
162 .handler
163 .set_gecko_prefs_original_values(original_values);
164 }
165 }
166
167 pub(crate) fn on_partially_revert_to_prev_gecko_pref_states(
168 prev_gecko_pref_states: &[Self],
169 non_reverting_pref_name: &str,
170 gecko_pref_store: Option<&GeckoPrefStore>,
171 ) {
172 if let Some(store) = gecko_pref_store {
173 let qualified_values: Vec<_> = prev_gecko_pref_states
174 .iter()
175 .filter(|state| state.original_value.pref != non_reverting_pref_name)
176 .map(|state| state.original_value.clone())
177 .collect();
178 store
179 .handler
180 .set_gecko_prefs_original_values(qualified_values);
181 }
182 }
183}
184
185#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
189pub struct ExperimentEnrollment {
190 pub slug: String,
191 pub status: EnrollmentStatus,
192}
193
194impl ExperimentEnrollment {
195 fn from_new_experiment(
198 is_user_participating: bool,
199 available_randomization_units: &AvailableRandomizationUnits,
200 experiment: &Experiment,
201 targeting_helper: &NimbusTargetingHelper,
202 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
203 ) -> Result<Self> {
204 Ok(if !is_user_participating {
205 Self {
206 slug: experiment.slug.clone(),
207 status: EnrollmentStatus::NotEnrolled {
208 reason: NotEnrolledReason::OptOut,
209 },
210 }
211 } else if experiment.is_enrollment_paused {
212 Self {
213 slug: experiment.slug.clone(),
214 status: EnrollmentStatus::NotEnrolled {
215 reason: NotEnrolledReason::EnrollmentsPaused,
216 },
217 }
218 } else {
219 let enrollment =
220 evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
221 debug!(
222 "Evaluating experiment slug: {:?} with targeting string: {:?}",
223 experiment.slug, experiment.targeting
224 );
225 debug!(
226 "Experiment '{}' is new - enrollment status is {:?}",
227 &enrollment.slug, &enrollment
228 );
229 if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
230 out_enrollment_events.push(enrollment.get_change_event())
231 }
232 enrollment
233 })
234 }
235
236 #[cfg_attr(not(feature = "stateful"), allow(unused))]
238 pub(crate) fn from_explicit_opt_in(
239 experiment: &Experiment,
240 branch_slug: &str,
241 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
242 ) -> Result<Self> {
243 if !experiment.has_branch(branch_slug) {
244 out_enrollment_events.push(EnrollmentChangeEvent {
245 experiment_slug: experiment.slug.to_string(),
246 branch_slug: branch_slug.to_string(),
247 reason: Some("does-not-exist".to_string()),
248 change: EnrollmentChangeEventType::EnrollFailed,
249 });
250
251 return Err(NimbusError::NoSuchBranch(
252 branch_slug.to_owned(),
253 experiment.slug.clone(),
254 ));
255 }
256 let enrollment = Self {
257 slug: experiment.slug.clone(),
258 status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
259 };
260 out_enrollment_events.push(enrollment.get_change_event());
261 Ok(enrollment)
262 }
263
264 #[allow(clippy::too_many_arguments)]
266 fn on_experiment_updated(
267 &self,
268 is_user_participating: bool,
269 available_randomization_units: &AvailableRandomizationUnits,
270 updated_experiment: &Experiment,
271 targeting_helper: &NimbusTargetingHelper,
272 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
273 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
274 ) -> Result<Self> {
275 Ok(match &self.status {
276 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
277 if !is_user_participating || updated_experiment.is_enrollment_paused {
278 self.clone()
279 } else {
280 let updated_enrollment = evaluate_enrollment(
281 available_randomization_units,
282 updated_experiment,
283 targeting_helper,
284 )?;
285 debug!(
286 "Experiment '{}' with enrollment {:?} is now {:?}",
287 &self.slug, &self, updated_enrollment
288 );
289 if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
290 out_enrollment_events.push(updated_enrollment.get_change_event());
291 }
292 updated_enrollment
293 }
294 }
295
296 EnrollmentStatus::Enrolled { branch, reason, .. } => {
297 if !is_user_participating {
298 debug!(
299 "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
300 &self.slug
301 );
302 #[cfg(feature = "stateful")]
303 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
304 let updated_enrollment =
305 self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
306 out_enrollment_events.push(updated_enrollment.get_change_event());
307 updated_enrollment
308 } else if !updated_experiment.has_branch(branch) {
309 #[cfg(feature = "stateful")]
311 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
312 let updated_enrollment =
313 self.disqualify_from_enrolled(DisqualifiedReason::Error);
314 out_enrollment_events.push(updated_enrollment.get_change_event());
315 updated_enrollment
316 } else if matches!(reason, EnrolledReason::OptIn) {
317 self.clone()
320 } else {
321 let evaluated_enrollment = evaluate_enrollment(
322 available_randomization_units,
323 updated_experiment,
324 targeting_helper,
325 )?;
326
327 #[cfg(feature = "stateful")]
328 if let EnrollmentStatus::Enrolled {
329 prev_gecko_pref_states: Some(prev_gecko_pref_states),
330 ..
331 } = &self.status
332 && self
333 .will_pref_experiment_change(updated_experiment, &evaluated_enrollment)
334 {
335 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
336 prev_gecko_pref_states,
337 gecko_pref_store,
338 );
339 }
340 match evaluated_enrollment.status {
341 EnrollmentStatus::Error { .. } => {
342 let updated_enrollment =
343 self.disqualify_from_enrolled(DisqualifiedReason::Error);
344 out_enrollment_events.push(updated_enrollment.get_change_event());
345 updated_enrollment
346 }
347 EnrollmentStatus::NotEnrolled {
348 reason: NotEnrolledReason::DifferentAppName,
349 }
350 | EnrollmentStatus::NotEnrolled {
351 reason: NotEnrolledReason::DifferentChannel,
352 }
353 | EnrollmentStatus::NotEnrolled {
354 reason: NotEnrolledReason::NotTargeted,
355 } => {
356 debug!(
357 "Existing experiment enrollment '{}' is now disqualified (targeting change)",
358 &self.slug
359 );
360 let updated_enrollment =
361 self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
362 out_enrollment_events.push(updated_enrollment.get_change_event());
363 updated_enrollment
364 }
365 EnrollmentStatus::NotEnrolled {
366 reason: NotEnrolledReason::NotSelected,
367 } => {
368 let updated_enrollment =
371 self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
372 out_enrollment_events.push(updated_enrollment.get_change_event());
373 updated_enrollment
374 }
375 EnrollmentStatus::NotEnrolled { .. }
376 | EnrollmentStatus::Enrolled { .. }
377 | EnrollmentStatus::Disqualified { .. }
378 | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
379 }
380 }
381 }
382 EnrollmentStatus::Disqualified { branch, reason, .. } => {
383 if !is_user_participating {
384 debug!(
385 "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
386 &self.slug
387 );
388 Self {
389 slug: self.slug.clone(),
390 status: EnrollmentStatus::Disqualified {
391 reason: DisqualifiedReason::OptOut,
392 branch: branch.clone(),
393 },
394 }
395 } else if updated_experiment.is_rollout
396 && matches!(
397 reason,
398 DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
399 )
400 {
401 let evaluated_enrollment = evaluate_enrollment(
402 available_randomization_units,
403 updated_experiment,
404 targeting_helper,
405 )?;
406 match evaluated_enrollment.status {
407 EnrollmentStatus::Enrolled { .. } => evaluated_enrollment,
408 _ => self.clone(),
409 }
410 } else {
411 self.clone()
412 }
413 }
414 EnrollmentStatus::WasEnrolled { .. } => self.clone(),
415 })
416 }
417
418 fn on_experiment_ended(
424 &self,
425 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
426 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
427 ) -> Option<Self> {
428 debug!(
429 "Experiment '{}' vanished while we had enrollment status of {:?}",
430 self.slug, self
431 );
432 let branch = match self.status {
433 EnrollmentStatus::Enrolled { ref branch, .. }
434 | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
435 EnrollmentStatus::NotEnrolled { .. }
436 | EnrollmentStatus::WasEnrolled { .. }
437 | EnrollmentStatus::Error { .. } => return None, };
439 #[cfg(feature = "stateful")]
440 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
441
442 let enrollment = Self {
443 slug: self.slug.clone(),
444 status: EnrollmentStatus::WasEnrolled {
445 branch: branch.to_owned(),
446 experiment_ended_at: now_secs(),
447 },
448 };
449 out_enrollment_events.push(enrollment.get_change_event());
450 Some(enrollment)
451 }
452
453 #[allow(clippy::unnecessary_wraps)]
455 #[cfg_attr(not(feature = "stateful"), allow(unused))]
456 pub(crate) fn on_explicit_opt_out(
457 &self,
458 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
459 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
460 ) -> ExperimentEnrollment {
461 match self.status {
462 EnrollmentStatus::Enrolled { .. } => {
463 #[cfg(feature = "stateful")]
464 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
465
466 let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
467 out_enrollment_events.push(enrollment.get_change_event());
468 enrollment
469 }
470 EnrollmentStatus::NotEnrolled { .. } => Self {
471 slug: self.slug.to_string(),
472 status: EnrollmentStatus::NotEnrolled {
473 reason: NotEnrolledReason::OptOut, },
475 },
476 EnrollmentStatus::Disqualified { .. }
477 | EnrollmentStatus::WasEnrolled { .. }
478 | EnrollmentStatus::Error { .. } => {
479 self.clone()
481 }
482 }
483 }
484
485 #[cfg(feature = "stateful")]
486 pub(crate) fn on_pref_unenroll(
487 &self,
488 pref_unenroll_reason: PrefUnenrollReason,
489 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
490 ) -> ExperimentEnrollment {
491 match self.status {
492 EnrollmentStatus::Enrolled { .. } => {
493 let enrollment =
494 self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
495 reason: pref_unenroll_reason,
496 });
497 out_enrollment_events.push(enrollment.get_change_event());
498 enrollment
499 }
500 _ => self.clone(),
501 }
502 }
503
504 #[cfg(feature = "stateful")]
506 pub(crate) fn on_add_gecko_pref_states(
507 &self,
508 prev_gecko_pref_states: Vec<PreviousGeckoPrefState>,
509 ) -> ExperimentEnrollment {
510 let mut next = self.clone();
511 if let EnrollmentStatus::Enrolled { reason, branch, .. } = &self.status {
512 next.status = EnrollmentStatus::Enrolled {
513 prev_gecko_pref_states: Some(prev_gecko_pref_states),
514 reason: reason.clone(),
515 branch: branch.clone(),
516 };
517 }
518 next
519 }
520
521 #[cfg(feature = "stateful")]
522 pub(crate) fn maybe_revert_unchanged_gecko_pref_states(
524 &self,
525 non_reverting_pref_name: &str,
526 gecko_pref_store: Option<&GeckoPrefStore>,
527 ) {
528 if let EnrollmentStatus::Enrolled {
529 prev_gecko_pref_states: Some(prev_gecko_pref_states),
530 ..
531 } = &self.status
532 {
533 PreviousGeckoPrefState::on_partially_revert_to_prev_gecko_pref_states(
534 prev_gecko_pref_states,
535 non_reverting_pref_name,
536 gecko_pref_store,
537 );
538 }
539 }
540
541 #[cfg(feature = "stateful")]
542 pub(crate) fn maybe_revert_all_gecko_pref_states(
544 &self,
545 gecko_pref_store: Option<&GeckoPrefStore>,
546 ) {
547 if let EnrollmentStatus::Enrolled {
548 prev_gecko_pref_states: Some(prev_gecko_pref_states),
549 ..
550 } = &self.status
551 {
552 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
553 prev_gecko_pref_states,
554 gecko_pref_store,
555 );
556 }
557 }
558
559 #[cfg_attr(not(feature = "stateful"), allow(unused))]
565 pub fn reset_telemetry_identifiers(
566 &self,
567 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
568 ) -> Self {
569 let updated = match self.status {
570 EnrollmentStatus::Enrolled { .. } => {
571 let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
572 out_enrollment_events.push(disqualified.get_change_event());
573 disqualified
574 }
575 EnrollmentStatus::NotEnrolled { .. }
576 | EnrollmentStatus::Disqualified { .. }
577 | EnrollmentStatus::WasEnrolled { .. }
578 | EnrollmentStatus::Error { .. } => self.clone(),
579 };
580 ExperimentEnrollment {
581 status: updated.status.clone(),
582 ..updated
583 }
584 }
585
586 fn maybe_garbage_collect(&self) -> Option<Self> {
589 if let EnrollmentStatus::WasEnrolled {
590 experiment_ended_at,
591 ..
592 } = self.status
593 {
594 let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
595 if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
596 return Some(self.clone());
597 }
598 }
599 debug!("Garbage collecting enrollment '{}'", self.slug);
600 None
601 }
602
603 fn get_change_event(&self) -> EnrollmentChangeEvent {
606 match &self.status {
607 EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
608 &self.slug,
609 branch,
610 None,
611 EnrollmentChangeEventType::Enrollment,
612 ),
613 EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
614 &self.slug,
615 branch,
616 None,
617 EnrollmentChangeEventType::Unenrollment,
618 ),
619 EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
620 &self.slug,
621 branch,
622 match reason {
623 DisqualifiedReason::NotSelected => Some("bucketing"),
624 DisqualifiedReason::NotTargeted => Some("targeting"),
625 DisqualifiedReason::OptOut => Some("optout"),
626 DisqualifiedReason::Error => Some("error"),
627 #[cfg(feature = "stateful")]
628 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
629 PrefUnenrollReason::Changed => Some("pref_changed"),
630 PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"),
631 },
632 },
633 EnrollmentChangeEventType::Disqualification,
634 ),
635 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
636 unreachable!()
637 }
638 }
639 }
640
641 fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
643 match self.status {
644 EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
645 status: EnrollmentStatus::Disqualified {
646 reason,
647 branch: branch.to_owned(),
648 },
649 ..self.clone()
650 },
651 EnrollmentStatus::NotEnrolled { .. }
652 | EnrollmentStatus::Disqualified { .. }
653 | EnrollmentStatus::WasEnrolled { .. }
654 | EnrollmentStatus::Error { .. } => self.clone(),
655 }
656 }
657
658 #[cfg(feature = "stateful")]
659 pub(crate) fn will_pref_experiment_change(
660 &self,
661 updated_experiment: &Experiment,
662 updated_enrollment: &ExperimentEnrollment,
663 ) -> bool {
664 let EnrollmentStatus::Enrolled {
665 prev_gecko_pref_states: Some(original_prev_gecko_pref_states),
666 branch: original_branch_slug,
667 ..
668 } = &self.status
669 else {
670 return false;
672 };
673
674 let EnrollmentStatus::Enrolled {
675 branch: updated_branch_slug,
676 ..
677 } = &updated_enrollment.status
678 else {
679 return true;
681 };
682
683 if updated_branch_slug != original_branch_slug {
685 return true;
686 }
687
688 let Some(updated_branch) = updated_experiment.get_branch(updated_branch_slug) else {
690 return true;
691 };
692
693 let original_feature_ids: HashSet<&String> = original_prev_gecko_pref_states
694 .iter()
695 .map(|state| &state.feature_id)
696 .collect();
697 let updated_features = updated_branch.get_feature_configs();
698
699 if updated_features.len() != original_feature_ids.len() {
701 return true;
702 }
703
704 for original_state in original_prev_gecko_pref_states {
705 let Some(updated_feature) = updated_features
706 .iter()
707 .find(|config| config.feature_id == original_state.feature_id)
708 else {
709 return true;
711 };
712
713 if !updated_feature.value.contains_key(&original_state.variable) {
715 return true;
716 }
717 }
718 false
719 }
720}
721
722#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
725pub enum EnrollmentStatus {
726 Enrolled {
727 reason: EnrolledReason,
728 branch: String,
729 #[cfg(feature = "stateful")]
730 #[serde(skip_serializing_if = "Option::is_none")]
731 prev_gecko_pref_states: Option<Vec<PreviousGeckoPrefState>>,
732 },
733 NotEnrolled {
734 reason: NotEnrolledReason,
735 },
736 Disqualified {
737 reason: DisqualifiedReason,
738 branch: String,
739 },
740 WasEnrolled {
741 branch: String,
742 experiment_ended_at: u64, },
744 Error {
746 reason: String,
749 },
750}
751
752impl EnrollmentStatus {
753 pub fn name(&self) -> String {
754 match self {
755 EnrollmentStatus::Enrolled { .. } => "Enrolled",
756 EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
757 EnrollmentStatus::Disqualified { .. } => "Disqualified",
758 EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
759 EnrollmentStatus::Error { .. } => "Error",
760 }
761 .into()
762 }
763}
764
765impl EnrollmentStatus {
766 pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
769 EnrollmentStatus::Enrolled {
770 reason,
771 branch: branch.to_owned(),
772 #[cfg(feature = "stateful")]
773 prev_gecko_pref_states: None,
774 }
775 }
776
777 pub fn is_enrolled(&self) -> bool {
780 matches!(self, EnrollmentStatus::Enrolled { .. })
781 }
782}
783
784pub(crate) trait ExperimentMetadata {
785 fn get_slug(&self) -> String;
786
787 fn is_rollout(&self) -> bool;
788}
789
790pub(crate) struct EnrollmentsEvolver<'a> {
791 available_randomization_units: &'a AvailableRandomizationUnits,
792 targeting_helper: &'a mut NimbusTargetingHelper,
793 coenrolling_feature_ids: &'a HashSet<&'a str>,
794}
795
796impl<'a> EnrollmentsEvolver<'a> {
797 pub(crate) fn new(
798 available_randomization_units: &'a AvailableRandomizationUnits,
799 targeting_helper: &'a mut NimbusTargetingHelper,
800 coenrolling_feature_ids: &'a HashSet<&str>,
801 ) -> Self {
802 Self {
803 available_randomization_units,
804 targeting_helper,
805 coenrolling_feature_ids,
806 }
807 }
808
809 pub(crate) fn evolve_enrollments<E>(
810 &mut self,
811 participation: Participation,
812 prev_experiments: &[E],
813 next_experiments: &[Experiment],
814 prev_enrollments: &[ExperimentEnrollment],
815 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
816 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
817 where
818 E: ExperimentMetadata + Clone,
819 {
820 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
821 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
822
823 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
826 prev_experiments,
827 prev_enrollments,
828 ExperimentMetadata::is_rollout,
829 );
830 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
831
832 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
833 participation.in_rollouts,
834 &prev_rollouts,
835 &next_rollouts,
836 &ro_enrollments,
837 #[cfg(feature = "stateful")]
838 gecko_pref_store,
839 )?;
840
841 enrollments.extend(next_ro_enrollments);
842 events.extend(ro_events);
843
844 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
845
846 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
851 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
852 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
853 .iter()
854 .filter(|e| !ro_slugs.contains(&e.slug))
855 .map(|e| e.to_owned())
856 .collect();
857
858 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
859 participation.in_experiments,
860 &prev_experiments,
861 &next_experiments,
862 &prev_enrollments,
863 #[cfg(feature = "stateful")]
864 gecko_pref_store,
865 )?;
866
867 enrollments.extend(next_exp_enrollments);
868 events.extend(exp_events);
869
870 Ok((enrollments, events))
871 }
872
873 pub(crate) fn evolve_enrollment_recipes<E>(
876 &mut self,
877 is_user_participating: bool,
878 prev_experiments: &[E],
879 next_experiments: &[Experiment],
880 prev_enrollments: &[ExperimentEnrollment],
881 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
882 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
883 where
884 E: ExperimentMetadata + Clone,
885 {
886 let mut enrollment_events = vec![];
887 let prev_experiments_map = map_experiments(prev_experiments);
888 let next_experiments_map = map_experiments(next_experiments);
889 let prev_enrollments_map = map_enrollments(prev_enrollments);
890
891 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
894 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
895
896 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
897
898 for prev_enrollment in prev_enrollments {
905 if matches!(
906 prev_enrollment.status,
907 EnrollmentStatus::NotEnrolled {
908 reason: NotEnrolledReason::FeatureConflict
909 }
910 ) {
911 continue;
912 }
913 let slug = &prev_enrollment.slug;
914
915 let next_enrollment = match self.evolve_enrollment(
916 is_user_participating,
917 prev_experiments_map.get(slug).copied(),
918 next_experiments_map.get(slug).copied(),
919 Some(prev_enrollment),
920 &mut enrollment_events,
921 #[cfg(feature = "stateful")]
922 gecko_pref_store,
923 ) {
924 Ok(enrollment) => enrollment,
925 Err(e) => {
926 warn!(
932 "{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ",
933 e, slug, prev_enrollment
934 );
935 None
936 }
937 };
938
939 #[cfg(feature = "stateful")]
940 if let Some(ref enrollment) = next_enrollment.clone() {
941 if self.targeting_helper.update_enrollment(enrollment) {
942 debug!("Enrollment updated for {}", enrollment.slug);
943 } else {
944 debug!("Enrollment unchanged for {}", enrollment.slug);
945 }
946 }
947
948 self.reserve_enrolled_features(
949 next_enrollment,
950 &next_experiments_map,
951 &mut enrolled_features,
952 &mut coenrolling_features,
953 &mut next_enrollments,
954 );
955 }
956
957 let next_experiments = sort_experiments_by_published_date(next_experiments);
960 for next_experiment in next_experiments {
961 let slug = &next_experiment.slug;
962
963 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
969 .get_feature_ids()
970 .iter()
971 .filter_map(|id| enrolled_features.get(id))
972 .collect();
973 if !needed_features_in_use.is_empty() {
974 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
975 if is_our_experiment {
976 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
980 } else {
983 next_enrollments.push(ExperimentEnrollment {
986 slug: slug.clone(),
987 status: EnrollmentStatus::NotEnrolled {
988 reason: NotEnrolledReason::FeatureConflict,
989 },
990 });
991
992 enrollment_events.push(EnrollmentChangeEvent {
993 experiment_slug: slug.clone(),
994 branch_slug: "N/A".to_string(),
995 reason: Some("feature-conflict".to_string()),
996 change: EnrollmentChangeEventType::EnrollFailed,
997 })
998 }
999 continue;
1005 }
1006
1007 let prev_enrollment = prev_enrollments_map.get(slug).copied();
1012
1013 if prev_enrollment.is_none()
1014 || matches!(
1015 prev_enrollment.unwrap().status,
1016 EnrollmentStatus::NotEnrolled {
1017 reason: NotEnrolledReason::FeatureConflict
1018 }
1019 )
1020 {
1021 let next_enrollment = match self.evolve_enrollment(
1022 is_user_participating,
1023 prev_experiments_map.get(slug).copied(),
1024 Some(next_experiment),
1025 prev_enrollment,
1026 &mut enrollment_events,
1027 #[cfg(feature = "stateful")]
1028 gecko_pref_store,
1029 ) {
1030 Ok(enrollment) => enrollment,
1031 Err(e) => {
1032 warn!(
1038 "{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ",
1039 e, slug, prev_enrollment
1040 );
1041 None
1042 }
1043 };
1044
1045 #[cfg(feature = "stateful")]
1046 if let Some(ref enrollment) = next_enrollment.clone() {
1047 if self.targeting_helper.update_enrollment(enrollment) {
1048 debug!("Enrollment updated for {}", enrollment.slug);
1049 } else {
1050 debug!("Enrollment unchanged for {}", enrollment.slug);
1051 }
1052 }
1053
1054 self.reserve_enrolled_features(
1055 next_enrollment,
1056 &next_experiments_map,
1057 &mut enrolled_features,
1058 &mut coenrolling_features,
1059 &mut next_enrollments,
1060 );
1061 }
1062 }
1063
1064 enrolled_features.extend(coenrolling_features);
1065
1066 let updated_enrolled_features = map_features(
1070 &next_enrollments,
1071 &next_experiments_map,
1072 self.coenrolling_feature_ids,
1073 );
1074 if enrolled_features != updated_enrolled_features {
1075 Err(NimbusError::InternalError(
1076 "Next enrollment calculation error",
1077 ))
1078 } else {
1079 Ok((next_enrollments, enrollment_events))
1080 }
1081 }
1082
1083 fn reserve_enrolled_features(
1085 &self,
1086 latest_enrollment: Option<ExperimentEnrollment>,
1087 experiments: &HashMap<String, &Experiment>,
1088 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
1089 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1090 enrollments: &mut Vec<ExperimentEnrollment>,
1091 ) {
1092 if let Some(enrollment) = latest_enrollment {
1093 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
1097 populate_feature_maps(
1098 enrolled_feature,
1099 self.coenrolling_feature_ids,
1100 enrolled_features,
1101 coenrolling_features,
1102 );
1103 }
1104 enrollments.push(enrollment);
1106 }
1107 }
1108
1109 pub(crate) fn evolve_enrollment<E>(
1119 &mut self,
1120 is_user_participating: bool,
1121 prev_experiment: Option<&E>,
1122 next_experiment: Option<&Experiment>,
1123 prev_enrollment: Option<&ExperimentEnrollment>,
1124 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1126 ) -> Result<Option<ExperimentEnrollment>>
1127 where
1128 E: ExperimentMetadata + Clone,
1129 {
1130 let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
1131 enrollment.status.is_enrolled()
1132 } else {
1133 false
1134 };
1135
1136 let targeting_helper = self
1141 .targeting_helper
1142 .put("is_already_enrolled", is_already_enrolled);
1143
1144 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
1145 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
1147 is_user_participating,
1148 self.available_randomization_units,
1149 experiment,
1150 &targeting_helper,
1151 out_enrollment_events,
1152 )?),
1153 (Some(_), None, Some(enrollment)) => enrollment.on_experiment_ended(
1155 #[cfg(feature = "stateful")]
1156 gecko_pref_store,
1157 out_enrollment_events,
1158 ),
1159 (Some(_), Some(experiment), Some(enrollment)) => {
1161 Some(enrollment.on_experiment_updated(
1162 is_user_participating,
1163 self.available_randomization_units,
1164 experiment,
1165 &targeting_helper,
1166 #[cfg(feature = "stateful")]
1167 gecko_pref_store,
1168 out_enrollment_events,
1169 )?)
1170 }
1171 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
1172 (None, Some(_), Some(_)) => {
1173 return Err(NimbusError::InternalError(
1174 "New experiment but enrollment already exists.",
1175 ));
1176 }
1177 (Some(_), None, None) | (Some(_), Some(_), None) => {
1178 return Err(NimbusError::InternalError(
1179 "Experiment in the db did not have an associated enrollment record.",
1180 ));
1181 }
1182 (None, None, None) => {
1183 return Err(NimbusError::InternalError(
1184 "evolve_experiment called with nothing that could evolve or be evolved",
1185 ));
1186 }
1187 })
1188 }
1189}
1190
1191fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
1192where
1193 E: ExperimentMetadata + Clone,
1194{
1195 let mut map_experiments = HashMap::with_capacity(experiments.len());
1196 for e in experiments {
1197 map_experiments.insert(e.get_slug(), e);
1198 }
1199 map_experiments
1200}
1201
1202pub fn map_enrollments(
1203 enrollments: &[ExperimentEnrollment],
1204) -> HashMap<String, &ExperimentEnrollment> {
1205 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
1206 for e in enrollments {
1207 map_enrollments.insert(e.slug.clone(), e);
1208 }
1209 map_enrollments
1210}
1211
1212pub(crate) fn filter_experiments_and_enrollments<E>(
1213 experiments: &[E],
1214 enrollments: &[ExperimentEnrollment],
1215 filter_fn: fn(&E) -> bool,
1216) -> (Vec<E>, Vec<ExperimentEnrollment>)
1217where
1218 E: ExperimentMetadata + Clone,
1219{
1220 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
1221
1222 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
1223
1224 let enrollments: Vec<ExperimentEnrollment> = enrollments
1225 .iter()
1226 .filter(|e| slugs.contains(&e.slug))
1227 .map(|e| e.to_owned())
1228 .collect();
1229
1230 (experiments, enrollments)
1231}
1232
1233fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1234where
1235 E: ExperimentMetadata + Clone,
1236{
1237 experiments
1238 .iter()
1239 .filter(|e| filter_fn(e))
1240 .cloned()
1241 .collect()
1242}
1243
1244pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1245 let mut experiments: Vec<_> = experiments.iter().collect();
1246 experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
1247 experiments
1248}
1249
1250fn map_features(
1253 enrollments: &[ExperimentEnrollment],
1254 experiments: &HashMap<String, &Experiment>,
1255 coenrolling_ids: &HashSet<&str>,
1256) -> HashMap<String, EnrolledFeatureConfig> {
1257 let mut colliding_features = HashMap::with_capacity(enrollments.len());
1258 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1259 for enrolled_feature_config in enrollments
1260 .iter()
1261 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1262 {
1263 populate_feature_maps(
1264 enrolled_feature_config,
1265 coenrolling_ids,
1266 &mut colliding_features,
1267 &mut coenrolling_features,
1268 );
1269 }
1270 colliding_features.extend(coenrolling_features.drain());
1271
1272 colliding_features
1273}
1274
1275pub fn map_features_by_feature_id(
1276 enrollments: &[ExperimentEnrollment],
1277 experiments: &[Experiment],
1278 coenrolling_ids: &HashSet<&str>,
1279) -> HashMap<String, EnrolledFeatureConfig> {
1280 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1281 experiments,
1282 enrollments,
1283 ExperimentMetadata::is_rollout,
1284 );
1285 let (experiments, exp_enrollments) =
1286 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1287
1288 let features_under_rollout = map_features(
1289 &ro_enrollments,
1290 &map_experiments(&rollouts),
1291 coenrolling_ids,
1292 );
1293 let features_under_experiment = map_features(
1294 &exp_enrollments,
1295 &map_experiments(&experiments),
1296 coenrolling_ids,
1297 );
1298
1299 features_under_experiment
1300 .defaults(&features_under_rollout)
1301 .unwrap()
1302}
1303
1304pub(crate) fn populate_feature_maps(
1305 enrolled_feature: EnrolledFeatureConfig,
1306 coenrolling_feature_ids: &HashSet<&str>,
1307 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1308 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1309) {
1310 let feature_id = &enrolled_feature.feature_id;
1311 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1312 colliding_features.insert(feature_id.clone(), enrolled_feature);
1315 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1316 let merged = enrolled_feature
1320 .defaults(existing)
1321 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1322
1323 let merged = EnrolledFeatureConfig {
1326 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1328 branch: None,
1329 ..merged
1330 };
1331 coenrolling_features.insert(feature_id.clone(), merged);
1332 } else {
1333 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1335 }
1336}
1337
1338fn get_enrolled_feature_configs(
1339 enrollment: &ExperimentEnrollment,
1340 experiments: &HashMap<String, &Experiment>,
1341) -> Vec<EnrolledFeatureConfig> {
1342 let branch_slug = match &enrollment.status {
1344 EnrollmentStatus::Enrolled { branch, .. } => branch,
1345 _ => return Vec::new(),
1346 };
1347
1348 let experiment_slug = &enrollment.slug;
1349
1350 let experiment = match experiments.get(experiment_slug).copied() {
1351 Some(exp) => exp,
1352 _ => return Vec::new(),
1353 };
1354
1355 let mut branch_features = match &experiment.get_branch(branch_slug) {
1358 Some(branch) => branch.get_feature_configs(),
1359 _ => Default::default(),
1360 };
1361
1362 branch_features.iter_mut().for_each(|f| {
1363 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1364 });
1365
1366 let branch_feature_ids = &branch_features
1367 .iter()
1368 .map(|f| &f.feature_id)
1369 .collect::<HashSet<_>>();
1370
1371 let non_branch_features: Vec<FeatureConfig> = experiment
1375 .get_feature_ids()
1376 .into_iter()
1377 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1378 .map(|feature_id| FeatureConfig {
1379 feature_id,
1380 ..Default::default()
1381 })
1382 .collect();
1383
1384 branch_features
1387 .iter()
1388 .chain(non_branch_features.iter())
1389 .map(|f| EnrolledFeatureConfig {
1390 feature: f.to_owned(),
1391 slug: experiment_slug.clone(),
1392 branch: if !experiment.is_rollout() {
1393 Some(branch_slug.clone())
1394 } else {
1395 None
1396 },
1397 feature_id: f.feature_id.clone(),
1398 })
1399 .collect()
1400}
1401
1402#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1406#[serde(rename_all = "camelCase")]
1407pub struct EnrolledFeatureConfig {
1408 pub feature: FeatureConfig,
1409 pub slug: String,
1410 pub branch: Option<String>,
1411 pub feature_id: String,
1412}
1413
1414impl Defaults for EnrolledFeatureConfig {
1415 fn defaults(&self, fallback: &Self) -> Result<Self> {
1416 if self.feature_id != fallback.feature_id {
1417 Err(NimbusError::InternalError(
1419 "Cannot merge enrolled feature configs from different features",
1420 ))
1421 } else {
1422 Ok(Self {
1423 slug: self.slug.to_owned(),
1424 feature_id: self.feature_id.to_owned(),
1425 feature: self.feature.defaults(&fallback.feature)?,
1427 branch: self.branch.to_owned(),
1431 })
1432 }
1433 }
1434}
1435
1436impl ExperimentMetadata for EnrolledFeatureConfig {
1437 fn get_slug(&self) -> String {
1438 self.slug.clone()
1439 }
1440
1441 fn is_rollout(&self) -> bool {
1442 self.branch.is_none()
1443 }
1444}
1445
1446#[derive(Debug, Clone, PartialEq, Eq)]
1447pub struct EnrolledFeature {
1448 pub slug: String,
1449 pub branch: Option<String>,
1450 pub feature_id: String,
1451}
1452
1453impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1454 fn from(value: &EnrolledFeatureConfig) -> Self {
1455 Self {
1456 slug: value.slug.clone(),
1457 branch: value.branch.clone(),
1458 feature_id: value.feature_id.clone(),
1459 }
1460 }
1461}
1462
1463#[derive(Serialize, Deserialize, Debug, Clone)]
1464pub struct EnrollmentChangeEvent {
1465 pub experiment_slug: String,
1466 pub branch_slug: String,
1467 pub reason: Option<String>,
1468 pub change: EnrollmentChangeEventType,
1469}
1470
1471impl EnrollmentChangeEvent {
1472 pub(crate) fn new(
1473 slug: &str,
1474 branch: &str,
1475 reason: Option<&str>,
1476 change: EnrollmentChangeEventType,
1477 ) -> Self {
1478 Self {
1479 experiment_slug: slug.to_owned(),
1480 branch_slug: branch.to_owned(),
1481 reason: reason.map(|s| s.to_owned()),
1482 change,
1483 }
1484 }
1485}
1486
1487#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1488pub enum EnrollmentChangeEventType {
1489 Enrollment,
1490 EnrollFailed,
1491 Disqualification,
1492 Unenrollment,
1493 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1494 UnenrollFailed,
1495}
1496
1497pub(crate) fn now_secs() -> u64 {
1498 SystemTime::now()
1499 .duration_since(UNIX_EPOCH)
1500 .expect("Current date before Unix Epoch.")
1501 .as_secs()
1502}