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