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 #[cfg(feature = "stateful")]
34 FirefoxLabsOptIn,
36}
37
38impl Display for EnrolledReason {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 Display::fmt(
41 match self {
42 EnrolledReason::Qualified => "Qualified",
43 EnrolledReason::OptIn => "OptIn",
44 #[cfg(feature = "stateful")]
45 EnrolledReason::FirefoxLabsOptIn => "FirefoxLabsOptIn",
46 },
47 f,
48 )
49 }
50}
51
52#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
57#[allow(deprecated)]
58pub enum NotEnrolledReason {
59 DifferentAppName,
61 DifferentChannel,
63 EnrollmentsPaused,
65 FeatureConflict { conflict_slug: Option<String> },
67 NotSelected,
69 NotTargeted,
71 ExperimentsOptOut,
73 RolloutsOptOut,
75
76 #[cfg(feature = "stateful")]
78 FirefoxLabs,
79
80 #[deprecated]
95 OptOut,
96}
97
98#[allow(deprecated)]
99impl Display for NotEnrolledReason {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 Display::fmt(
102 match self {
103 NotEnrolledReason::DifferentAppName => "DifferentAppName",
104 NotEnrolledReason::DifferentChannel => "DifferentChannel",
105 NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
106 NotEnrolledReason::FeatureConflict { .. } => "FeatureConflict",
107 NotEnrolledReason::NotSelected => "NotSelected",
108 NotEnrolledReason::NotTargeted => "NotTargeted",
109 NotEnrolledReason::ExperimentsOptOut => "ExperimentsOptOut",
110 NotEnrolledReason::RolloutsOptOut => "RolloutsOptOut",
111 #[cfg(feature = "stateful")]
112 NotEnrolledReason::FirefoxLabs => "FirefoxLabs",
113 NotEnrolledReason::OptOut => "OptOut",
114 },
115 f,
116 )
117 }
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone)]
121pub struct Participation {
122 pub in_experiments: bool,
123 pub in_rollouts: bool,
124}
125
126impl Default for Participation {
127 fn default() -> Self {
128 Self {
129 in_experiments: true,
130 in_rollouts: true,
131 }
132 }
133}
134
135#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
140pub enum DisqualifiedReason {
141 Error,
143 OptOut,
145 ExperimentsOptOut,
147 RolloutsOptOut,
149 NotTargeted,
151 NotSelected,
153 #[cfg(feature = "stateful")]
155 PrefUnenrollReason {
156 reason: PrefUnenrollReason,
157 },
158 #[cfg(feature = "stateful")]
159 FirefoxLabsOptOut,
160}
161
162impl Display for DisqualifiedReason {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 Display::fmt(
165 match self {
166 DisqualifiedReason::Error => "Error",
167 DisqualifiedReason::OptOut => "OptOut",
168 DisqualifiedReason::ExperimentsOptOut => "ExperimentsOptOut",
169 DisqualifiedReason::RolloutsOptOut => "ExperimentsOptOut",
170 DisqualifiedReason::NotSelected => "NotSelected",
171 DisqualifiedReason::NotTargeted => "NotTargeted",
172 #[cfg(feature = "stateful")]
173 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
174 PrefUnenrollReason::Changed => "PrefChanged",
175 PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
176 },
177 #[cfg(feature = "stateful")]
178 DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
179 },
180 f,
181 )
182 }
183}
184
185impl DisqualifiedReason {
186 fn for_enrollment_change_event(&self) -> &'static str {
188 match self {
189 DisqualifiedReason::NotSelected => "bucketing",
190 DisqualifiedReason::NotTargeted => "targeting",
191 DisqualifiedReason::OptOut => "optout",
192 DisqualifiedReason::ExperimentsOptOut => "experiments-opt-out",
193 DisqualifiedReason::RolloutsOptOut => "rollouts-opt-out",
194 DisqualifiedReason::Error => "error",
195 #[cfg(feature = "stateful")]
196 DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
197 PrefUnenrollReason::Changed => "pref_changed",
198 PrefUnenrollReason::FailedToSet => "pref_failed_to_set",
199 },
200 #[cfg(feature = "stateful")]
201 DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
202 }
203 }
204}
205
206#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
210#[cfg(feature = "stateful")]
211pub struct PreviousGeckoPrefState {
212 pub original_value: OriginalGeckoPref,
213 pub feature_id: String,
214 pub variable: String,
215}
216
217#[cfg(feature = "stateful")]
218impl PreviousGeckoPrefState {
219 pub(crate) fn on_revert_all_to_prev_gecko_pref_states(
220 prev_gecko_pref_states: &[Self],
221 gecko_pref_store: Option<&GeckoPrefStore>,
222 ) {
223 if let Some(store) = gecko_pref_store {
224 let original_values: Vec<_> = prev_gecko_pref_states
225 .iter()
226 .map(|state| state.original_value.clone())
227 .collect();
228 store
229 .handler
230 .set_gecko_prefs_original_values(original_values);
231 }
232 }
233
234 pub(crate) fn on_partially_revert_to_prev_gecko_pref_states(
235 prev_gecko_pref_states: &[Self],
236 non_reverting_pref_name: &str,
237 gecko_pref_store: Option<&GeckoPrefStore>,
238 ) {
239 if let Some(store) = gecko_pref_store {
240 let qualified_values: Vec<_> = prev_gecko_pref_states
241 .iter()
242 .filter(|state| state.original_value.pref != non_reverting_pref_name)
243 .map(|state| state.original_value.clone())
244 .collect();
245 store
246 .handler
247 .set_gecko_prefs_original_values(qualified_values);
248 }
249 }
250}
251
252#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
256pub struct ExperimentEnrollment {
257 pub slug: String,
258 pub status: EnrollmentStatus,
259}
260
261impl ExperimentEnrollment {
262 fn from_new_experiment(
265 is_user_participating: bool,
266 available_randomization_units: &AvailableRandomizationUnits,
267 experiment: &Experiment,
268 targeting_helper: &NimbusTargetingHelper,
269 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
270 ) -> Result<Self> {
271 #[cfg(feature = "stateful")]
274 if experiment.is_firefox_labs_opt_in {
275 return Ok(Self {
276 slug: experiment.slug.clone(),
277 status: EnrollmentStatus::NotEnrolled {
278 reason: NotEnrolledReason::FirefoxLabs,
279 },
280 });
281 }
282
283 Ok(if !is_user_participating {
284 Self {
285 slug: experiment.slug.clone(),
286 status: EnrollmentStatus::NotEnrolled {
287 reason: if experiment.is_rollout() {
288 NotEnrolledReason::RolloutsOptOut
289 } else {
290 NotEnrolledReason::ExperimentsOptOut
291 },
292 },
293 }
294 } else if experiment.is_enrollment_paused {
295 Self {
296 slug: experiment.slug.clone(),
297 status: EnrollmentStatus::NotEnrolled {
298 reason: NotEnrolledReason::EnrollmentsPaused,
299 },
300 }
301 } else {
302 let enrollment =
303 evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
304 debug!(
305 "Evaluating experiment slug: {:?} with targeting string: {:?}",
306 experiment.slug, experiment.targeting
307 );
308 debug!(
309 "Experiment '{}' is new - enrollment status is {:?}",
310 &enrollment.slug, &enrollment
311 );
312 if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
313 out_enrollment_events.push(enrollment.get_change_event(Some(experiment)))
314 }
315 enrollment
316 })
317 }
318
319 #[cfg_attr(not(feature = "stateful"), allow(unused))]
321 pub(crate) fn from_explicit_opt_in(
322 experiment: &Experiment,
323 branch_slug: &str,
324 reason: EnrolledReason,
325 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
326 ) -> Result<Self> {
327 if !experiment.has_branch(branch_slug) {
328 out_enrollment_events.push(EnrollmentChangeEvent {
329 experiment_slug: experiment.slug.to_string(),
330 branch_slug: branch_slug.to_string(),
331 reason: Some("does-not-exist".to_string()),
332 change: EnrollmentChangeEventType::EnrollFailed,
333 feature_ids: experiment.get_feature_ids(),
334 });
335
336 return Err(NimbusError::NoSuchBranch(
337 branch_slug.to_owned(),
338 experiment.slug.clone(),
339 ));
340 }
341 let enrollment = Self {
342 slug: experiment.slug.clone(),
343 status: EnrollmentStatus::new_enrolled(reason, branch_slug),
344 };
345 out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
346 Ok(enrollment)
347 }
348
349 #[allow(clippy::too_many_arguments)]
351 fn on_experiment_updated(
352 &self,
353 is_user_participating: bool,
354 available_randomization_units: &AvailableRandomizationUnits,
355 updated_experiment: &Experiment,
356 targeting_helper: &NimbusTargetingHelper,
357 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
358 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
359 ) -> Result<Self> {
360 #[cfg(feature = "stateful")]
361 if updated_experiment.is_firefox_labs_opt_in && !self.status.is_enrolled() {
362 return Ok(self.clone());
363 }
364
365 #[cfg(feature = "stateful")]
367 let is_user_participating =
368 is_user_participating || updated_experiment.is_firefox_labs_opt_in;
369
370 Ok(match &self.status {
371 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
372 if !is_user_participating || updated_experiment.is_enrollment_paused {
373 self.clone()
374 } else {
375 let updated_enrollment = evaluate_enrollment(
376 available_randomization_units,
377 updated_experiment,
378 targeting_helper,
379 )?;
380 debug!(
381 "Experiment '{}' with enrollment {:?} is now {:?}",
382 &self.slug, &self, updated_enrollment
383 );
384 if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
385 out_enrollment_events
386 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
387 }
388 updated_enrollment
389 }
390 }
391
392 EnrollmentStatus::Enrolled { branch, reason, .. } => {
393 if !is_user_participating {
394 debug!(
395 "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
396 &self.slug
397 );
398 #[cfg(feature = "stateful")]
399 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
400 let updated_enrollment =
401 self.disqualify_from_enrolled(if updated_experiment.is_rollout {
402 DisqualifiedReason::RolloutsOptOut
403 } else {
404 DisqualifiedReason::ExperimentsOptOut
405 });
406 out_enrollment_events
407 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
408 updated_enrollment
409 } else if !updated_experiment.has_branch(branch) {
410 #[cfg(feature = "stateful")]
412 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
413 let updated_enrollment =
414 self.disqualify_from_enrolled(DisqualifiedReason::Error);
415 out_enrollment_events
416 .push(updated_enrollment.get_change_event(Some(updated_experiment)));
417 updated_enrollment
418 } else if matches!(reason, EnrolledReason::OptIn) {
419 self.clone()
422 } else {
423 let evaluated_enrollment = evaluate_enrollment(
424 available_randomization_units,
425 updated_experiment,
426 targeting_helper,
427 )?;
428
429 #[cfg(feature = "stateful")]
430 if let EnrollmentStatus::Enrolled {
431 prev_gecko_pref_states: Some(prev_gecko_pref_states),
432 ..
433 } = &self.status
434 && self
435 .will_pref_experiment_change(updated_experiment, &evaluated_enrollment)
436 {
437 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
438 prev_gecko_pref_states,
439 gecko_pref_store,
440 );
441 }
442 match evaluated_enrollment.status {
443 EnrollmentStatus::Error { .. } => {
444 let updated_enrollment =
445 self.disqualify_from_enrolled(DisqualifiedReason::Error);
446 out_enrollment_events.push(
447 updated_enrollment.get_change_event(Some(updated_experiment)),
448 );
449 updated_enrollment
450 }
451 EnrollmentStatus::NotEnrolled {
452 reason: NotEnrolledReason::DifferentAppName,
453 }
454 | EnrollmentStatus::NotEnrolled {
455 reason: NotEnrolledReason::DifferentChannel,
456 }
457 | EnrollmentStatus::NotEnrolled {
458 reason: NotEnrolledReason::NotTargeted,
459 } => {
460 debug!(
461 "Existing experiment enrollment '{}' is now disqualified (targeting change)",
462 &self.slug
463 );
464 let updated_enrollment =
465 self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
466 out_enrollment_events.push(
467 updated_enrollment.get_change_event(Some(updated_experiment)),
468 );
469 updated_enrollment
470 }
471 EnrollmentStatus::NotEnrolled {
472 reason: NotEnrolledReason::NotSelected,
473 } => {
474 let updated_enrollment =
477 self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
478 out_enrollment_events.push(
479 updated_enrollment.get_change_event(Some(updated_experiment)),
480 );
481 updated_enrollment
482 }
483 EnrollmentStatus::NotEnrolled { .. }
484 | EnrollmentStatus::Enrolled { .. }
485 | EnrollmentStatus::Disqualified { .. }
486 | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
487 }
488 }
489 }
490 EnrollmentStatus::Disqualified { branch, reason, .. } => {
491 if !is_user_participating {
492 debug!(
493 "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
494 &self.slug
495 );
496 Self {
497 slug: self.slug.clone(),
498 status: EnrollmentStatus::Disqualified {
499 reason: if updated_experiment.is_rollout {
500 DisqualifiedReason::RolloutsOptOut
501 } else {
502 DisqualifiedReason::ExperimentsOptOut
503 },
504 branch: branch.clone(),
505 },
506 }
507 } else if updated_experiment.is_rollout
508 && matches!(
509 reason,
510 DisqualifiedReason::NotSelected
511 | DisqualifiedReason::NotTargeted
512 | DisqualifiedReason::RolloutsOptOut,
513 )
514 {
515 let updated_enrollment = evaluate_enrollment(
516 available_randomization_units,
517 updated_experiment,
518 targeting_helper,
519 )?;
520 match updated_enrollment.status {
521 EnrollmentStatus::Enrolled { .. } => {
522 out_enrollment_events.push(
523 updated_enrollment.get_change_event(Some(updated_experiment)),
524 );
525 updated_enrollment
526 }
527 _ => self.clone(),
528 }
529 } else {
530 self.clone()
531 }
532 }
533 EnrollmentStatus::WasEnrolled { .. } => self.clone(),
534 })
535 }
536
537 fn on_experiment_ended(
543 &self,
544 experiment: &Experiment,
545 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
546 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
547 ) -> Option<Self> {
548 debug!(
549 "Experiment '{}' vanished while we had enrollment status of {:?}",
550 self.slug, self
551 );
552 let branch = match self.status {
553 EnrollmentStatus::Enrolled { ref branch, .. }
554 | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
555 EnrollmentStatus::NotEnrolled { .. }
556 | EnrollmentStatus::WasEnrolled { .. }
557 | EnrollmentStatus::Error { .. } => return None, };
559 #[cfg(feature = "stateful")]
560 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
561
562 let enrollment = Self {
563 slug: self.slug.clone(),
564 status: EnrollmentStatus::WasEnrolled {
565 branch: branch.to_owned(),
566 experiment_ended_at: now_secs(),
567 },
568 };
569 out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
570 Some(enrollment)
571 }
572
573 #[allow(clippy::unnecessary_wraps)]
575 #[cfg_attr(not(feature = "stateful"), allow(unused))]
576 pub(crate) fn on_explicit_opt_out(
577 &self,
578 experiment: Option<&Experiment>,
579 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
580 reason: DisqualifiedReason,
581 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
582 ) -> ExperimentEnrollment {
583 match self.status {
584 EnrollmentStatus::Enrolled { .. } => {
585 #[cfg(feature = "stateful")]
586 self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
587
588 let enrollment = self.disqualify_from_enrolled(reason);
589 out_enrollment_events.push(enrollment.get_change_event(experiment));
590 enrollment
591 }
592 EnrollmentStatus::NotEnrolled { .. }
593 | EnrollmentStatus::Disqualified { .. }
594 | EnrollmentStatus::WasEnrolled { .. }
595 | EnrollmentStatus::Error { .. } => {
596 self.clone()
598 }
599 }
600 }
601
602 #[cfg(feature = "stateful")]
603 pub(crate) fn on_pref_unenroll(
604 &self,
605 pref_unenroll_reason: PrefUnenrollReason,
606 experiment: Option<&Experiment>,
607 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
608 ) -> ExperimentEnrollment {
609 match self.status {
610 EnrollmentStatus::Enrolled { .. } => {
611 let enrollment =
612 self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
613 reason: pref_unenroll_reason,
614 });
615 out_enrollment_events.push(enrollment.get_change_event(experiment));
616 enrollment
617 }
618 _ => self.clone(),
619 }
620 }
621
622 #[cfg(feature = "stateful")]
624 pub(crate) fn on_add_gecko_pref_states(
625 &self,
626 prev_gecko_pref_states: Vec<PreviousGeckoPrefState>,
627 ) -> ExperimentEnrollment {
628 let mut next = self.clone();
629 if let EnrollmentStatus::Enrolled { reason, branch, .. } = &self.status {
630 next.status = EnrollmentStatus::Enrolled {
631 prev_gecko_pref_states: Some(prev_gecko_pref_states),
632 reason: reason.clone(),
633 branch: branch.clone(),
634 };
635 }
636 next
637 }
638
639 #[cfg(feature = "stateful")]
640 pub(crate) fn maybe_revert_unchanged_gecko_pref_states(
642 &self,
643 non_reverting_pref_name: &str,
644 gecko_pref_store: Option<&GeckoPrefStore>,
645 ) {
646 if let EnrollmentStatus::Enrolled {
647 prev_gecko_pref_states: Some(prev_gecko_pref_states),
648 ..
649 } = &self.status
650 {
651 PreviousGeckoPrefState::on_partially_revert_to_prev_gecko_pref_states(
652 prev_gecko_pref_states,
653 non_reverting_pref_name,
654 gecko_pref_store,
655 );
656 }
657 }
658
659 #[cfg(feature = "stateful")]
660 pub(crate) fn maybe_revert_all_gecko_pref_states(
662 &self,
663 gecko_pref_store: Option<&GeckoPrefStore>,
664 ) {
665 if let EnrollmentStatus::Enrolled {
666 prev_gecko_pref_states: Some(prev_gecko_pref_states),
667 ..
668 } = &self.status
669 {
670 PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
671 prev_gecko_pref_states,
672 gecko_pref_store,
673 );
674 }
675 }
676
677 #[cfg_attr(not(feature = "stateful"), allow(unused))]
683 pub fn reset_telemetry_identifiers(
684 &self,
685 experiment: Option<&Experiment>,
686 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
687 ) -> Self {
688 let updated = match self.status {
689 EnrollmentStatus::Enrolled { .. } => {
690 if let Some(experiment) = experiment
691 && experiment.is_firefox_labs_opt_in
692 {
693 self.clone()
695 } else {
696 let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
697 out_enrollment_events.push(disqualified.get_change_event(experiment));
698 disqualified
699 }
700 }
701 EnrollmentStatus::NotEnrolled { .. }
702 | EnrollmentStatus::Disqualified { .. }
703 | EnrollmentStatus::WasEnrolled { .. }
704 | EnrollmentStatus::Error { .. } => self.clone(),
705 };
706 ExperimentEnrollment {
707 status: updated.status.clone(),
708 ..updated
709 }
710 }
711
712 fn maybe_garbage_collect(&self) -> Option<Self> {
715 if let EnrollmentStatus::WasEnrolled {
716 experiment_ended_at,
717 ..
718 } = self.status
719 {
720 let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
721 if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
722 return Some(self.clone());
723 }
724 }
725 debug!("Garbage collecting enrollment '{}'", self.slug);
726 None
727 }
728
729 fn get_change_event(&self, experiment: Option<&Experiment>) -> EnrollmentChangeEvent {
732 match &self.status {
733 EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
734 &self.slug,
735 branch,
736 None,
737 EnrollmentChangeEventType::Enrollment,
738 experiment,
739 ),
740 EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
741 &self.slug,
742 branch,
743 None,
744 EnrollmentChangeEventType::Unenrollment,
745 experiment,
746 ),
747 EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
748 &self.slug,
749 branch,
750 Some(reason.for_enrollment_change_event()),
751 EnrollmentChangeEventType::Disqualification,
752 experiment,
753 ),
754 EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
755 unreachable!()
756 }
757 }
758 }
759
760 fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
762 match self.status {
763 EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
764 status: EnrollmentStatus::Disqualified {
765 reason,
766 branch: branch.to_owned(),
767 },
768 ..self.clone()
769 },
770 EnrollmentStatus::NotEnrolled { .. }
771 | EnrollmentStatus::Disqualified { .. }
772 | EnrollmentStatus::WasEnrolled { .. }
773 | EnrollmentStatus::Error { .. } => self.clone(),
774 }
775 }
776
777 #[cfg(feature = "stateful")]
778 pub(crate) fn will_pref_experiment_change(
779 &self,
780 updated_experiment: &Experiment,
781 updated_enrollment: &ExperimentEnrollment,
782 ) -> bool {
783 let EnrollmentStatus::Enrolled {
784 prev_gecko_pref_states: Some(original_prev_gecko_pref_states),
785 branch: original_branch_slug,
786 ..
787 } = &self.status
788 else {
789 return false;
791 };
792
793 let EnrollmentStatus::Enrolled {
794 branch: updated_branch_slug,
795 ..
796 } = &updated_enrollment.status
797 else {
798 return true;
800 };
801
802 if updated_branch_slug != original_branch_slug {
804 return true;
805 }
806
807 let Some(updated_branch) = updated_experiment.get_branch(updated_branch_slug) else {
809 return true;
810 };
811
812 let original_feature_ids: HashSet<&String> = original_prev_gecko_pref_states
813 .iter()
814 .map(|state| &state.feature_id)
815 .collect();
816 let updated_features = updated_branch.get_feature_configs();
817
818 if updated_features.len() != original_feature_ids.len() {
820 return true;
821 }
822
823 for original_state in original_prev_gecko_pref_states {
824 let Some(updated_feature) = updated_features
825 .iter()
826 .find(|config| config.feature_id == original_state.feature_id)
827 else {
828 return true;
830 };
831
832 if !updated_feature.value.contains_key(&original_state.variable) {
834 return true;
835 }
836 }
837 false
838 }
839}
840
841#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
844pub enum EnrollmentStatus {
845 Enrolled {
846 reason: EnrolledReason,
847 branch: String,
848 #[cfg(feature = "stateful")]
849 #[serde(skip_serializing_if = "Option::is_none")]
850 prev_gecko_pref_states: Option<Vec<PreviousGeckoPrefState>>,
851 },
852 NotEnrolled {
853 reason: NotEnrolledReason,
854 },
855 Disqualified {
856 reason: DisqualifiedReason,
857 branch: String,
858 },
859 WasEnrolled {
860 branch: String,
861 experiment_ended_at: u64, },
863 Error {
865 reason: String,
868 },
869}
870
871impl EnrollmentStatus {
872 pub fn name(&self) -> String {
873 match self {
874 EnrollmentStatus::Enrolled { .. } => "Enrolled",
875 EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
876 EnrollmentStatus::Disqualified { .. } => "Disqualified",
877 EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
878 EnrollmentStatus::Error { .. } => "Error",
879 }
880 .into()
881 }
882
883 pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
886 EnrollmentStatus::Enrolled {
887 reason,
888 branch: branch.to_owned(),
889 #[cfg(feature = "stateful")]
890 prev_gecko_pref_states: None,
891 }
892 }
893
894 pub fn is_enrolled(&self) -> bool {
895 matches!(self, EnrollmentStatus::Enrolled { .. })
896 }
897
898 pub fn is_enrolled_with_reason(&self, expected_reason: EnrolledReason) -> bool {
899 matches!(
900 self,
901 EnrollmentStatus::Enrolled { reason, ..}
902 if *reason == expected_reason
903 )
904 }
905}
906
907pub(crate) trait ExperimentMetadata {
908 fn get_slug(&self) -> String;
909
910 fn is_rollout(&self) -> bool;
911}
912
913pub(crate) struct EnrollmentsEvolver<'a> {
914 available_randomization_units: &'a AvailableRandomizationUnits,
915 targeting_helper: &'a mut NimbusTargetingHelper,
916 coenrolling_feature_ids: &'a HashSet<&'a str>,
917}
918
919impl<'a> EnrollmentsEvolver<'a> {
920 pub(crate) fn new(
921 available_randomization_units: &'a AvailableRandomizationUnits,
922 targeting_helper: &'a mut NimbusTargetingHelper,
923 coenrolling_feature_ids: &'a HashSet<&str>,
924 ) -> Self {
925 Self {
926 available_randomization_units,
927 targeting_helper,
928 coenrolling_feature_ids,
929 }
930 }
931
932 pub(crate) fn evolve_enrollments(
933 &mut self,
934 participation: Participation,
935 prev_experiments: &[Experiment],
936 next_experiments: &[Experiment],
937 prev_enrollments: &[ExperimentEnrollment],
938 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
939 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
940 let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
941 let mut events: Vec<EnrollmentChangeEvent> = Default::default();
942
943 let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
946 prev_experiments,
947 prev_enrollments,
948 ExperimentMetadata::is_rollout,
949 );
950 let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
951
952 let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
953 participation.in_rollouts,
954 &prev_rollouts,
955 &next_rollouts,
956 &ro_enrollments,
957 #[cfg(feature = "stateful")]
958 gecko_pref_store,
959 )?;
960
961 enrollments.extend(next_ro_enrollments);
962 events.extend(ro_events);
963
964 let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
965
966 let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
971 let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
972 let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
973 .iter()
974 .filter(|e| !ro_slugs.contains(&e.slug))
975 .map(|e| e.to_owned())
976 .collect();
977
978 let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
979 participation.in_experiments,
980 &prev_experiments,
981 &next_experiments,
982 &prev_enrollments,
983 #[cfg(feature = "stateful")]
984 gecko_pref_store,
985 )?;
986
987 enrollments.extend(next_exp_enrollments);
988 events.extend(exp_events);
989
990 Ok((enrollments, events))
991 }
992
993 pub(crate) fn evolve_enrollment_recipes(
996 &mut self,
997 is_user_participating: bool,
998 prev_experiments: &[Experiment],
999 next_experiments: &[Experiment],
1000 prev_enrollments: &[ExperimentEnrollment],
1001 #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1002 ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
1003 let mut enrollment_events = vec![];
1004 let prev_experiments_map = map_experiments(prev_experiments);
1005 let next_experiments_map = map_experiments(next_experiments);
1006 let prev_enrollments_map = map_enrollments(prev_enrollments);
1007
1008 let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
1011 let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
1012
1013 let mut next_enrollments = Vec::with_capacity(next_experiments.len());
1014
1015 for prev_enrollment in prev_enrollments {
1022 if matches!(
1023 &prev_enrollment.status,
1024 EnrollmentStatus::NotEnrolled {
1025 reason: NotEnrolledReason::FeatureConflict { conflict_slug },
1026 }
1027 if conflict_slug.is_some()
1028 ) {
1029 continue;
1030 }
1031 let slug = &prev_enrollment.slug;
1032
1033 let next_enrollment = match self.evolve_enrollment(
1034 is_user_participating,
1035 prev_experiments_map.get(slug).copied(),
1036 next_experiments_map.get(slug).copied(),
1037 Some(prev_enrollment),
1038 &mut enrollment_events,
1039 #[cfg(feature = "stateful")]
1040 gecko_pref_store,
1041 ) {
1042 Ok(enrollment) => enrollment,
1043 Err(e) => {
1044 warn!(
1050 "{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ",
1051 e, slug, prev_enrollment
1052 );
1053 None
1054 }
1055 };
1056
1057 #[cfg(feature = "stateful")]
1058 if let Some(ref enrollment) = next_enrollment.clone() {
1059 if self.targeting_helper.update_enrollment(enrollment) {
1060 debug!("Enrollment updated for {}", enrollment.slug);
1061 } else {
1062 debug!("Enrollment unchanged for {}", enrollment.slug);
1063 }
1064 }
1065
1066 self.reserve_enrolled_features(
1067 next_enrollment,
1068 &next_experiments_map,
1069 &mut enrolled_features,
1070 &mut coenrolling_features,
1071 &mut next_enrollments,
1072 );
1073 }
1074
1075 let next_experiments = sort_experiments_by_published_date(next_experiments);
1078 for next_experiment in next_experiments {
1079 let slug = &next_experiment.slug;
1080
1081 let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
1087 .get_feature_ids()
1088 .iter()
1089 .filter_map(|id| enrolled_features.get(id))
1090 .collect();
1091 if !needed_features_in_use.is_empty() {
1092 let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
1093 if is_our_experiment {
1094 assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
1098 } else {
1101 next_enrollments.push(ExperimentEnrollment {
1104 slug: slug.clone(),
1105 status: EnrollmentStatus::NotEnrolled {
1106 reason: NotEnrolledReason::FeatureConflict {
1107 conflict_slug: Some(needed_features_in_use[0].slug.clone()),
1108 },
1109 },
1110 });
1111
1112 enrollment_events.push(EnrollmentChangeEvent {
1113 experiment_slug: slug.clone(),
1114 branch_slug: "N/A".to_string(),
1115 reason: Some("feature-conflict".to_string()),
1116 change: EnrollmentChangeEventType::EnrollFailed,
1117 feature_ids: next_experiment.get_feature_ids(),
1118 })
1119 }
1120 continue;
1126 }
1127
1128 let prev_enrollment = prev_enrollments_map.get(slug).copied();
1133
1134 if prev_enrollment.is_none()
1135 || matches!(
1136 &prev_enrollment.unwrap().status,
1137 EnrollmentStatus::NotEnrolled {
1138 reason: NotEnrolledReason::FeatureConflict { conflict_slug }
1139 }
1140 if conflict_slug.is_some()
1141 )
1142 {
1143 let next_enrollment = match self.evolve_enrollment(
1144 is_user_participating,
1145 prev_experiments_map.get(slug).copied(),
1146 Some(next_experiment),
1147 prev_enrollment,
1148 &mut enrollment_events,
1149 #[cfg(feature = "stateful")]
1150 gecko_pref_store,
1151 ) {
1152 Ok(enrollment) => enrollment,
1153 Err(e) => {
1154 warn!(
1160 "{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ",
1161 e, slug, prev_enrollment
1162 );
1163 None
1164 }
1165 };
1166
1167 #[cfg(feature = "stateful")]
1168 if let Some(ref enrollment) = next_enrollment.clone() {
1169 if self.targeting_helper.update_enrollment(enrollment) {
1170 debug!("Enrollment updated for {}", enrollment.slug);
1171 } else {
1172 debug!("Enrollment unchanged for {}", enrollment.slug);
1173 }
1174 }
1175
1176 self.reserve_enrolled_features(
1177 next_enrollment,
1178 &next_experiments_map,
1179 &mut enrolled_features,
1180 &mut coenrolling_features,
1181 &mut next_enrollments,
1182 );
1183 }
1184 }
1185
1186 enrolled_features.extend(coenrolling_features);
1187
1188 let updated_enrolled_features = map_features(
1192 &next_enrollments,
1193 &next_experiments_map,
1194 self.coenrolling_feature_ids,
1195 );
1196 if enrolled_features != updated_enrolled_features {
1197 Err(NimbusError::InternalError(
1198 "Next enrollment calculation error",
1199 ))
1200 } else {
1201 Ok((next_enrollments, enrollment_events))
1202 }
1203 }
1204
1205 fn reserve_enrolled_features(
1207 &self,
1208 latest_enrollment: Option<ExperimentEnrollment>,
1209 experiments: &HashMap<String, &Experiment>,
1210 enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
1211 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1212 enrollments: &mut Vec<ExperimentEnrollment>,
1213 ) {
1214 if let Some(enrollment) = latest_enrollment {
1215 for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
1219 populate_feature_maps(
1220 enrolled_feature,
1221 self.coenrolling_feature_ids,
1222 enrolled_features,
1223 coenrolling_features,
1224 );
1225 }
1226 enrollments.push(enrollment);
1228 }
1229 }
1230
1231 pub(crate) fn evolve_enrollment(
1241 &mut self,
1242 is_user_participating: bool,
1243 prev_experiment: Option<&Experiment>,
1244 next_experiment: Option<&Experiment>,
1245 prev_enrollment: Option<&ExperimentEnrollment>,
1246 out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1248 ) -> Result<Option<ExperimentEnrollment>> {
1249 let is_already_enrolled = prev_enrollment
1250 .map(|e| e.status.is_enrolled())
1251 .unwrap_or_default();
1252
1253 let targeting_helper = self
1258 .targeting_helper
1259 .put("is_already_enrolled", is_already_enrolled);
1260
1261 Ok(match (prev_experiment, next_experiment, prev_enrollment) {
1262 (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
1264 is_user_participating,
1265 self.available_randomization_units,
1266 experiment,
1267 &targeting_helper,
1268 out_enrollment_events,
1269 )?),
1270 (Some(prev_experiment), None, Some(enrollment)) => enrollment.on_experiment_ended(
1272 prev_experiment,
1273 #[cfg(feature = "stateful")]
1274 gecko_pref_store,
1275 out_enrollment_events,
1276 ),
1277 (Some(_), Some(experiment), Some(enrollment)) => {
1279 Some(enrollment.on_experiment_updated(
1280 is_user_participating,
1281 self.available_randomization_units,
1282 experiment,
1283 &targeting_helper,
1284 #[cfg(feature = "stateful")]
1285 gecko_pref_store,
1286 out_enrollment_events,
1287 )?)
1288 }
1289 (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
1290 (None, Some(_), Some(_)) => {
1291 return Err(NimbusError::InternalError(
1292 "New experiment but enrollment already exists.",
1293 ));
1294 }
1295 (Some(_), None, None) | (Some(_), Some(_), None) => {
1296 return Err(NimbusError::InternalError(
1297 "Experiment in the db did not have an associated enrollment record.",
1298 ));
1299 }
1300 (None, None, None) => {
1301 return Err(NimbusError::InternalError(
1302 "evolve_experiment called with nothing that could evolve or be evolved",
1303 ));
1304 }
1305 })
1306 }
1307}
1308
1309fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
1310where
1311 E: ExperimentMetadata + Clone,
1312{
1313 let mut map_experiments = HashMap::with_capacity(experiments.len());
1314 for e in experiments {
1315 map_experiments.insert(e.get_slug(), e);
1316 }
1317 map_experiments
1318}
1319
1320pub fn map_enrollments(
1321 enrollments: &[ExperimentEnrollment],
1322) -> HashMap<String, &ExperimentEnrollment> {
1323 let mut map_enrollments = HashMap::with_capacity(enrollments.len());
1324 for e in enrollments {
1325 map_enrollments.insert(e.slug.clone(), e);
1326 }
1327 map_enrollments
1328}
1329
1330pub(crate) fn filter_experiments_and_enrollments<E>(
1331 experiments: &[E],
1332 enrollments: &[ExperimentEnrollment],
1333 filter_fn: fn(&E) -> bool,
1334) -> (Vec<E>, Vec<ExperimentEnrollment>)
1335where
1336 E: ExperimentMetadata + Clone,
1337{
1338 let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
1339
1340 let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
1341
1342 let enrollments: Vec<ExperimentEnrollment> = enrollments
1343 .iter()
1344 .filter(|e| slugs.contains(&e.slug))
1345 .map(|e| e.to_owned())
1346 .collect();
1347
1348 (experiments, enrollments)
1349}
1350
1351fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1352where
1353 E: ExperimentMetadata + Clone,
1354{
1355 experiments
1356 .iter()
1357 .filter(|e| filter_fn(e))
1358 .cloned()
1359 .collect()
1360}
1361
1362pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1363 let mut experiments: Vec<_> = experiments.iter().collect();
1364 experiments.sort_by_key(|e| e.published_date);
1365 experiments
1366}
1367
1368fn map_features(
1371 enrollments: &[ExperimentEnrollment],
1372 experiments: &HashMap<String, &Experiment>,
1373 coenrolling_ids: &HashSet<&str>,
1374) -> HashMap<String, EnrolledFeatureConfig> {
1375 let mut colliding_features = HashMap::with_capacity(enrollments.len());
1376 let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1377 for enrolled_feature_config in enrollments
1378 .iter()
1379 .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1380 {
1381 populate_feature_maps(
1382 enrolled_feature_config,
1383 coenrolling_ids,
1384 &mut colliding_features,
1385 &mut coenrolling_features,
1386 );
1387 }
1388 colliding_features.extend(coenrolling_features.drain());
1389
1390 colliding_features
1391}
1392
1393pub fn map_features_by_feature_id(
1394 enrollments: &[ExperimentEnrollment],
1395 experiments: &[Experiment],
1396 coenrolling_ids: &HashSet<&str>,
1397) -> HashMap<String, EnrolledFeatureConfig> {
1398 let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1399 experiments,
1400 enrollments,
1401 ExperimentMetadata::is_rollout,
1402 );
1403 let (experiments, exp_enrollments) =
1404 filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1405
1406 let features_under_rollout = map_features(
1407 &ro_enrollments,
1408 &map_experiments(&rollouts),
1409 coenrolling_ids,
1410 );
1411 let features_under_experiment = map_features(
1412 &exp_enrollments,
1413 &map_experiments(&experiments),
1414 coenrolling_ids,
1415 );
1416
1417 features_under_experiment
1418 .defaults(&features_under_rollout)
1419 .unwrap()
1420}
1421
1422pub(crate) fn populate_feature_maps(
1423 enrolled_feature: EnrolledFeatureConfig,
1424 coenrolling_feature_ids: &HashSet<&str>,
1425 colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1426 coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1427) {
1428 let feature_id = &enrolled_feature.feature_id;
1429 if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1430 colliding_features.insert(feature_id.clone(), enrolled_feature);
1433 } else if let Some(existing) = coenrolling_features.get(feature_id) {
1434 let merged = enrolled_feature
1438 .defaults(existing)
1439 .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1440
1441 let merged = EnrolledFeatureConfig {
1444 slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1446 branch: None,
1447 ..merged
1448 };
1449 coenrolling_features.insert(feature_id.clone(), merged);
1450 } else {
1451 coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1453 }
1454}
1455
1456fn get_enrolled_feature_configs(
1457 enrollment: &ExperimentEnrollment,
1458 experiments: &HashMap<String, &Experiment>,
1459) -> Vec<EnrolledFeatureConfig> {
1460 let branch_slug = match &enrollment.status {
1462 EnrollmentStatus::Enrolled { branch, .. } => branch,
1463 _ => return Vec::new(),
1464 };
1465
1466 let experiment_slug = &enrollment.slug;
1467
1468 let experiment = match experiments.get(experiment_slug).copied() {
1469 Some(exp) => exp,
1470 _ => return Vec::new(),
1471 };
1472
1473 let mut branch_features = match &experiment.get_branch(branch_slug) {
1476 Some(branch) => branch.get_feature_configs(),
1477 _ => Default::default(),
1478 };
1479
1480 branch_features.iter_mut().for_each(|f| {
1481 json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1482 });
1483
1484 let branch_feature_ids = &branch_features
1485 .iter()
1486 .map(|f| &f.feature_id)
1487 .collect::<HashSet<_>>();
1488
1489 let non_branch_features: Vec<FeatureConfig> = experiment
1493 .get_feature_ids()
1494 .into_iter()
1495 .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1496 .map(|feature_id| FeatureConfig {
1497 feature_id,
1498 ..Default::default()
1499 })
1500 .collect();
1501
1502 branch_features
1505 .iter()
1506 .chain(non_branch_features.iter())
1507 .map(|f| EnrolledFeatureConfig {
1508 feature: f.to_owned(),
1509 slug: experiment_slug.clone(),
1510 branch: if !experiment.is_rollout() {
1511 Some(branch_slug.clone())
1512 } else {
1513 None
1514 },
1515 feature_id: f.feature_id.clone(),
1516 })
1517 .collect()
1518}
1519
1520#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1524#[serde(rename_all = "camelCase")]
1525pub struct EnrolledFeatureConfig {
1526 pub feature: FeatureConfig,
1527 pub slug: String,
1528 pub branch: Option<String>,
1529 pub feature_id: String,
1530}
1531
1532impl Defaults for EnrolledFeatureConfig {
1533 fn defaults(&self, fallback: &Self) -> Result<Self> {
1534 if self.feature_id != fallback.feature_id {
1535 Err(NimbusError::InternalError(
1537 "Cannot merge enrolled feature configs from different features",
1538 ))
1539 } else {
1540 Ok(Self {
1541 slug: self.slug.to_owned(),
1542 feature_id: self.feature_id.to_owned(),
1543 feature: self.feature.defaults(&fallback.feature)?,
1545 branch: self.branch.to_owned(),
1549 })
1550 }
1551 }
1552}
1553
1554impl ExperimentMetadata for EnrolledFeatureConfig {
1555 fn get_slug(&self) -> String {
1556 self.slug.clone()
1557 }
1558
1559 fn is_rollout(&self) -> bool {
1560 self.branch.is_none()
1561 }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq)]
1565pub struct EnrolledFeature {
1566 pub slug: String,
1567 pub branch: Option<String>,
1568 pub feature_id: String,
1569}
1570
1571impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1572 fn from(value: &EnrolledFeatureConfig) -> Self {
1573 Self {
1574 slug: value.slug.clone(),
1575 branch: value.branch.clone(),
1576 feature_id: value.feature_id.clone(),
1577 }
1578 }
1579}
1580
1581#[derive(Serialize, Deserialize, Debug, Clone)]
1582#[cfg_attr(test, derive(Eq, PartialEq))]
1583pub struct EnrollmentChangeEvent {
1584 pub experiment_slug: String,
1585 pub branch_slug: String,
1586 pub reason: Option<String>,
1587 pub change: EnrollmentChangeEventType,
1588 pub feature_ids: Vec<String>,
1589}
1590
1591impl EnrollmentChangeEvent {
1592 pub(crate) fn new(
1593 slug: &str,
1594 branch: &str,
1595 reason: Option<&str>,
1596 change: EnrollmentChangeEventType,
1597 experiment: Option<&Experiment>,
1598 ) -> Self {
1599 Self {
1600 experiment_slug: slug.to_owned(),
1601 branch_slug: branch.to_owned(),
1602 reason: reason.map(|s| s.to_owned()),
1603 change,
1604 feature_ids: experiment.map(|e| e.get_feature_ids()).unwrap_or_default(),
1605 }
1606 }
1607}
1608
1609#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1610pub enum EnrollmentChangeEventType {
1611 Enrollment,
1612 EnrollFailed,
1613 Disqualification,
1614 Unenrollment,
1615 #[cfg_attr(not(feature = "stateful"), allow(unused))]
1616 UnenrollFailed,
1617}
1618
1619pub(crate) fn now_secs() -> u64 {
1620 SystemTime::now()
1621 .duration_since(UNIX_EPOCH)
1622 .expect("Current date before Unix Epoch.")
1623 .as_secs()
1624}