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(Some(experiment)))
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 feature_ids: experiment.get_feature_ids(),
250 });
251
252 return Err(NimbusError::NoSuchBranch(
253 branch_slug.to_owned(),
254 experiment.slug.clone(),
255 ));
256 }
257 let enrollment = Self {
258 slug: experiment.slug.clone(),
259 status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
260 };
261 out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
262 Ok(enrollment)
263 }
264
265 #[allow(clippy::too_many_arguments)]
267 fn on_experiment_updated(
268 &self,
269 is_user_participating: bool,
270 available_randomization_units: &AvailableRandomizationUnits,
271 updated_experiment: &Experiment,
272 targeting_helper: &NimbusTargetingHelper,
273 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
274 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
275 ) -> Result<Self> {
276 Ok(match &self.status {
277 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
278 if !is_user_participating || updated_experiment.is_enrollment_paused {
279 self.clone()
280 } else {
281 let updated_enrollment = evaluate_enrollment(
282 available_randomization_units,
283 updated_experiment,
284 targeting_helper,
285 )?;
286 debug!(
287 "Experiment '{}' with enrollment {:?} is now {:?}",
288 &self.slug, &self, updated_enrollment
289 );
290 if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
291 out_enrollment_events
292 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
293 }
294 updated_enrollment
295 }
296 }
297
298 EnrollmentStatus::Enrolled { branch, reason, .. } => {
299 if !is_user_participating {
300 debug!(
301 "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
302 &self.slug
303 );
304 #[cfg(feature = "stateful")]
305 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
306 let updated_enrollment =
307 self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
308 out_enrollment_events
309 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
310 updated_enrollment
311 } else if !updated_experiment.has_branch(branch) {
312 #[cfg(feature = "stateful")]
314 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
315 let updated_enrollment =
316 self.disqualify_from_enrolled(DisqualifiedReason::Error);
317 out_enrollment_events
318 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
319 updated_enrollment
320 } else if matches!(reason, EnrolledReason::OptIn) {
321 self.clone()
324 } else {
325 let evaluated_enrollment = evaluate_enrollment(
326 available_randomization_units,
327 updated_experiment,
328 targeting_helper,
329 )?;
330
331 #[cfg(feature = "stateful")]
332 if let EnrollmentStatus::Enrolled {
333 prev_gecko_pref_states: Some(prev_gecko_pref_states),
334 ..
335 } = &self.status
336 && self
337 .will_pref_experiment_change(updated_experiment, &evaluated_enrollment)
338 {
339 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
340 prev_gecko_pref_states,
341 gecko_pref_store,
342 );
343 }
344 match evaluated_enrollment.status {
345 EnrollmentStatus::Error { .. } => {
346 let updated_enrollment =
347 self.disqualify_from_enrolled(DisqualifiedReason::Error);
348 out_enrollment_events.push(
349 updated_enrollment.get_change_event(Some(updated_experiment)),
350 );
351 updated_enrollment
352 }
353 EnrollmentStatus::NotEnrolled {
354 reason: NotEnrolledReason::DifferentAppName,
355 }
356 | EnrollmentStatus::NotEnrolled {
357 reason: NotEnrolledReason::DifferentChannel,
358 }
359 | EnrollmentStatus::NotEnrolled {
360 reason: NotEnrolledReason::NotTargeted,
361 } => {
362 debug!(
363 "Existing experiment enrollment '{}' is now disqualified (targeting change)",
364 &self.slug
365 );
366 let updated_enrollment =
367 self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
368 out_enrollment_events.push(
369 updated_enrollment.get_change_event(Some(updated_experiment)),
370 );
371 updated_enrollment
372 }
373 EnrollmentStatus::NotEnrolled {
374 reason: NotEnrolledReason::NotSelected,
375 } => {
376 let updated_enrollment =
379 self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
380 out_enrollment_events.push(
381 updated_enrollment.get_change_event(Some(updated_experiment)),
382 );
383 updated_enrollment
384 }
385 EnrollmentStatus::NotEnrolled { .. }
386 | EnrollmentStatus::Enrolled { .. }
387 | EnrollmentStatus::Disqualified { .. }
388 | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
389 }
390 }
391 }
392 EnrollmentStatus::Disqualified { branch, reason, .. } => {
393 if !is_user_participating {
394 debug!(
395 "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
396 &self.slug
397 );
398 Self {
399 slug: self.slug.clone(),
400 status: EnrollmentStatus::Disqualified {
401 reason: DisqualifiedReason::OptOut,
402 branch: branch.clone(),
403 },
404 }
405 } else if updated_experiment.is_rollout
406 && matches!(
407 reason,
408 DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
409 )
410 {
411 let updated_enrollment = evaluate_enrollment(
412 available_randomization_units,
413 updated_experiment,
414 targeting_helper,
415 )?;
416 match updated_enrollment.status {
417 EnrollmentStatus::Enrolled { .. } => {
418 out_enrollment_events.push(
419 updated_enrollment.get_change_event(Some(updated_experiment)),
420 );
421 updated_enrollment
422 }
423 _ => self.clone(),
424 }
425 } else {
426 self.clone()
427 }
428 }
429 EnrollmentStatus::WasEnrolled { .. } => self.clone(),
430 })
431 }
432
433 fn on_experiment_ended(
439 &self,
440 experiment: &Experiment,
441 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
442 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
443 ) -> Option<Self> {
444 debug!(
445 "Experiment '{}' vanished while we had enrollment status of {:?}",
446 self.slug, self
447 );
448 let branch = match self.status {
449 EnrollmentStatus::Enrolled { ref branch, .. }
450 | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
451 EnrollmentStatus::NotEnrolled { .. }
452 | EnrollmentStatus::WasEnrolled { .. }
453 | EnrollmentStatus::Error { .. } => return None, };
455 #[cfg(feature = "stateful")]
456 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
457
458 let enrollment = Self {
459 slug: self.slug.clone(),
460 status: EnrollmentStatus::WasEnrolled {
461 branch: branch.to_owned(),
462 experiment_ended_at: now_secs(),
463 },
464 };
465 out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
466 Some(enrollment)
467 }
468
469 #[allow(clippy::unnecessary_wraps)]
471 #[cfg_attr(not(feature = "stateful"), allow(unused))]
472 pub(crate) fn on_explicit_opt_out(
473 &self,
474 experiment: Option<&Experiment>,
475 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
476 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
477 ) -> ExperimentEnrollment {
478 match self.status {
479 EnrollmentStatus::Enrolled { .. } => {
480 #[cfg(feature = "stateful")]
481 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
482
483 let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
484 out_enrollment_events.push(enrollment.get_change_event(experiment));
485 enrollment
486 }
487 EnrollmentStatus::NotEnrolled { .. } => Self {
488 slug: self.slug.to_string(),
489 status: EnrollmentStatus::NotEnrolled {
490 reason: NotEnrolledReason::OptOut, },
492 },
493 EnrollmentStatus::Disqualified { .. }
494 | EnrollmentStatus::WasEnrolled { .. }
495 | EnrollmentStatus::Error { .. } => {
496 self.clone()
498 }
499 }
500 }
501
502 #[cfg(feature = "stateful")]
503 pub(crate) fn on_pref_unenroll(
504 &self,
505 pref_unenroll_reason: PrefUnenrollReason,
506 experiment: Option<&Experiment>,
507 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
508 ) -> ExperimentEnrollment {
509 match self.status {
510 EnrollmentStatus::Enrolled { .. } => {
511 let enrollment =
512 self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
513 reason: pref_unenroll_reason,
514 });
515 out_enrollment_events.push(enrollment.get_change_event(experiment));
516 enrollment
517 }
518 _ => self.clone(),
519 }
520 }
521
522 #[cfg(feature = "stateful")]
524 pub(crate) fn on_add_gecko_pref_states(
525 &self,
526 prev_gecko_pref_states: Vec<PreviousGeckoPrefState>,
527 ) -> ExperimentEnrollment {
528 let mut next = self.clone();
529 if let EnrollmentStatus::Enrolled { reason, branch, .. } = &self.status {
530 next.status = EnrollmentStatus::Enrolled {
531 prev_gecko_pref_states: Some(prev_gecko_pref_states),
532 reason: reason.clone(),
533 branch: branch.clone(),
534 };
535 }
536 next
537 }
538
539 #[cfg(feature = "stateful")]
540 pub(crate) fn maybe_revert_unchanged_gecko_pref_states(
542 &self,
543 non_reverting_pref_name: &str,
544 gecko_pref_store: Option<&GeckoPrefStore>,
545 ) {
546 if let EnrollmentStatus::Enrolled {
547 prev_gecko_pref_states: Some(prev_gecko_pref_states),
548 ..
549 } = &self.status
550 {
551 PreviousGeckoPrefState::on_partially_revert_to_prev_gecko_pref_states(
552 prev_gecko_pref_states,
553 non_reverting_pref_name,
554 gecko_pref_store,
555 );
556 }
557 }
558
559 #[cfg(feature = "stateful")]
560 pub(crate) fn maybe_revert_all_gecko_pref_states(
562 &self,
563 gecko_pref_store: Option<&GeckoPrefStore>,
564 ) {
565 if let EnrollmentStatus::Enrolled {
566 prev_gecko_pref_states: Some(prev_gecko_pref_states),
567 ..
568 } = &self.status
569 {
570 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
571 prev_gecko_pref_states,
572 gecko_pref_store,
573 );
574 }
575 }
576
577 #[cfg_attr(not(feature = "stateful"), allow(unused))]
583 pub fn reset_telemetry_identifiers(
584 &self,
585 experiment: Option<&Experiment>,
586 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
587 ) -> Self {
588 let updated = match self.status {
589 EnrollmentStatus::Enrolled { .. } => {
590 let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
591 out_enrollment_events.push(disqualified.get_change_event(experiment));
592 disqualified
593 }
594 EnrollmentStatus::NotEnrolled { .. }
595 | EnrollmentStatus::Disqualified { .. }
596 | EnrollmentStatus::WasEnrolled { .. }
597 | EnrollmentStatus::Error { .. } => self.clone(),
598 };
599 ExperimentEnrollment {
600 status: updated.status.clone(),
601 ..updated
602 }
603 }
604
605 fn maybe_garbage_collect(&self) -> Option<Self> {
608 if let EnrollmentStatus::WasEnrolled {
609 experiment_ended_at,
610 ..
611 } = self.status
612 {
613 let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
614 if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
615 return Some(self.clone());
616 }
617 }
618 debug!("Garbage collecting enrollment '{}'", self.slug);
619 None
620 }
621
622 fn get_change_event(&self, experiment: Option<&Experiment>) -> EnrollmentChangeEvent {
625 match &self.status {
626 EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
627 &self.slug,
628 branch,
629 None,
630 EnrollmentChangeEventType::Enrollment,
631 experiment,
632 ),
633 EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
634 &self.slug,
635 branch,
636 None,
637 EnrollmentChangeEventType::Unenrollment,
638 experiment,
639 ),
640 EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
641 &self.slug,
642 branch,
643 match reason {
644 DisqualifiedReason::NotSelected => Some("bucketing"),
645 DisqualifiedReason::NotTargeted => Some("targeting"),
646 DisqualifiedReason::OptOut => Some("optout"),
647 DisqualifiedReason::Error => Some("error"),
648 #[cfg(feature = "stateful")]
649 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
650 PrefUnenrollReason::Changed => Some("pref_changed"),
651 PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"),
652 },
653 },
654 EnrollmentChangeEventType::Disqualification,
655 experiment,
656 ),
657 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
658 unreachable!()
659 }
660 }
661 }
662
663 fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
665 match self.status {
666 EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
667 status: EnrollmentStatus::Disqualified {
668 reason,
669 branch: branch.to_owned(),
670 },
671 ..self.clone()
672 },
673 EnrollmentStatus::NotEnrolled { .. }
674 | EnrollmentStatus::Disqualified { .. }
675 | EnrollmentStatus::WasEnrolled { .. }
676 | EnrollmentStatus::Error { .. } => self.clone(),
677 }
678 }
679
680 #[cfg(feature = "stateful")]
681 pub(crate) fn will_pref_experiment_change(
682 &self,
683 updated_experiment: &Experiment,
684 updated_enrollment: &ExperimentEnrollment,
685 ) -> bool {
686 let EnrollmentStatus::Enrolled {
687 prev_gecko_pref_states: Some(original_prev_gecko_pref_states),
688 branch: original_branch_slug,
689 ..
690 } = &self.status
691 else {
692 return false;
694 };
695
696 let EnrollmentStatus::Enrolled {
697 branch: updated_branch_slug,
698 ..
699 } = &updated_enrollment.status
700 else {
701 return true;
703 };
704
705 if updated_branch_slug != original_branch_slug {
707 return true;
708 }
709
710 let Some(updated_branch) = updated_experiment.get_branch(updated_branch_slug) else {
712 return true;
713 };
714
715 let original_feature_ids: HashSet<&String> = original_prev_gecko_pref_states
716 .iter()
717 .map(|state| &state.feature_id)
718 .collect();
719 let updated_features = updated_branch.get_feature_configs();
720
721 if updated_features.len() != original_feature_ids.len() {
723 return true;
724 }
725
726 for original_state in original_prev_gecko_pref_states {
727 let Some(updated_feature) = updated_features
728 .iter()
729 .find(|config| config.feature_id == original_state.feature_id)
730 else {
731 return true;
733 };
734
735 if !updated_feature.value.contains_key(&original_state.variable) {
737 return true;
738 }
739 }
740 false
741 }
742}
743
744#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
747pub enum EnrollmentStatus {
748 Enrolled {
749 reason: EnrolledReason,
750 branch: String,
751 #[cfg(feature = "stateful")]
752 #[serde(skip_serializing_if = "Option::is_none")]
753 prev_gecko_pref_states: Option<Vec<PreviousGeckoPrefState>>,
754 },
755 NotEnrolled {
756 reason: NotEnrolledReason,
757 },
758 Disqualified {
759 reason: DisqualifiedReason,
760 branch: String,
761 },
762 WasEnrolled {
763 branch: String,
764 experiment_ended_at: u64, },
766 Error {
768 reason: String,
771 },
772}
773
774impl EnrollmentStatus {
775 pub fn name(&self) -> String {
776 match self {
777 EnrollmentStatus::Enrolled { .. } => "Enrolled",
778 EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
779 EnrollmentStatus::Disqualified { .. } => "Disqualified",
780 EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
781 EnrollmentStatus::Error { .. } => "Error",
782 }
783 .into()
784 }
785}
786
787impl EnrollmentStatus {
788 pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
791 EnrollmentStatus::Enrolled {
792 reason,
793 branch: branch.to_owned(),
794 #[cfg(feature = "stateful")]
795 prev_gecko_pref_states: None,
796 }
797 }
798
799 pub fn is_enrolled(&self) -> bool {
802 matches!(self, EnrollmentStatus::Enrolled { .. })
803 }
804}
805
806pub(crate) trait ExperimentMetadata {
807 fn get_slug(&self) -> String;
808
809 fn is_rollout(&self) -> bool;
810}
811
812pub(crate) struct EnrollmentsEvolver<'a> {
813 available_randomization_units: &'a AvailableRandomizationUnits,
814 targeting_helper: &'a mut NimbusTargetingHelper,
815 coenrolling_feature_ids: &'a HashSet<&'a str>,
816}
817
818impl<'a> EnrollmentsEvolver<'a> {
819 pub(crate) fn new(
820 available_randomization_units: &'a AvailableRandomizationUnits,
821 targeting_helper: &'a mut NimbusTargetingHelper,
822 coenrolling_feature_ids: &'a HashSet<&str>,
823 ) -> Self {
824 Self {
825 available_randomization_units,
826 targeting_helper,
827 coenrolling_feature_ids,
828 }
829 }
830
831 pub(crate) fn evolve_enrollments(
832 &mut self,
833 participation: Participation,
834 prev_experiments: &[Experiment],
835 next_experiments: &[Experiment],
836 prev_enrollments: &[ExperimentEnrollment],
837 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
838 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
839 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
840 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
841
842 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
845 prev_experiments,
846 prev_enrollments,
847 ExperimentMetadata::is_rollout,
848 );
849 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
850
851 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
852 participation.in_rollouts,
853 &prev_rollouts,
854 &next_rollouts,
855 &ro_enrollments,
856 #[cfg(feature = "stateful")]
857 gecko_pref_store,
858 )?;
859
860 enrollments.extend(next_ro_enrollments);
861 events.extend(ro_events);
862
863 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
864
865 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
870 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
871 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
872 .iter()
873 .filter(|e| !ro_slugs.contains(&e.slug))
874 .map(|e| e.to_owned())
875 .collect();
876
877 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
878 participation.in_experiments,
879 &prev_experiments,
880 &next_experiments,
881 &prev_enrollments,
882 #[cfg(feature = "stateful")]
883 gecko_pref_store,
884 )?;
885
886 enrollments.extend(next_exp_enrollments);
887 events.extend(exp_events);
888
889 Ok((enrollments, events))
890 }
891
892 pub(crate) fn evolve_enrollment_recipes(
895 &mut self,
896 is_user_participating: bool,
897 prev_experiments: &[Experiment],
898 next_experiments: &[Experiment],
899 prev_enrollments: &[ExperimentEnrollment],
900 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
901 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
902 let mut enrollment_events = vec![];
903 let prev_experiments_map = map_experiments(prev_experiments);
904 let next_experiments_map = map_experiments(next_experiments);
905 let prev_enrollments_map = map_enrollments(prev_enrollments);
906
907 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
910 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
911
912 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
913
914 for prev_enrollment in prev_enrollments {
921 if matches!(
922 prev_enrollment.status,
923 EnrollmentStatus::NotEnrolled {
924 reason: NotEnrolledReason::FeatureConflict
925 }
926 ) {
927 continue;
928 }
929 let slug = &prev_enrollment.slug;
930
931 let next_enrollment = match self.evolve_enrollment(
932 is_user_participating,
933 prev_experiments_map.get(slug).copied(),
934 next_experiments_map.get(slug).copied(),
935 Some(prev_enrollment),
936 &mut enrollment_events,
937 #[cfg(feature = "stateful")]
938 gecko_pref_store,
939 ) {
940 Ok(enrollment) => enrollment,
941 Err(e) => {
942 warn!(
948 "{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ",
949 e, slug, prev_enrollment
950 );
951 None
952 }
953 };
954
955 #[cfg(feature = "stateful")]
956 if let Some(ref enrollment) = next_enrollment.clone() {
957 if self.targeting_helper.update_enrollment(enrollment) {
958 debug!("Enrollment updated for {}", enrollment.slug);
959 } else {
960 debug!("Enrollment unchanged for {}", enrollment.slug);
961 }
962 }
963
964 self.reserve_enrolled_features(
965 next_enrollment,
966 &next_experiments_map,
967 &mut enrolled_features,
968 &mut coenrolling_features,
969 &mut next_enrollments,
970 );
971 }
972
973 let next_experiments = sort_experiments_by_published_date(next_experiments);
976 for next_experiment in next_experiments {
977 let slug = &next_experiment.slug;
978
979 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
985 .get_feature_ids()
986 .iter()
987 .filter_map(|id| enrolled_features.get(id))
988 .collect();
989 if !needed_features_in_use.is_empty() {
990 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
991 if is_our_experiment {
992 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
996 } else {
999 next_enrollments.push(ExperimentEnrollment {
1002 slug: slug.clone(),
1003 status: EnrollmentStatus::NotEnrolled {
1004 reason: NotEnrolledReason::FeatureConflict,
1005 },
1006 });
1007
1008 enrollment_events.push(EnrollmentChangeEvent {
1009 experiment_slug: slug.clone(),
1010 branch_slug: "N/A".to_string(),
1011 reason: Some("feature-conflict".to_string()),
1012 change: EnrollmentChangeEventType::EnrollFailed,
1013 feature_ids: next_experiment.get_feature_ids(),
1014 })
1015 }
1016 continue;
1022 }
1023
1024 let prev_enrollment = prev_enrollments_map.get(slug).copied();
1029
1030 if prev_enrollment.is_none()
1031 || matches!(
1032 prev_enrollment.unwrap().status,
1033 EnrollmentStatus::NotEnrolled {
1034 reason: NotEnrolledReason::FeatureConflict
1035 }
1036 )
1037 {
1038 let next_enrollment = match self.evolve_enrollment(
1039 is_user_participating,
1040 prev_experiments_map.get(slug).copied(),
1041 Some(next_experiment),
1042 prev_enrollment,
1043 &mut enrollment_events,
1044 #[cfg(feature = "stateful")]
1045 gecko_pref_store,
1046 ) {
1047 Ok(enrollment) => enrollment,
1048 Err(e) => {
1049 warn!(
1055 "{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ",
1056 e, slug, prev_enrollment
1057 );
1058 None
1059 }
1060 };
1061
1062 #[cfg(feature = "stateful")]
1063 if let Some(ref enrollment) = next_enrollment.clone() {
1064 if self.targeting_helper.update_enrollment(enrollment) {
1065 debug!("Enrollment updated for {}", enrollment.slug);
1066 } else {
1067 debug!("Enrollment unchanged for {}", enrollment.slug);
1068 }
1069 }
1070
1071 self.reserve_enrolled_features(
1072 next_enrollment,
1073 &next_experiments_map,
1074 &mut enrolled_features,
1075 &mut coenrolling_features,
1076 &mut next_enrollments,
1077 );
1078 }
1079 }
1080
1081 enrolled_features.extend(coenrolling_features);
1082
1083 let updated_enrolled_features = map_features(
1087 &next_enrollments,
1088 &next_experiments_map,
1089 self.coenrolling_feature_ids,
1090 );
1091 if enrolled_features != updated_enrolled_features {
1092 Err(NimbusError::InternalError(
1093 "Next enrollment calculation error",
1094 ))
1095 } else {
1096 Ok((next_enrollments, enrollment_events))
1097 }
1098 }
1099
1100 fn reserve_enrolled_features(
1102 &self,
1103 latest_enrollment: Option<ExperimentEnrollment>,
1104 experiments: &HashMap<String, &Experiment>,
1105 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
1106 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1107 enrollments: &mut Vec<ExperimentEnrollment>,
1108 ) {
1109 if let Some(enrollment) = latest_enrollment {
1110 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
1114 populate_feature_maps(
1115 enrolled_feature,
1116 self.coenrolling_feature_ids,
1117 enrolled_features,
1118 coenrolling_features,
1119 );
1120 }
1121 enrollments.push(enrollment);
1123 }
1124 }
1125
1126 pub(crate) fn evolve_enrollment(
1136 &mut self,
1137 is_user_participating: bool,
1138 prev_experiment: Option<&Experiment>,
1139 next_experiment: Option<&Experiment>,
1140 prev_enrollment: Option<&ExperimentEnrollment>,
1141 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1143 ) -> Result<Option<ExperimentEnrollment>> {
1144 let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
1145 enrollment.status.is_enrolled()
1146 } else {
1147 false
1148 };
1149
1150 let targeting_helper = self
1155 .targeting_helper
1156 .put("is_already_enrolled", is_already_enrolled);
1157
1158 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
1159 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
1161 is_user_participating,
1162 self.available_randomization_units,
1163 experiment,
1164 &targeting_helper,
1165 out_enrollment_events,
1166 )?),
1167 (Some(prev_experiment), None, Some(enrollment)) => enrollment.on_experiment_ended(
1169 prev_experiment,
1170 #[cfg(feature = "stateful")]
1171 gecko_pref_store,
1172 out_enrollment_events,
1173 ),
1174 (Some(_), Some(experiment), Some(enrollment)) => {
1176 Some(enrollment.on_experiment_updated(
1177 is_user_participating,
1178 self.available_randomization_units,
1179 experiment,
1180 &targeting_helper,
1181 #[cfg(feature = "stateful")]
1182 gecko_pref_store,
1183 out_enrollment_events,
1184 )?)
1185 }
1186 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
1187 (None, Some(_), Some(_)) => {
1188 return Err(NimbusError::InternalError(
1189 "New experiment but enrollment already exists.",
1190 ));
1191 }
1192 (Some(_), None, None) | (Some(_), Some(_), None) => {
1193 return Err(NimbusError::InternalError(
1194 "Experiment in the db did not have an associated enrollment record.",
1195 ));
1196 }
1197 (None, None, None) => {
1198 return Err(NimbusError::InternalError(
1199 "evolve_experiment called with nothing that could evolve or be evolved",
1200 ));
1201 }
1202 })
1203 }
1204}
1205
1206fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
1207where
1208 E: ExperimentMetadata + Clone,
1209{
1210 let mut map_experiments = HashMap::with_capacity(experiments.len());
1211 for e in experiments {
1212 map_experiments.insert(e.get_slug(), e);
1213 }
1214 map_experiments
1215}
1216
1217pub fn map_enrollments(
1218 enrollments: &[ExperimentEnrollment],
1219) -> HashMap<String, &ExperimentEnrollment> {
1220 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
1221 for e in enrollments {
1222 map_enrollments.insert(e.slug.clone(), e);
1223 }
1224 map_enrollments
1225}
1226
1227pub(crate) fn filter_experiments_and_enrollments<E>(
1228 experiments: &[E],
1229 enrollments: &[ExperimentEnrollment],
1230 filter_fn: fn(&E) -> bool,
1231) -> (Vec<E>, Vec<ExperimentEnrollment>)
1232where
1233 E: ExperimentMetadata + Clone,
1234{
1235 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
1236
1237 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
1238
1239 let enrollments: Vec<ExperimentEnrollment> = enrollments
1240 .iter()
1241 .filter(|e| slugs.contains(&e.slug))
1242 .map(|e| e.to_owned())
1243 .collect();
1244
1245 (experiments, enrollments)
1246}
1247
1248fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1249where
1250 E: ExperimentMetadata + Clone,
1251{
1252 experiments
1253 .iter()
1254 .filter(|e| filter_fn(e))
1255 .cloned()
1256 .collect()
1257}
1258
1259pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1260 let mut experiments: Vec<_> = experiments.iter().collect();
1261 experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
1262 experiments
1263}
1264
1265fn map_features(
1268 enrollments: &[ExperimentEnrollment],
1269 experiments: &HashMap<String, &Experiment>,
1270 coenrolling_ids: &HashSet<&str>,
1271) -> HashMap<String, EnrolledFeatureConfig> {
1272 let mut colliding_features = HashMap::with_capacity(enrollments.len());
1273 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1274 for enrolled_feature_config in enrollments
1275 .iter()
1276 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1277 {
1278 populate_feature_maps(
1279 enrolled_feature_config,
1280 coenrolling_ids,
1281 &mut colliding_features,
1282 &mut coenrolling_features,
1283 );
1284 }
1285 colliding_features.extend(coenrolling_features.drain());
1286
1287 colliding_features
1288}
1289
1290pub fn map_features_by_feature_id(
1291 enrollments: &[ExperimentEnrollment],
1292 experiments: &[Experiment],
1293 coenrolling_ids: &HashSet<&str>,
1294) -> HashMap<String, EnrolledFeatureConfig> {
1295 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1296 experiments,
1297 enrollments,
1298 ExperimentMetadata::is_rollout,
1299 );
1300 let (experiments, exp_enrollments) =
1301 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1302
1303 let features_under_rollout = map_features(
1304 &ro_enrollments,
1305 &map_experiments(&rollouts),
1306 coenrolling_ids,
1307 );
1308 let features_under_experiment = map_features(
1309 &exp_enrollments,
1310 &map_experiments(&experiments),
1311 coenrolling_ids,
1312 );
1313
1314 features_under_experiment
1315 .defaults(&features_under_rollout)
1316 .unwrap()
1317}
1318
1319pub(crate) fn populate_feature_maps(
1320 enrolled_feature: EnrolledFeatureConfig,
1321 coenrolling_feature_ids: &HashSet<&str>,
1322 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1323 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1324) {
1325 let feature_id = &enrolled_feature.feature_id;
1326 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1327 colliding_features.insert(feature_id.clone(), enrolled_feature);
1330 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1331 let merged = enrolled_feature
1335 .defaults(existing)
1336 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1337
1338 let merged = EnrolledFeatureConfig {
1341 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1343 branch: None,
1344 ..merged
1345 };
1346 coenrolling_features.insert(feature_id.clone(), merged);
1347 } else {
1348 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1350 }
1351}
1352
1353fn get_enrolled_feature_configs(
1354 enrollment: &ExperimentEnrollment,
1355 experiments: &HashMap<String, &Experiment>,
1356) -> Vec<EnrolledFeatureConfig> {
1357 let branch_slug = match &enrollment.status {
1359 EnrollmentStatus::Enrolled { branch, .. } => branch,
1360 _ => return Vec::new(),
1361 };
1362
1363 let experiment_slug = &enrollment.slug;
1364
1365 let experiment = match experiments.get(experiment_slug).copied() {
1366 Some(exp) => exp,
1367 _ => return Vec::new(),
1368 };
1369
1370 let mut branch_features = match &experiment.get_branch(branch_slug) {
1373 Some(branch) => branch.get_feature_configs(),
1374 _ => Default::default(),
1375 };
1376
1377 branch_features.iter_mut().for_each(|f| {
1378 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1379 });
1380
1381 let branch_feature_ids = &branch_features
1382 .iter()
1383 .map(|f| &f.feature_id)
1384 .collect::<HashSet<_>>();
1385
1386 let non_branch_features: Vec<FeatureConfig> = experiment
1390 .get_feature_ids()
1391 .into_iter()
1392 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1393 .map(|feature_id| FeatureConfig {
1394 feature_id,
1395 ..Default::default()
1396 })
1397 .collect();
1398
1399 branch_features
1402 .iter()
1403 .chain(non_branch_features.iter())
1404 .map(|f| EnrolledFeatureConfig {
1405 feature: f.to_owned(),
1406 slug: experiment_slug.clone(),
1407 branch: if !experiment.is_rollout() {
1408 Some(branch_slug.clone())
1409 } else {
1410 None
1411 },
1412 feature_id: f.feature_id.clone(),
1413 })
1414 .collect()
1415}
1416
1417#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1421#[serde(rename_all = "camelCase")]
1422pub struct EnrolledFeatureConfig {
1423 pub feature: FeatureConfig,
1424 pub slug: String,
1425 pub branch: Option<String>,
1426 pub feature_id: String,
1427}
1428
1429impl Defaults for EnrolledFeatureConfig {
1430 fn defaults(&self, fallback: &Self) -> Result<Self> {
1431 if self.feature_id != fallback.feature_id {
1432 Err(NimbusError::InternalError(
1434 "Cannot merge enrolled feature configs from different features",
1435 ))
1436 } else {
1437 Ok(Self {
1438 slug: self.slug.to_owned(),
1439 feature_id: self.feature_id.to_owned(),
1440 feature: self.feature.defaults(&fallback.feature)?,
1442 branch: self.branch.to_owned(),
1446 })
1447 }
1448 }
1449}
1450
1451impl ExperimentMetadata for EnrolledFeatureConfig {
1452 fn get_slug(&self) -> String {
1453 self.slug.clone()
1454 }
1455
1456 fn is_rollout(&self) -> bool {
1457 self.branch.is_none()
1458 }
1459}
1460
1461#[derive(Debug, Clone, PartialEq, Eq)]
1462pub struct EnrolledFeature {
1463 pub slug: String,
1464 pub branch: Option<String>,
1465 pub feature_id: String,
1466}
1467
1468impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1469 fn from(value: &EnrolledFeatureConfig) -> Self {
1470 Self {
1471 slug: value.slug.clone(),
1472 branch: value.branch.clone(),
1473 feature_id: value.feature_id.clone(),
1474 }
1475 }
1476}
1477
1478#[derive(Serialize, Deserialize, Debug, Clone)]
1479#[cfg_attr(test, derive(Eq, PartialEq))]
1480pub struct EnrollmentChangeEvent {
1481 pub experiment_slug: String,
1482 pub branch_slug: String,
1483 pub reason: Option<String>,
1484 pub change: EnrollmentChangeEventType,
1485 pub feature_ids: Vec<String>,
1486}
1487
1488impl EnrollmentChangeEvent {
1489 pub(crate) fn new(
1490 slug: &str,
1491 branch: &str,
1492 reason: Option<&str>,
1493 change: EnrollmentChangeEventType,
1494 experiment: Option<&Experiment>,
1495 ) -> Self {
1496 Self {
1497 experiment_slug: slug.to_owned(),
1498 branch_slug: branch.to_owned(),
1499 reason: reason.map(|s| s.to_owned()),
1500 change,
1501 feature_ids: experiment.map(|e| e.get_feature_ids()).unwrap_or_default(),
1502 }
1503 }
1504}
1505
1506#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1507pub enum EnrollmentChangeEventType {
1508 Enrollment,
1509 EnrollFailed,
1510 Disqualification,
1511 Unenrollment,
1512 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1513 UnenrollFailed,
1514}
1515
1516pub(crate) fn now_secs() -> u64 {
1517 SystemTime::now()
1518 .duration_since(UNIX_EPOCH)
1519 .expect("Current date before Unix Epoch.")
1520 .as_secs()
1521}