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