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