nimbus/
enrollment.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4#[cfg(feature = "stateful")]
5use crate::stateful::gecko_prefs::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// These are types we use internally for managing enrollments.
23// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
24// ⚠️ in `mod test_schema_bw_compat` below, and may require a DB migration. ⚠️
25#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
26pub enum EnrolledReason {
27    /// A normal enrollment as per the experiment's rules.
28    Qualified,
29    /// Explicit opt-in.
30    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// These are types we use internally for managing non-enrollments.
46
47// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
48// ⚠️ in `mod test_schema_bw_compat` below, and may require a DB migration. ⚠️
49#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
50pub enum NotEnrolledReason {
51    /// The user opted-out of experiments before we ever got enrolled to this one.
52    OptOut,
53    /// The evaluator bucketing did not choose us.
54    NotSelected,
55    /// We are not being targeted for this experiment.
56    NotTargeted,
57    /// The experiment enrollment is paused.
58    EnrollmentsPaused,
59    /// The experiment used a feature that was already under experiment.
60    FeatureConflict,
61}
62
63impl Display for NotEnrolledReason {
64    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
65        Display::fmt(
66            match self {
67                NotEnrolledReason::OptOut => "OptOut",
68                NotEnrolledReason::NotSelected => "NotSelected",
69                NotEnrolledReason::NotTargeted => "NotTargeted",
70                NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
71                NotEnrolledReason::FeatureConflict => "FeatureConflict",
72            },
73            f,
74        )
75    }
76}
77
78// These are types we use internally for managing disqualifications.
79
80// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
81// ⚠️ in `mod test_schema_bw_compat` below, and may require a DB migration. ⚠️
82#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
83pub enum DisqualifiedReason {
84    /// There was an error.
85    Error,
86    /// The user opted-out from this experiment or experiments in general.
87    OptOut,
88    /// The targeting has changed for an experiment.
89    NotTargeted,
90    /// The bucketing has changed for an experiment.
91    NotSelected,
92    /// A pref used in the experiment was set by the user.
93    #[cfg(feature = "stateful")]
94    PrefUnenrollReason { reason: PrefUnenrollReason },
95}
96
97impl Display for DisqualifiedReason {
98    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
99        Display::fmt(
100            match self {
101                DisqualifiedReason::Error => "Error",
102                DisqualifiedReason::OptOut => "OptOut",
103                DisqualifiedReason::NotSelected => "NotSelected",
104                DisqualifiedReason::NotTargeted => "NotTargeted",
105                #[cfg(feature = "stateful")]
106                DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
107                    PrefUnenrollReason::Changed => "PrefChanged",
108                    PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
109                },
110            },
111            f,
112        )
113    }
114}
115
116// Every experiment has an ExperimentEnrollment, even when we aren't enrolled.
117
118// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
119// ⚠️ in `mod test_schema_bw_compat` below, and may require a DB migration. ⚠️
120#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
121pub struct ExperimentEnrollment {
122    pub slug: String,
123    pub status: EnrollmentStatus,
124}
125
126impl ExperimentEnrollment {
127    /// Evaluate an experiment enrollment for an experiment
128    /// we are seeing for the first time.
129    fn from_new_experiment(
130        is_user_participating: bool,
131        available_randomization_units: &AvailableRandomizationUnits,
132        experiment: &Experiment,
133        targeting_helper: &NimbusTargetingHelper,
134        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
135    ) -> Result<Self> {
136        Ok(if !is_user_participating {
137            Self {
138                slug: experiment.slug.clone(),
139                status: EnrollmentStatus::NotEnrolled {
140                    reason: NotEnrolledReason::OptOut,
141                },
142            }
143        } else if experiment.is_enrollment_paused {
144            Self {
145                slug: experiment.slug.clone(),
146                status: EnrollmentStatus::NotEnrolled {
147                    reason: NotEnrolledReason::EnrollmentsPaused,
148                },
149            }
150        } else {
151            let enrollment =
152                evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
153            debug!(
154                "Experiment '{}' is new - enrollment status is {:?}",
155                &enrollment.slug, &enrollment
156            );
157            if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
158                out_enrollment_events.push(enrollment.get_change_event())
159            }
160            enrollment
161        })
162    }
163
164    /// Force enroll ourselves in an experiment.
165    #[cfg_attr(not(feature = "stateful"), allow(unused))]
166    pub(crate) fn from_explicit_opt_in(
167        experiment: &Experiment,
168        branch_slug: &str,
169        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
170    ) -> Result<Self> {
171        if !experiment.has_branch(branch_slug) {
172            out_enrollment_events.push(EnrollmentChangeEvent {
173                experiment_slug: experiment.slug.to_string(),
174                branch_slug: branch_slug.to_string(),
175                reason: Some("does-not-exist".to_string()),
176                change: EnrollmentChangeEventType::EnrollFailed,
177            });
178
179            return Err(NimbusError::NoSuchBranch(
180                branch_slug.to_owned(),
181                experiment.slug.clone(),
182            ));
183        }
184        let enrollment = Self {
185            slug: experiment.slug.clone(),
186            status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
187        };
188        out_enrollment_events.push(enrollment.get_change_event());
189        Ok(enrollment)
190    }
191
192    /// Update our enrollment to an experiment we have seen before.
193    #[allow(clippy::too_many_arguments)]
194    fn on_experiment_updated(
195        &self,
196        is_user_participating: bool,
197        available_randomization_units: &AvailableRandomizationUnits,
198        updated_experiment: &Experiment,
199        targeting_helper: &NimbusTargetingHelper,
200        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
201    ) -> Result<Self> {
202        Ok(match &self.status {
203            EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
204                if !is_user_participating || updated_experiment.is_enrollment_paused {
205                    self.clone()
206                } else {
207                    let updated_enrollment = evaluate_enrollment(
208                        available_randomization_units,
209                        updated_experiment,
210                        targeting_helper,
211                    )?;
212                    debug!(
213                        "Experiment '{}' with enrollment {:?} is now {:?}",
214                        &self.slug, &self, updated_enrollment
215                    );
216                    if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
217                        out_enrollment_events.push(updated_enrollment.get_change_event());
218                    }
219                    updated_enrollment
220                }
221            }
222
223            EnrollmentStatus::Enrolled {
224                ref branch,
225                ref reason,
226                ..
227            } => {
228                if !is_user_participating {
229                    debug!(
230                        "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
231                        &self.slug
232                    );
233                    let updated_enrollment =
234                        self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
235                    out_enrollment_events.push(updated_enrollment.get_change_event());
236                    updated_enrollment
237                } else if !updated_experiment.has_branch(branch) {
238                    // The branch we were in disappeared!
239                    let updated_enrollment =
240                        self.disqualify_from_enrolled(DisqualifiedReason::Error);
241                    out_enrollment_events.push(updated_enrollment.get_change_event());
242                    updated_enrollment
243                } else if matches!(reason, EnrolledReason::OptIn) {
244                    // we check if we opted-in an experiment, if so
245                    // we don't need to update our enrollment
246                    self.clone()
247                } else {
248                    let evaluated_enrollment = evaluate_enrollment(
249                        available_randomization_units,
250                        updated_experiment,
251                        targeting_helper,
252                    )?;
253                    match evaluated_enrollment.status {
254                        EnrollmentStatus::Error { .. } => {
255                            let updated_enrollment =
256                                self.disqualify_from_enrolled(DisqualifiedReason::Error);
257                            out_enrollment_events.push(updated_enrollment.get_change_event());
258                            updated_enrollment
259                        }
260                        EnrollmentStatus::NotEnrolled {
261                            reason: NotEnrolledReason::NotTargeted,
262                        } => {
263                            debug!("Existing experiment enrollment '{}' is now disqualified (targeting change)", &self.slug);
264                            let updated_enrollment =
265                                self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
266                            out_enrollment_events.push(updated_enrollment.get_change_event());
267                            updated_enrollment
268                        }
269                        EnrollmentStatus::NotEnrolled {
270                            reason: NotEnrolledReason::NotSelected,
271                        } => {
272                            // In the case of a rollout being scaled back, we should be disqualified with NotSelected.
273                            //
274                            let updated_enrollment =
275                                self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
276                            out_enrollment_events.push(updated_enrollment.get_change_event());
277                            updated_enrollment
278                        }
279                        EnrollmentStatus::NotEnrolled { .. }
280                        | EnrollmentStatus::Enrolled { .. }
281                        | EnrollmentStatus::Disqualified { .. }
282                        | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
283                    }
284                }
285            }
286            EnrollmentStatus::Disqualified {
287                ref branch, reason, ..
288            } => {
289                if !is_user_participating {
290                    debug!(
291                        "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
292                        &self.slug
293                    );
294                    Self {
295                        slug: self.slug.clone(),
296                        status: EnrollmentStatus::Disqualified {
297                            reason: DisqualifiedReason::OptOut,
298                            branch: branch.clone(),
299                        },
300                    }
301                } else if updated_experiment.is_rollout
302                    && matches!(
303                        reason,
304                        DisqualifiedReason::NotSelected | DisqualifiedReason::NotTargeted,
305                    )
306                {
307                    let evaluated_enrollment = evaluate_enrollment(
308                        available_randomization_units,
309                        updated_experiment,
310                        targeting_helper,
311                    )?;
312                    match evaluated_enrollment.status {
313                        EnrollmentStatus::Enrolled { .. } => evaluated_enrollment,
314                        _ => self.clone(),
315                    }
316                } else {
317                    self.clone()
318                }
319            }
320            EnrollmentStatus::WasEnrolled { .. } => self.clone(),
321        })
322    }
323
324    /// Transition our enrollment to WasEnrolled (Option::Some) or delete it (Option::None)
325    /// after an experiment has disappeared from the server.
326    ///
327    /// If we transitioned to WasEnrolled, our enrollment will be garbage collected
328    /// from the database after `PREVIOUS_ENROLLMENTS_GC_TIME`.
329    fn on_experiment_ended(
330        &self,
331        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
332    ) -> Option<Self> {
333        debug!(
334            "Experiment '{}' vanished while we had enrollment status of {:?}",
335            self.slug, self
336        );
337        let branch = match self.status {
338            EnrollmentStatus::Enrolled { ref branch, .. }
339            | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
340            EnrollmentStatus::NotEnrolled { .. }
341            | EnrollmentStatus::WasEnrolled { .. }
342            | EnrollmentStatus::Error { .. } => return None, // We were never enrolled anyway, simply delete the enrollment record from the DB.
343        };
344        let enrollment = Self {
345            slug: self.slug.clone(),
346            status: EnrollmentStatus::WasEnrolled {
347                branch: branch.to_owned(),
348                experiment_ended_at: now_secs(),
349            },
350        };
351        out_enrollment_events.push(enrollment.get_change_event());
352        Some(enrollment)
353    }
354
355    /// Force unenroll ourselves from an experiment.
356    #[allow(clippy::unnecessary_wraps)]
357    #[cfg_attr(not(feature = "stateful"), allow(unused))]
358    pub(crate) fn on_explicit_opt_out(
359        &self,
360        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
361    ) -> ExperimentEnrollment {
362        match self.status {
363            EnrollmentStatus::Enrolled { .. } => {
364                let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
365                out_enrollment_events.push(enrollment.get_change_event());
366                enrollment
367            }
368            EnrollmentStatus::NotEnrolled { .. } => Self {
369                slug: self.slug.to_string(),
370                status: EnrollmentStatus::NotEnrolled {
371                    reason: NotEnrolledReason::OptOut, // Explicitly set the reason to OptOut.
372                },
373            },
374            EnrollmentStatus::Disqualified { .. }
375            | EnrollmentStatus::WasEnrolled { .. }
376            | EnrollmentStatus::Error { .. } => {
377                // Nothing to do here.
378                self.clone()
379            }
380        }
381    }
382
383    #[cfg(feature = "stateful")]
384    pub(crate) fn on_pref_unenroll(
385        &self,
386        pref_unenroll_reason: PrefUnenrollReason,
387        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
388    ) -> ExperimentEnrollment {
389        match self.status {
390            EnrollmentStatus::Enrolled { .. } => {
391                let enrollment =
392                    self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
393                        reason: pref_unenroll_reason,
394                    });
395                out_enrollment_events.push(enrollment.get_change_event());
396                enrollment
397            }
398            _ => self.clone(),
399        }
400    }
401
402    /// Reset identifiers in response to application-level telemetry reset.
403    ///
404    /// We move any enrolled experiments to the "disqualified" state, since their further
405    /// partipation would submit partial data that could skew analysis.
406    ///
407    #[cfg_attr(not(feature = "stateful"), allow(unused))]
408    pub fn reset_telemetry_identifiers(
409        &self,
410        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
411    ) -> Self {
412        let updated = match self.status {
413            EnrollmentStatus::Enrolled { .. } => {
414                let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
415                out_enrollment_events.push(disqualified.get_change_event());
416                disqualified
417            }
418            EnrollmentStatus::NotEnrolled { .. }
419            | EnrollmentStatus::Disqualified { .. }
420            | EnrollmentStatus::WasEnrolled { .. }
421            | EnrollmentStatus::Error { .. } => self.clone(),
422        };
423        ExperimentEnrollment {
424            status: updated.status.clone(),
425            ..updated
426        }
427    }
428
429    /// Garbage collect old experiments we've kept a WasEnrolled enrollment from.
430    /// Returns Option::None if the enrollment should be nuked from the db.
431    fn maybe_garbage_collect(&self) -> Option<Self> {
432        if let EnrollmentStatus::WasEnrolled {
433            experiment_ended_at,
434            ..
435        } = self.status
436        {
437            let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
438            if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
439                return Some(self.clone());
440            }
441        }
442        debug!("Garbage collecting enrollment '{}'", self.slug);
443        None
444    }
445
446    // Create a telemetry event describing the transition
447    // to the current enrollment state.
448    fn get_change_event(&self) -> EnrollmentChangeEvent {
449        match &self.status {
450            EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
451                &self.slug,
452                branch,
453                None,
454                EnrollmentChangeEventType::Enrollment,
455            ),
456            EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
457                &self.slug,
458                branch,
459                None,
460                EnrollmentChangeEventType::Unenrollment,
461            ),
462            EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
463                &self.slug,
464                branch,
465                match reason {
466                    DisqualifiedReason::NotSelected => Some("bucketing"),
467                    DisqualifiedReason::NotTargeted => Some("targeting"),
468                    DisqualifiedReason::OptOut => Some("optout"),
469                    DisqualifiedReason::Error => Some("error"),
470                    #[cfg(feature = "stateful")]
471                    DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
472                        PrefUnenrollReason::Changed => Some("pref_changed"),
473                        PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"),
474                    },
475                },
476                EnrollmentChangeEventType::Disqualification,
477            ),
478            EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
479                unreachable!()
480            }
481        }
482    }
483
484    /// If the current state is `Enrolled`, move to `Disqualified` with the given reason.
485    fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
486        match self.status {
487            EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
488                status: EnrollmentStatus::Disqualified {
489                    reason,
490                    branch: branch.to_owned(),
491                },
492                ..self.clone()
493            },
494            EnrollmentStatus::NotEnrolled { .. }
495            | EnrollmentStatus::Disqualified { .. }
496            | EnrollmentStatus::WasEnrolled { .. }
497            | EnrollmentStatus::Error { .. } => self.clone(),
498        }
499    }
500}
501
502// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
503// ⚠️ in `mod test_schema_bw_compat` below, and may require a DB migration. ⚠️
504#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
505pub enum EnrollmentStatus {
506    Enrolled {
507        reason: EnrolledReason,
508        branch: String,
509    },
510    NotEnrolled {
511        reason: NotEnrolledReason,
512    },
513    Disqualified {
514        reason: DisqualifiedReason,
515        branch: String,
516    },
517    WasEnrolled {
518        branch: String,
519        experiment_ended_at: u64, // unix timestamp in sec, used to GC old enrollments
520    },
521    // There was some error opting in.
522    Error {
523        // Ideally this would be an Error, but then we'd need to make Error
524        // serde compatible, which isn't trivial nor desirable.
525        reason: String,
526    },
527}
528
529impl EnrollmentStatus {
530    pub fn name(&self) -> String {
531        match self {
532            EnrollmentStatus::Enrolled { .. } => "Enrolled",
533            EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
534            EnrollmentStatus::Disqualified { .. } => "Disqualified",
535            EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
536            EnrollmentStatus::Error { .. } => "Error",
537        }
538        .into()
539    }
540}
541
542impl EnrollmentStatus {
543    // Note that for now, we only support a single feature_id per experiment,
544    // so this code is expected to shift once we start supporting multiple.
545    pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
546        EnrollmentStatus::Enrolled {
547            reason,
548            branch: branch.to_owned(),
549        }
550    }
551
552    // This is used in examples, but not in the main dylib, and
553    // triggers a dead code warning when building with `--release`.
554    pub fn is_enrolled(&self) -> bool {
555        matches!(self, EnrollmentStatus::Enrolled { .. })
556    }
557}
558
559pub(crate) trait ExperimentMetadata {
560    fn get_slug(&self) -> String;
561
562    fn is_rollout(&self) -> bool;
563}
564
565pub(crate) struct EnrollmentsEvolver<'a> {
566    available_randomization_units: &'a AvailableRandomizationUnits,
567    targeting_helper: &'a mut NimbusTargetingHelper,
568    coenrolling_feature_ids: &'a HashSet<&'a str>,
569}
570
571impl<'a> EnrollmentsEvolver<'a> {
572    pub(crate) fn new(
573        available_randomization_units: &'a AvailableRandomizationUnits,
574        targeting_helper: &'a mut NimbusTargetingHelper,
575        coenrolling_feature_ids: &'a HashSet<&str>,
576    ) -> Self {
577        Self {
578            available_randomization_units,
579            targeting_helper,
580            coenrolling_feature_ids,
581        }
582    }
583
584    pub(crate) fn evolve_enrollments<E>(
585        &mut self,
586        is_user_participating: bool,
587        prev_experiments: &[E],
588        next_experiments: &[Experiment],
589        prev_enrollments: &[ExperimentEnrollment],
590    ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
591    where
592        E: ExperimentMetadata + Clone,
593    {
594        let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
595        let mut events: Vec<EnrollmentChangeEvent> = Default::default();
596
597        // Do rollouts first.
598        // At the moment, we only allow one rollout per feature, so we can re-use the same machinery as experiments
599        let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
600            prev_experiments,
601            prev_enrollments,
602            ExperimentMetadata::is_rollout,
603        );
604        let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
605
606        let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
607            is_user_participating,
608            &prev_rollouts,
609            &next_rollouts,
610            &ro_enrollments,
611        )?;
612
613        enrollments.extend(next_ro_enrollments);
614        events.extend(ro_events);
615
616        let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
617
618        // Now we do the experiments.
619        // We need to mop up all the enrollments that aren't rollouts (not just belonging to experiments that aren't rollouts)
620        // because some of them don't belong to any experiments recipes, and evolve_enrollment_recipes will handle the error
621        // states for us.
622        let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
623        let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
624        let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
625            .iter()
626            .filter(|e| !ro_slugs.contains(&e.slug))
627            .map(|e| e.to_owned())
628            .collect();
629
630        let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
631            is_user_participating,
632            &prev_experiments,
633            &next_experiments,
634            &prev_enrollments,
635        )?;
636
637        enrollments.extend(next_exp_enrollments);
638        events.extend(exp_events);
639
640        Ok((enrollments, events))
641    }
642
643    /// Evolve and calculate the new set of enrollments, using the
644    /// previous and current state of experiments and current enrollments.
645    pub(crate) fn evolve_enrollment_recipes<E>(
646        &mut self,
647        is_user_participating: bool,
648        prev_experiments: &[E],
649        next_experiments: &[Experiment],
650        prev_enrollments: &[ExperimentEnrollment],
651    ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)>
652    where
653        E: ExperimentMetadata + Clone,
654    {
655        let mut enrollment_events = vec![];
656        let prev_experiments_map = map_experiments(prev_experiments);
657        let next_experiments_map = map_experiments(next_experiments);
658        let prev_enrollments_map = map_enrollments(prev_enrollments);
659
660        // Step 1. Build an initial active_features to keep track of
661        // the features that are being experimented upon.
662        let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
663        let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
664
665        let mut next_enrollments = Vec::with_capacity(next_experiments.len());
666
667        // Step 2.
668        // Evolve the experiments with previous enrollments first (except for
669        // those that already have a feature conflict).  While we're doing so,
670        // start building up active_features, the map of feature_ids under
671        // experiment to EnrolledFeatureConfigs, and next_enrollments.
672
673        for prev_enrollment in prev_enrollments {
674            if matches!(
675                prev_enrollment.status,
676                EnrollmentStatus::NotEnrolled {
677                    reason: NotEnrolledReason::FeatureConflict
678                }
679            ) {
680                continue;
681            }
682            let slug = &prev_enrollment.slug;
683
684            let next_enrollment = match self.evolve_enrollment(
685                is_user_participating,
686                prev_experiments_map.get(slug).copied(),
687                next_experiments_map.get(slug).copied(),
688                Some(prev_enrollment),
689                &mut enrollment_events,
690            ) {
691                Ok(enrollment) => enrollment,
692                Err(e) => {
693                    // It would be a fine thing if we had counters that
694                    // collected the number of errors here, and at the
695                    // place in this function where enrollments could be
696                    // dropped.  We could then send those errors to
697                    // telemetry so that they could be monitored (SDK-309)
698                    warn!("{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
699                    None
700                }
701            };
702
703            #[cfg(feature = "stateful")]
704            if let Some(ref enrollment) = next_enrollment.clone() {
705                if self.targeting_helper.update_enrollment(enrollment) {
706                    debug!("Enrollment updated for {}", enrollment.slug);
707                } else {
708                    debug!("Enrollment unchanged for {}", enrollment.slug);
709                }
710            }
711
712            self.reserve_enrolled_features(
713                next_enrollment,
714                &next_experiments_map,
715                &mut enrolled_features,
716                &mut coenrolling_features,
717                &mut next_enrollments,
718            );
719        }
720
721        // Step 3. Evolve the remaining enrollments with the previous and
722        // next data.
723        let next_experiments = sort_experiments_by_published_date(next_experiments);
724        for next_experiment in next_experiments {
725            let slug = &next_experiment.slug;
726
727            // Check that the feature ids that this experiment needs are available.  If not, then declare
728            // the enrollment as NotEnrolled; and we continue to the next
729            // experiment.
730            // `needed_features_in_use` are the features needed for this experiment, but already in use.
731            // If this is not empty, then the experiment is either already enrolled, or cannot be enrolled.
732            let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
733                .get_feature_ids()
734                .iter()
735                .filter_map(|id| enrolled_features.get(id))
736                .collect();
737            if !needed_features_in_use.is_empty() {
738                let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
739                if is_our_experiment {
740                    // At least one of these conflicted features are in use by this experiment.
741                    // Unless the experiment has changed midflight, all the features will be from
742                    // this experiment.
743                    assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
744                    // N.B. If this experiment is enrolled already, then we called
745                    // evolve_enrollment() on this enrollment and this experiment above.
746                } else {
747                    // At least one feature needed for this experiment is already in use by another experiment.
748                    // Thus, we cannot proceed with an enrollment other than as a `FeatureConflict`.
749                    next_enrollments.push(ExperimentEnrollment {
750                        slug: slug.clone(),
751                        status: EnrollmentStatus::NotEnrolled {
752                            reason: NotEnrolledReason::FeatureConflict,
753                        },
754                    });
755
756                    enrollment_events.push(EnrollmentChangeEvent {
757                        experiment_slug: slug.clone(),
758                        branch_slug: "N/A".to_string(),
759                        reason: Some("feature-conflict".to_string()),
760                        change: EnrollmentChangeEventType::EnrollFailed,
761                    })
762                }
763                // Whether it's our experiment or not that is using these features, no further enrollment can
764                // happen.
765                // Because no change has happened to this experiment's enrollment status, we don't need
766                // to log an enrollment event.
767                // All we can do is continue to the next experiment.
768                continue;
769            }
770
771            // If we got here, then the features are not already active.
772            // But we evolved all the existing enrollments in step 2,
773            // (except the feature conflicted ones)
774            // so we should be mindful that we don't evolve them a second time.
775            let prev_enrollment = prev_enrollments_map.get(slug).copied();
776
777            if prev_enrollment.is_none()
778                || matches!(
779                    prev_enrollment.unwrap().status,
780                    EnrollmentStatus::NotEnrolled {
781                        reason: NotEnrolledReason::FeatureConflict
782                    }
783                )
784            {
785                let next_enrollment = match self.evolve_enrollment(
786                    is_user_participating,
787                    prev_experiments_map.get(slug).copied(),
788                    Some(next_experiment),
789                    prev_enrollment,
790                    &mut enrollment_events,
791                ) {
792                    Ok(enrollment) => enrollment,
793                    Err(e) => {
794                        // It would be a fine thing if we had counters that
795                        // collected the number of errors here, and at the
796                        // place in this function where enrollments could be
797                        // dropped.  We could then send those errors to
798                        // telemetry so that they could be monitored (SDK-309)
799                        warn!("{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ", e, slug, prev_enrollment);
800                        None
801                    }
802                };
803
804                #[cfg(feature = "stateful")]
805                if let Some(ref enrollment) = next_enrollment.clone() {
806                    if self.targeting_helper.update_enrollment(enrollment) {
807                        debug!("Enrollment updated for {}", enrollment.slug);
808                    } else {
809                        debug!("Enrollment unchanged for {}", enrollment.slug);
810                    }
811                }
812
813                self.reserve_enrolled_features(
814                    next_enrollment,
815                    &next_experiments_map,
816                    &mut enrolled_features,
817                    &mut coenrolling_features,
818                    &mut next_enrollments,
819                );
820            }
821        }
822
823        enrolled_features.extend(coenrolling_features);
824
825        // Check that we generate the enrolled feature map from the new
826        // enrollments and new experiments.  Perhaps this should just be an
827        // assert.
828        let updated_enrolled_features = map_features(
829            &next_enrollments,
830            &next_experiments_map,
831            self.coenrolling_feature_ids,
832        );
833        if enrolled_features != updated_enrolled_features {
834            Err(NimbusError::InternalError(
835                "Next enrollment calculation error",
836            ))
837        } else {
838            Ok((next_enrollments, enrollment_events))
839        }
840    }
841
842    // Book-keeping method used in evolve_enrollments.
843    fn reserve_enrolled_features(
844        &self,
845        latest_enrollment: Option<ExperimentEnrollment>,
846        experiments: &HashMap<String, &Experiment>,
847        enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
848        coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
849        enrollments: &mut Vec<ExperimentEnrollment>,
850    ) {
851        if let Some(enrollment) = latest_enrollment {
852            // Now we have an enrollment object!
853            // If it's an enrolled enrollment, then get the FeatureConfigs
854            // from the experiment and store them in the enrolled_features or coenrolling_features maps.
855            for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
856                populate_feature_maps(
857                    enrolled_feature,
858                    self.coenrolling_feature_ids,
859                    enrolled_features,
860                    coenrolling_features,
861                );
862            }
863            // Also, record the enrollment for our return value
864            enrollments.push(enrollment);
865        }
866    }
867
868    /// Evolve a single enrollment using the previous and current state of an
869    /// experiment and maybe garbage collect at least a subset of invalid
870    /// experiments.
871    ///
872    /// XXX need to verify the exact set of gc-related side-effects and
873    /// document them here.
874    ///
875    /// Returns an Option-wrapped version of the updated enrollment.  None
876    /// means that the enrollment has been/should be discarded.
877    pub(crate) fn evolve_enrollment<E>(
878        &mut self,
879        is_user_participating: bool,
880        prev_experiment: Option<&E>,
881        next_experiment: Option<&Experiment>,
882        prev_enrollment: Option<&ExperimentEnrollment>,
883        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, // out param containing the events we'd like to emit to glean.
884    ) -> Result<Option<ExperimentEnrollment>>
885    where
886        E: ExperimentMetadata + Clone,
887    {
888        let is_already_enrolled = if let Some(enrollment) = prev_enrollment {
889            enrollment.status.is_enrolled()
890        } else {
891            false
892        };
893
894        // XXX This is not pretty, however, we need to re-write the way sticky targeting strings are generated in
895        // experimenter. Once https://github.com/mozilla/experimenter/issues/8661 is fixed, we can remove the calculation
896        // for `is_already_enrolled` above, the `put` call here and the `put` method declaration, and replace it with
897        // let th = self.targeting_helper;
898        let targeting_helper = self
899            .targeting_helper
900            .put("is_already_enrolled", is_already_enrolled);
901
902        Ok(match (prev_experiment, next_experiment, prev_enrollment) {
903            // New experiment.
904            (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
905                is_user_participating,
906                self.available_randomization_units,
907                experiment,
908                &targeting_helper,
909                out_enrollment_events,
910            )?),
911            // Experiment deleted remotely.
912            (Some(_), None, Some(enrollment)) => {
913                enrollment.on_experiment_ended(out_enrollment_events)
914            }
915            // Known experiment.
916            (Some(_), Some(experiment), Some(enrollment)) => {
917                Some(enrollment.on_experiment_updated(
918                    is_user_participating,
919                    self.available_randomization_units,
920                    experiment,
921                    &targeting_helper,
922                    out_enrollment_events,
923                )?)
924            }
925            (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
926            (None, Some(_), Some(_)) => {
927                return Err(NimbusError::InternalError(
928                    "New experiment but enrollment already exists.",
929                ))
930            }
931            (Some(_), None, None) | (Some(_), Some(_), None) => {
932                return Err(NimbusError::InternalError(
933                    "Experiment in the db did not have an associated enrollment record.",
934                ))
935            }
936            (None, None, None) => {
937                return Err(NimbusError::InternalError(
938                    "evolve_experiment called with nothing that could evolve or be evolved",
939                ))
940            }
941        })
942    }
943}
944
945fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
946where
947    E: ExperimentMetadata + Clone,
948{
949    let mut map_experiments = HashMap::with_capacity(experiments.len());
950    for e in experiments {
951        map_experiments.insert(e.get_slug(), e);
952    }
953    map_experiments
954}
955
956pub fn map_enrollments(
957    enrollments: &[ExperimentEnrollment],
958) -> HashMap<String, &ExperimentEnrollment> {
959    let mut map_enrollments = HashMap::with_capacity(enrollments.len());
960    for e in enrollments {
961        map_enrollments.insert(e.slug.clone(), e);
962    }
963    map_enrollments
964}
965
966pub(crate) fn filter_experiments_and_enrollments<E>(
967    experiments: &[E],
968    enrollments: &[ExperimentEnrollment],
969    filter_fn: fn(&E) -> bool,
970) -> (Vec<E>, Vec<ExperimentEnrollment>)
971where
972    E: ExperimentMetadata + Clone,
973{
974    let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
975
976    let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
977
978    let enrollments: Vec<ExperimentEnrollment> = enrollments
979        .iter()
980        .filter(|e| slugs.contains(&e.slug))
981        .map(|e| e.to_owned())
982        .collect();
983
984    (experiments, enrollments)
985}
986
987fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
988where
989    E: ExperimentMetadata + Clone,
990{
991    experiments
992        .iter()
993        .filter(|e| filter_fn(e))
994        .cloned()
995        .collect()
996}
997
998pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
999    let mut experiments: Vec<_> = experiments.iter().collect();
1000    experiments.sort_by(|a, b| a.published_date.cmp(&b.published_date));
1001    experiments
1002}
1003
1004/// Take a list of enrollments and a map of experiments, and generate mapping of `feature_id` to
1005/// `EnrolledFeatureConfig` structs.
1006fn map_features(
1007    enrollments: &[ExperimentEnrollment],
1008    experiments: &HashMap<String, &Experiment>,
1009    coenrolling_ids: &HashSet<&str>,
1010) -> HashMap<String, EnrolledFeatureConfig> {
1011    let mut colliding_features = HashMap::with_capacity(enrollments.len());
1012    let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1013    for enrolled_feature_config in enrollments
1014        .iter()
1015        .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1016    {
1017        populate_feature_maps(
1018            enrolled_feature_config,
1019            coenrolling_ids,
1020            &mut colliding_features,
1021            &mut coenrolling_features,
1022        );
1023    }
1024    colliding_features.extend(coenrolling_features.drain());
1025
1026    colliding_features
1027}
1028
1029pub fn map_features_by_feature_id(
1030    enrollments: &[ExperimentEnrollment],
1031    experiments: &[Experiment],
1032    coenrolling_ids: &HashSet<&str>,
1033) -> HashMap<String, EnrolledFeatureConfig> {
1034    let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1035        experiments,
1036        enrollments,
1037        ExperimentMetadata::is_rollout,
1038    );
1039    let (experiments, exp_enrollments) =
1040        filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1041
1042    let features_under_rollout = map_features(
1043        &ro_enrollments,
1044        &map_experiments(&rollouts),
1045        coenrolling_ids,
1046    );
1047    let features_under_experiment = map_features(
1048        &exp_enrollments,
1049        &map_experiments(&experiments),
1050        coenrolling_ids,
1051    );
1052
1053    features_under_experiment
1054        .defaults(&features_under_rollout)
1055        .unwrap()
1056}
1057
1058pub(crate) fn populate_feature_maps(
1059    enrolled_feature: EnrolledFeatureConfig,
1060    coenrolling_feature_ids: &HashSet<&str>,
1061    colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1062    coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1063) {
1064    let feature_id = &enrolled_feature.feature_id;
1065    if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1066        // If we're not allowing co-enrollment for this feature, then add it to enrolled_features.
1067        // We'll use this map to prevent collisions.
1068        colliding_features.insert(feature_id.clone(), enrolled_feature);
1069    } else if let Some(existing) = coenrolling_features.get(feature_id) {
1070        // Otherwise, we'll add to the coenrolling_features map.
1071        // In this branch, we've enrolled in one experiment already before this one.
1072        // We take care to merge this one with the existing one.
1073        let merged = enrolled_feature
1074            .defaults(existing)
1075            .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1076
1077        // We change the branch to None, so we don't send exposure events from this feature.
1078        // This is the subject of the ADR for https://mozilla-hub.atlassian.net/browse/EXP-3630.
1079        let merged = EnrolledFeatureConfig {
1080            // We make up the slug by appending. This is only for debugging reasons.
1081            slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1082            branch: None,
1083            ..merged
1084        };
1085        coenrolling_features.insert(feature_id.clone(), merged);
1086    } else {
1087        // In this branch, this is the first time we've added this feature to the coenrolling_features map.
1088        coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1089    }
1090}
1091
1092fn get_enrolled_feature_configs(
1093    enrollment: &ExperimentEnrollment,
1094    experiments: &HashMap<String, &Experiment>,
1095) -> Vec<EnrolledFeatureConfig> {
1096    // If status is not enrolled, then we can leave early.
1097    let branch_slug = match &enrollment.status {
1098        EnrollmentStatus::Enrolled { branch, .. } => branch,
1099        _ => return Vec::new(),
1100    };
1101
1102    let experiment_slug = &enrollment.slug;
1103
1104    let experiment = match experiments.get(experiment_slug).copied() {
1105        Some(exp) => exp,
1106        _ => return Vec::new(),
1107    };
1108
1109    // Get the branch from the experiment, and then get the feature configs
1110    // from there.
1111    let mut branch_features = match &experiment.get_branch(branch_slug) {
1112        Some(branch) => branch.get_feature_configs(),
1113        _ => Default::default(),
1114    };
1115
1116    branch_features.iter_mut().for_each(|f| {
1117        json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1118    });
1119
1120    let branch_feature_ids = &branch_features
1121        .iter()
1122        .map(|f| &f.feature_id)
1123        .collect::<HashSet<_>>();
1124
1125    // The experiment might have other branches that deal with different features.
1126    // We don't want them getting involved in other experiments, so we'll make default
1127    // FeatureConfigs.
1128    let non_branch_features: Vec<FeatureConfig> = experiment
1129        .get_feature_ids()
1130        .into_iter()
1131        .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1132        .map(|feature_id| FeatureConfig {
1133            feature_id,
1134            ..Default::default()
1135        })
1136        .collect();
1137
1138    // Now we've got the feature configs for all features in this experiment,
1139    // we can make EnrolledFeatureConfigs with them.
1140    branch_features
1141        .iter()
1142        .chain(non_branch_features.iter())
1143        .map(|f| EnrolledFeatureConfig {
1144            feature: f.to_owned(),
1145            slug: experiment_slug.clone(),
1146            branch: if !experiment.is_rollout() {
1147                Some(branch_slug.clone())
1148            } else {
1149                None
1150            },
1151            feature_id: f.feature_id.clone(),
1152        })
1153        .collect()
1154}
1155
1156/// Small transitory struct to contain all the information needed to configure a feature with the Feature API.
1157/// By design, we don't want to store it on the disk. Instead we calculate it from experiments
1158/// and enrollments.
1159#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1160#[serde(rename_all = "camelCase")]
1161pub struct EnrolledFeatureConfig {
1162    pub feature: FeatureConfig,
1163    pub slug: String,
1164    pub branch: Option<String>,
1165    pub feature_id: String,
1166}
1167
1168impl Defaults for EnrolledFeatureConfig {
1169    fn defaults(&self, fallback: &Self) -> Result<Self> {
1170        if self.feature_id != fallback.feature_id {
1171            // This is unlikely to happen, but if it does it's a bug in Nimbus
1172            Err(NimbusError::InternalError(
1173                "Cannot merge enrolled feature configs from different features",
1174            ))
1175        } else {
1176            Ok(Self {
1177                slug: self.slug.to_owned(),
1178                feature_id: self.feature_id.to_owned(),
1179                // Merge the actual feature config.
1180                feature: self.feature.defaults(&fallback.feature)?,
1181                // If this is an experiment, then this will be Some(_).
1182                // The feature is involved in zero or one experiments, and 0 or more rollouts.
1183                // So we can clone this Option safely.
1184                branch: self.branch.to_owned(),
1185            })
1186        }
1187    }
1188}
1189
1190impl ExperimentMetadata for EnrolledFeatureConfig {
1191    fn get_slug(&self) -> String {
1192        self.slug.clone()
1193    }
1194
1195    fn is_rollout(&self) -> bool {
1196        self.branch.is_none()
1197    }
1198}
1199
1200#[derive(Debug, Clone, PartialEq, Eq)]
1201pub struct EnrolledFeature {
1202    pub slug: String,
1203    pub branch: Option<String>,
1204    pub feature_id: String,
1205}
1206
1207impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1208    fn from(value: &EnrolledFeatureConfig) -> Self {
1209        Self {
1210            slug: value.slug.clone(),
1211            branch: value.branch.clone(),
1212            feature_id: value.feature_id.clone(),
1213        }
1214    }
1215}
1216
1217#[derive(Serialize, Deserialize, Debug, Clone)]
1218pub struct EnrollmentChangeEvent {
1219    pub experiment_slug: String,
1220    pub branch_slug: String,
1221    pub reason: Option<String>,
1222    pub change: EnrollmentChangeEventType,
1223}
1224
1225impl EnrollmentChangeEvent {
1226    pub(crate) fn new(
1227        slug: &str,
1228        branch: &str,
1229        reason: Option<&str>,
1230        change: EnrollmentChangeEventType,
1231    ) -> Self {
1232        Self {
1233            experiment_slug: slug.to_owned(),
1234            branch_slug: branch.to_owned(),
1235            reason: reason.map(|s| s.to_owned()),
1236            change,
1237        }
1238    }
1239}
1240
1241#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1242pub enum EnrollmentChangeEventType {
1243    Enrollment,
1244    EnrollFailed,
1245    Disqualification,
1246    Unenrollment,
1247    #[cfg_attr(not(feature = "stateful"), allow(unused))]
1248    UnenrollFailed,
1249}
1250
1251pub(crate) fn now_secs() -> u64 {
1252    SystemTime::now()
1253        .duration_since(UNIX_EPOCH)
1254        .expect("Current date before Unix Epoch.")
1255        .as_secs()
1256}