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