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(
810 &mut self,
811 participation: Participation,
812 prev_experiments: &[Experiment],
813 next_experiments: &[Experiment],
814 prev_enrollments: &[ExperimentEnrollment],
815 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
816 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
817 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
818 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
819
820 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
823 prev_experiments,
824 prev_enrollments,
825 ExperimentMetadata::is_rollout,
826 );
827 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
828
829 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
830 participation.in_rollouts,
831 &prev_rollouts,
832 &next_rollouts,
833 &ro_enrollments,
834 #[cfg(feature = "stateful")]
835 gecko_pref_store,
836 )?;
837
838 enrollments.extend(next_ro_enrollments);
839 events.extend(ro_events);
840
841 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
842
843 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
848 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
849 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
850 .iter()
851 .filter(|e| !ro_slugs.contains(&e.slug))
852 .map(|e| e.to_owned())
853 .collect();
854
855 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
856 participation.in_experiments,
857 &prev_experiments,
858 &next_experiments,
859 &prev_enrollments,
860 #[cfg(feature = "stateful")]
861 gecko_pref_store,
862 )?;
863
864 enrollments.extend(next_exp_enrollments);
865 events.extend(exp_events);
866
867 Ok((enrollments, events))
868 }
869
870 pub(crate) fn evolve_enrollment_recipes(
873 &mut self,
874 is_user_participating: bool,
875 prev_experiments: &[Experiment],
876 next_experiments: &[Experiment],
877 prev_enrollments: &[ExperimentEnrollment],
878 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
879 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
880 let mut enrollment_events = vec![];
881 let prev_experiments_map = map_experiments(prev_experiments);
882 let next_experiments_map = map_experiments(next_experiments);
883 let prev_enrollments_map = map_enrollments(prev_enrollments);
884
885 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
888 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
889
890 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
891
892 for prev_enrollment in prev_enrollments {
899 if matches!(
900 prev_enrollment.status,
901 EnrollmentStatus::NotEnrolled {
902 reason: NotEnrolledReason::FeatureConflict
903 }
904 ) {
905 continue;
906 }
907 let slug = &prev_enrollment.slug;
908
909 let next_enrollment = match self.evolve_enrollment(
910 is_user_participating,
911 prev_experiments_map.get(slug).copied(),
912 next_experiments_map.get(slug).copied(),
913 Some(prev_enrollment),
914 &mut enrollment_events,
915 #[cfg(feature = "stateful")]
916 gecko_pref_store,
917 ) {
918 Ok(enrollment) => enrollment,
919 Err(e) => {
920 warn!(
926 "{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ",
927 e, slug, prev_enrollment
928 );
929 None
930 }
931 };
932
933 #[cfg(feature = "stateful")]
934 if let Some(ref enrollment) = next_enrollment.clone() {
935 if self.targeting_helper.update_enrollment(enrollment) {
936 debug!("Enrollment updated for {}", enrollment.slug);
937 } else {
938 debug!("Enrollment unchanged for {}", enrollment.slug);
939 }
940 }
941
942 self.reserve_enrolled_features(
943 next_enrollment,
944 &next_experiments_map,
945 &mut enrolled_features,
946 &mut coenrolling_features,
947 &mut next_enrollments,
948 );
949 }
950
951 let next_experiments = sort_experiments_by_published_date(next_experiments);
954 for next_experiment in next_experiments {
955 let slug = &next_experiment.slug;
956
957 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
963 .get_feature_ids()
964 .iter()
965 .filter_map(|id| enrolled_features.get(id))
966 .collect();
967 if !needed_features_in_use.is_empty() {
968 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
969 if is_our_experiment {
970 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
974 } else {
977 next_enrollments.push(ExperimentEnrollment {
980 slug: slug.clone(),
981 status: EnrollmentStatus::NotEnrolled {
982 reason: NotEnrolledReason::FeatureConflict,
983 },
984 });
985
986 enrollment_events.push(EnrollmentChangeEvent {
987 experiment_slug: slug.clone(),
988 branch_slug: "N/A".to_string(),
989 reason: Some("feature-conflict".to_string()),
990 change: EnrollmentChangeEventType::EnrollFailed,
991 })
992 }
993 continue;
999 }
1000
1001 let prev_enrollment = prev_enrollments_map.get(slug).copied();
1006
1007 if prev_enrollment.is_none()
1008 || matches!(
1009 prev_enrollment.unwrap().status,
1010 EnrollmentStatus::NotEnrolled {
1011 reason: NotEnrolledReason::FeatureConflict
1012 }
1013 )
1014 {
1015 let next_enrollment = match self.evolve_enrollment(
1016 is_user_participating,
1017 prev_experiments_map.get(slug).copied(),
1018 Some(next_experiment),
1019 prev_enrollment,
1020 &mut enrollment_events,
1021 #[cfg(feature = "stateful")]
1022 gecko_pref_store,
1023 ) {
1024 Ok(enrollment) => enrollment,
1025 Err(e) => {
1026 warn!(
1032 "{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ",
1033 e, slug, prev_enrollment
1034 );
1035 None
1036 }
1037 };
1038
1039 #[cfg(feature = "stateful")]
1040 if let Some(ref enrollment) = next_enrollment.clone() {
1041 if self.targeting_helper.update_enrollment(enrollment) {
1042 debug!("Enrollment updated for {}", enrollment.slug);
1043 } else {
1044 debug!("Enrollment unchanged for {}", enrollment.slug);
1045 }
1046 }
1047
1048 self.reserve_enrolled_features(
1049 next_enrollment,
1050 &next_experiments_map,
1051 &mut enrolled_features,
1052 &mut coenrolling_features,
1053 &mut next_enrollments,
1054 );
1055 }
1056 }
1057
1058 enrolled_features.extend(coenrolling_features);
1059
1060 let updated_enrolled_features = map_features(
1064 &next_enrollments,
1065 &next_experiments_map,
1066 self.coenrolling_feature_ids,
1067 );
1068 if enrolled_features != updated_enrolled_features {
1069 Err(NimbusError::InternalError(
1070 "Next enrollment calculation error",
1071 ))
1072 } else {
1073 Ok((next_enrollments, enrollment_events))
1074 }
1075 }
1076
1077 fn reserve_enrolled_features(
1079 &self,
1080 latest_enrollment: Option<ExperimentEnrollment>,
1081 experiments: &HashMap<String, &Experiment>,
1082 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
1083 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1084 enrollments: &mut Vec<ExperimentEnrollment>,
1085 ) {
1086 if let Some(enrollment) = latest_enrollment {
1087 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
1091 populate_feature_maps(
1092 enrolled_feature,
1093 self.coenrolling_feature_ids,
1094 enrolled_features,
1095 coenrolling_features,
1096 );
1097 }
1098 enrollments.push(enrollment);
1100 }
1101 }
1102
1103 pub(crate) fn evolve_enrollment(
1113 &mut self,
1114 is_user_participating: bool,
1115 prev_experiment: Option<&Experiment>,
1116 next_experiment: Option<&Experiment>,
1117 prev_enrollment: Option<&ExperimentEnrollment>,
1118 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1120 ) -> Result<Option<ExperimentEnrollment>> {
1121 let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
1122 enrollment.status.is_enrolled()
1123 } else {
1124 false
1125 };
1126
1127 let targeting_helper = self
1132 .targeting_helper
1133 .put("is_already_enrolled", is_already_enrolled);
1134
1135 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
1136 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
1138 is_user_participating,
1139 self.available_randomization_units,
1140 experiment,
1141 &targeting_helper,
1142 out_enrollment_events,
1143 )?),
1144 (Some(_), None, Some(enrollment)) => enrollment.on_experiment_ended(
1146 #[cfg(feature = "stateful")]
1147 gecko_pref_store,
1148 out_enrollment_events,
1149 ),
1150 (Some(_), Some(experiment), Some(enrollment)) => {
1152 Some(enrollment.on_experiment_updated(
1153 is_user_participating,
1154 self.available_randomization_units,
1155 experiment,
1156 &targeting_helper,
1157 #[cfg(feature = "stateful")]
1158 gecko_pref_store,
1159 out_enrollment_events,
1160 )?)
1161 }
1162 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
1163 (None, Some(_), Some(_)) => {
1164 return Err(NimbusError::InternalError(
1165 "New experiment but enrollment already exists.",
1166 ));
1167 }
1168 (Some(_), None, None) | (Some(_), Some(_), None) => {
1169 return Err(NimbusError::InternalError(
1170 "Experiment in the db did not have an associated enrollment record.",
1171 ));
1172 }
1173 (None, None, None) => {
1174 return Err(NimbusError::InternalError(
1175 "evolve_experiment called with nothing that could evolve or be evolved",
1176 ));
1177 }
1178 })
1179 }
1180}
1181
1182fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
1183where
1184 E: ExperimentMetadata + Clone,
1185{
1186 let mut map_experiments = HashMap::with_capacity(experiments.len());
1187 for e in experiments {
1188 map_experiments.insert(e.get_slug(), e);
1189 }
1190 map_experiments
1191}
1192
1193pub fn map_enrollments(
1194 enrollments: &[ExperimentEnrollment],
1195) -> HashMap<String, &ExperimentEnrollment> {
1196 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
1197 for e in enrollments {
1198 map_enrollments.insert(e.slug.clone(), e);
1199 }
1200 map_enrollments
1201}
1202
1203pub(crate) fn filter_experiments_and_enrollments<E>(
1204 experiments: &[E],
1205 enrollments: &[ExperimentEnrollment],
1206 filter_fn: fn(&E) -> bool,
1207) -> (Vec<E>, Vec<ExperimentEnrollment>)
1208where
1209 E: ExperimentMetadata + Clone,
1210{
1211 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
1212
1213 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
1214
1215 let enrollments: Vec<ExperimentEnrollment> = enrollments
1216 .iter()
1217 .filter(|e| slugs.contains(&e.slug))
1218 .map(|e| e.to_owned())
1219 .collect();
1220
1221 (experiments, enrollments)
1222}
1223
1224fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1225where
1226 E: ExperimentMetadata + Clone,
1227{
1228 experiments
1229 .iter()
1230 .filter(|e| filter_fn(e))
1231 .cloned()
1232 .collect()
1233}
1234
1235pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1236 let mut experiments: Vec<_> = experiments.iter().collect();
1237 experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
1238 experiments
1239}
1240
1241fn map_features(
1244 enrollments: &[ExperimentEnrollment],
1245 experiments: &HashMap<String, &Experiment>,
1246 coenrolling_ids: &HashSet<&str>,
1247) -> HashMap<String, EnrolledFeatureConfig> {
1248 let mut colliding_features = HashMap::with_capacity(enrollments.len());
1249 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1250 for enrolled_feature_config in enrollments
1251 .iter()
1252 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1253 {
1254 populate_feature_maps(
1255 enrolled_feature_config,
1256 coenrolling_ids,
1257 &mut colliding_features,
1258 &mut coenrolling_features,
1259 );
1260 }
1261 colliding_features.extend(coenrolling_features.drain());
1262
1263 colliding_features
1264}
1265
1266pub fn map_features_by_feature_id(
1267 enrollments: &[ExperimentEnrollment],
1268 experiments: &[Experiment],
1269 coenrolling_ids: &HashSet<&str>,
1270) -> HashMap<String, EnrolledFeatureConfig> {
1271 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1272 experiments,
1273 enrollments,
1274 ExperimentMetadata::is_rollout,
1275 );
1276 let (experiments, exp_enrollments) =
1277 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1278
1279 let features_under_rollout = map_features(
1280 &ro_enrollments,
1281 &map_experiments(&rollouts),
1282 coenrolling_ids,
1283 );
1284 let features_under_experiment = map_features(
1285 &exp_enrollments,
1286 &map_experiments(&experiments),
1287 coenrolling_ids,
1288 );
1289
1290 features_under_experiment
1291 .defaults(&features_under_rollout)
1292 .unwrap()
1293}
1294
1295pub(crate) fn populate_feature_maps(
1296 enrolled_feature: EnrolledFeatureConfig,
1297 coenrolling_feature_ids: &HashSet<&str>,
1298 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1299 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1300) {
1301 let feature_id = &enrolled_feature.feature_id;
1302 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1303 colliding_features.insert(feature_id.clone(), enrolled_feature);
1306 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1307 let merged = enrolled_feature
1311 .defaults(existing)
1312 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1313
1314 let merged = EnrolledFeatureConfig {
1317 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1319 branch: None,
1320 ..merged
1321 };
1322 coenrolling_features.insert(feature_id.clone(), merged);
1323 } else {
1324 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1326 }
1327}
1328
1329fn get_enrolled_feature_configs(
1330 enrollment: &ExperimentEnrollment,
1331 experiments: &HashMap<String, &Experiment>,
1332) -> Vec<EnrolledFeatureConfig> {
1333 let branch_slug = match &enrollment.status {
1335 EnrollmentStatus::Enrolled { branch, .. } => branch,
1336 _ => return Vec::new(),
1337 };
1338
1339 let experiment_slug = &enrollment.slug;
1340
1341 let experiment = match experiments.get(experiment_slug).copied() {
1342 Some(exp) => exp,
1343 _ => return Vec::new(),
1344 };
1345
1346 let mut branch_features = match &experiment.get_branch(branch_slug) {
1349 Some(branch) => branch.get_feature_configs(),
1350 _ => Default::default(),
1351 };
1352
1353 branch_features.iter_mut().for_each(|f| {
1354 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1355 });
1356
1357 let branch_feature_ids = &branch_features
1358 .iter()
1359 .map(|f| &f.feature_id)
1360 .collect::<HashSet<_>>();
1361
1362 let non_branch_features: Vec<FeatureConfig> = experiment
1366 .get_feature_ids()
1367 .into_iter()
1368 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1369 .map(|feature_id| FeatureConfig {
1370 feature_id,
1371 ..Default::default()
1372 })
1373 .collect();
1374
1375 branch_features
1378 .iter()
1379 .chain(non_branch_features.iter())
1380 .map(|f| EnrolledFeatureConfig {
1381 feature: f.to_owned(),
1382 slug: experiment_slug.clone(),
1383 branch: if !experiment.is_rollout() {
1384 Some(branch_slug.clone())
1385 } else {
1386 None
1387 },
1388 feature_id: f.feature_id.clone(),
1389 })
1390 .collect()
1391}
1392
1393#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1397#[serde(rename_all = "camelCase")]
1398pub struct EnrolledFeatureConfig {
1399 pub feature: FeatureConfig,
1400 pub slug: String,
1401 pub branch: Option<String>,
1402 pub feature_id: String,
1403}
1404
1405impl Defaults for EnrolledFeatureConfig {
1406 fn defaults(&self, fallback: &Self) -> Result<Self> {
1407 if self.feature_id != fallback.feature_id {
1408 Err(NimbusError::InternalError(
1410 "Cannot merge enrolled feature configs from different features",
1411 ))
1412 } else {
1413 Ok(Self {
1414 slug: self.slug.to_owned(),
1415 feature_id: self.feature_id.to_owned(),
1416 feature: self.feature.defaults(&fallback.feature)?,
1418 branch: self.branch.to_owned(),
1422 })
1423 }
1424 }
1425}
1426
1427impl ExperimentMetadata for EnrolledFeatureConfig {
1428 fn get_slug(&self) -> String {
1429 self.slug.clone()
1430 }
1431
1432 fn is_rollout(&self) -> bool {
1433 self.branch.is_none()
1434 }
1435}
1436
1437#[derive(Debug, Clone, PartialEq, Eq)]
1438pub struct EnrolledFeature {
1439 pub slug: String,
1440 pub branch: Option<String>,
1441 pub feature_id: String,
1442}
1443
1444impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1445 fn from(value: &EnrolledFeatureConfig) -> Self {
1446 Self {
1447 slug: value.slug.clone(),
1448 branch: value.branch.clone(),
1449 feature_id: value.feature_id.clone(),
1450 }
1451 }
1452}
1453
1454#[derive(Serialize, Deserialize, Debug, Clone)]
1455pub struct EnrollmentChangeEvent {
1456 pub experiment_slug: String,
1457 pub branch_slug: String,
1458 pub reason: Option<String>,
1459 pub change: EnrollmentChangeEventType,
1460}
1461
1462impl EnrollmentChangeEvent {
1463 pub(crate) fn new(
1464 slug: &str,
1465 branch: &str,
1466 reason: Option<&str>,
1467 change: EnrollmentChangeEventType,
1468 ) -> Self {
1469 Self {
1470 experiment_slug: slug.to_owned(),
1471 branch_slug: branch.to_owned(),
1472 reason: reason.map(|s| s.to_owned()),
1473 change,
1474 }
1475 }
1476}
1477
1478#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1479pub enum EnrollmentChangeEventType {
1480 Enrollment,
1481 EnrollFailed,
1482 Disqualification,
1483 Unenrollment,
1484 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1485 UnenrollFailed,
1486}
1487
1488pub(crate) fn now_secs() -> u64 {
1489 SystemTime::now()
1490 .duration_since(UNIX_EPOCH)
1491 .expect("Current date before Unix Epoch.")
1492 .as_secs()
1493}