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
5use std::collections::{HashMap, HashSet};
6use std::fmt::{self, Display};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde_derive::{Deserialize, Serialize};
10
11use crate::defaults::Defaults;
12use crate::error::{NimbusError, Result, debug, warn};
13use crate::evaluator::evaluate_enrollment;
14use crate::json;
15#[cfg(feature = "stateful")]
16use crate::stateful::gecko_prefs::{GeckoPrefStore, OriginalGeckoPref, PrefUnenrollReason};
17use crate::{
18    AvailableRandomizationUnits, Experiment, FeatureConfig, NimbusTargetingHelper,
19    SLUG_REPLACEMENT_PATTERN,
20};
21
22pub(crate) const PREVIOUS_ENROLLMENTS_GC_TIME: Duration = Duration::from_secs(365 * 24 * 3600);
23
24// These are types we use internally for managing enrollments.
25// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
26// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
27#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
28pub enum EnrolledReason {
29    /// A normal enrollment as per the experiment's rules.
30    Qualified,
31    /// Explicit opt-in.
32    OptIn,
33    #[cfg(feature = "stateful")]
34    /// Opt-in via Firefox Labs.
35    FirefoxLabsOptIn,
36}
37
38impl Display for EnrolledReason {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        Display::fmt(
41            match self {
42                EnrolledReason::Qualified => "Qualified",
43                EnrolledReason::OptIn => "OptIn",
44                #[cfg(feature = "stateful")]
45                EnrolledReason::FirefoxLabsOptIn => "FirefoxLabsOptIn",
46            },
47            f,
48        )
49    }
50}
51
52// These are types we use internally for managing non-enrollments.
53
54// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
55// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
56#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
57#[allow(deprecated)]
58pub enum NotEnrolledReason {
59    /// The experiment targets a different application.
60    DifferentAppName,
61    /// The experiment targets a different channel.
62    DifferentChannel,
63    /// The experiment enrollment is paused.
64    EnrollmentsPaused,
65    /// The experiment used a feature that was already under experiment.
66    FeatureConflict { conflict_slug: Option<String> },
67    /// The evaluator bucketing did not choose us.
68    NotSelected,
69    /// We are not being targeted for this experiment.
70    NotTargeted,
71    /// The user opted-out of experiments before we ever got enrolled to this one.
72    ExperimentsOptOut,
73    /// The user opted-out of rollouts before we ever got enrolled to this one.
74    RolloutsOptOut,
75
76    /// This is a Firefox Labs opt-in and we have not opted-in.
77    #[cfg(feature = "stateful")]
78    FirefoxLabs,
79
80    /// This state represents several cases:
81    ///
82    /// - this is an experiment and the user globally opted out of experiments
83    ///   (or the global participation opt-out pre Firefox 145);
84    /// - this is a rollout and the user globally opted out of rollouts (or the
85    ///   global participation opt-out pre Firefox 145); or
86    /// - this experiment or rollout was opted-out of manually by the user while
87    ///   not enrolled.
88    ///
89    /// The global participation opt-out was changed to individual experiments
90    /// and rollouts opt-outs in Firefox 145, but separate reasons were not
91    /// added until Firefox 153. It is therefore impossible to distinguish what
92    /// exactly this state represents and so we must interpet it in the most
93    /// strict sense in order to respect user choice.
94    #[deprecated]
95    OptOut,
96}
97
98#[allow(deprecated)]
99impl Display for NotEnrolledReason {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        Display::fmt(
102            match self {
103                NotEnrolledReason::DifferentAppName => "DifferentAppName",
104                NotEnrolledReason::DifferentChannel => "DifferentChannel",
105                NotEnrolledReason::EnrollmentsPaused => "EnrollmentsPaused",
106                NotEnrolledReason::FeatureConflict { .. } => "FeatureConflict",
107                NotEnrolledReason::NotSelected => "NotSelected",
108                NotEnrolledReason::NotTargeted => "NotTargeted",
109                NotEnrolledReason::ExperimentsOptOut => "ExperimentsOptOut",
110                NotEnrolledReason::RolloutsOptOut => "RolloutsOptOut",
111                #[cfg(feature = "stateful")]
112                NotEnrolledReason::FirefoxLabs => "FirefoxLabs",
113                NotEnrolledReason::OptOut => "OptOut",
114            },
115            f,
116        )
117    }
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone)]
121pub struct Participation {
122    pub in_experiments: bool,
123    pub in_rollouts: bool,
124}
125
126impl Default for Participation {
127    fn default() -> Self {
128        Self {
129            in_experiments: true,
130            in_rollouts: true,
131        }
132    }
133}
134
135// These are types we use internally for managing disqualifications.
136
137// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
138// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
139#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
140pub enum DisqualifiedReason {
141    /// There was an error.
142    Error,
143    /// The user opted-out from this experiment.
144    OptOut,
145    /// The user opted-out from all experiments.
146    ExperimentsOptOut,
147    // The user opted-out from all rollouts.
148    RolloutsOptOut,
149    /// The targeting has changed for an experiment.
150    NotTargeted,
151    /// The bucketing has changed for an experiment.
152    NotSelected,
153    /// A pref used in the experiment was set by the user.
154    #[cfg(feature = "stateful")]
155    PrefUnenrollReason {
156        reason: PrefUnenrollReason,
157    },
158    #[cfg(feature = "stateful")]
159    FirefoxLabsOptOut,
160}
161
162impl Display for DisqualifiedReason {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        Display::fmt(
165            match self {
166                DisqualifiedReason::Error => "Error",
167                DisqualifiedReason::OptOut => "OptOut",
168                DisqualifiedReason::ExperimentsOptOut => "ExperimentsOptOut",
169                DisqualifiedReason::RolloutsOptOut => "ExperimentsOptOut",
170                DisqualifiedReason::NotSelected => "NotSelected",
171                DisqualifiedReason::NotTargeted => "NotTargeted",
172                #[cfg(feature = "stateful")]
173                DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
174                    PrefUnenrollReason::Changed => "PrefChanged",
175                    PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
176                },
177                #[cfg(feature = "stateful")]
178                DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
179            },
180            f,
181        )
182    }
183}
184
185impl DisqualifiedReason {
186    // TODO(bug 2046987): Unify with Display impl?
187    fn for_enrollment_change_event(&self) -> &'static str {
188        match self {
189            DisqualifiedReason::NotSelected => "bucketing",
190            DisqualifiedReason::NotTargeted => "targeting",
191            DisqualifiedReason::OptOut => "optout",
192            DisqualifiedReason::ExperimentsOptOut => "experiments-opt-out",
193            DisqualifiedReason::RolloutsOptOut => "rollouts-opt-out",
194            DisqualifiedReason::Error => "error",
195            #[cfg(feature = "stateful")]
196            DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
197                PrefUnenrollReason::Changed => "pref_changed",
198                PrefUnenrollReason::FailedToSet => "pref_failed_to_set",
199            },
200            #[cfg(feature = "stateful")]
201            DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
202        }
203    }
204}
205
206// The previous state of a Gecko pref before enrollment took place.
207// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
208// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
209#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
210#[cfg(feature = "stateful")]
211pub struct PreviousGeckoPrefState {
212    pub original_value: OriginalGeckoPref,
213    pub feature_id: String,
214    pub variable: String,
215}
216
217#[cfg(feature = "stateful")]
218impl PreviousGeckoPrefState {
219    pub(crate) fn on_revert_all_to_prev_gecko_pref_states(
220        prev_gecko_pref_states: &[Self],
221        gecko_pref_store: Option<&GeckoPrefStore>,
222    ) {
223        if let Some(store) = gecko_pref_store {
224            let original_values: Vec<_> = prev_gecko_pref_states
225                .iter()
226                .map(|state| state.original_value.clone())
227                .collect();
228            store
229                .handler
230                .set_gecko_prefs_original_values(original_values);
231        }
232    }
233
234    pub(crate) fn on_partially_revert_to_prev_gecko_pref_states(
235        prev_gecko_pref_states: &[Self],
236        non_reverting_pref_name: &str,
237        gecko_pref_store: Option<&GeckoPrefStore>,
238    ) {
239        if let Some(store) = gecko_pref_store {
240            let qualified_values: Vec<_> = prev_gecko_pref_states
241                .iter()
242                .filter(|state| state.original_value.pref != non_reverting_pref_name)
243                .map(|state| state.original_value.clone())
244                .collect();
245            store
246                .handler
247                .set_gecko_prefs_original_values(qualified_values);
248        }
249    }
250}
251
252// Every experiment has an ExperimentEnrollment, even when we aren't enrolled.
253// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
254// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
255#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
256pub struct ExperimentEnrollment {
257    pub slug: String,
258    pub status: EnrollmentStatus,
259}
260
261impl ExperimentEnrollment {
262    /// Evaluate an experiment enrollment for an experiment
263    /// we are seeing for the first time.
264    fn from_new_experiment(
265        is_user_participating: bool,
266        available_randomization_units: &AvailableRandomizationUnits,
267        experiment: &Experiment,
268        targeting_helper: &NimbusTargetingHelper,
269        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
270    ) -> Result<Self> {
271        // The rollout opt-out does not apply to Firefox Labs, since the user
272        // must consent to each opt-in.
273        #[cfg(feature = "stateful")]
274        if experiment.is_firefox_labs_opt_in {
275            return Ok(Self {
276                slug: experiment.slug.clone(),
277                status: EnrollmentStatus::NotEnrolled {
278                    reason: NotEnrolledReason::FirefoxLabs,
279                },
280            });
281        }
282
283        Ok(if !is_user_participating {
284            Self {
285                slug: experiment.slug.clone(),
286                status: EnrollmentStatus::NotEnrolled {
287                    reason: if experiment.is_rollout() {
288                        NotEnrolledReason::RolloutsOptOut
289                    } else {
290                        NotEnrolledReason::ExperimentsOptOut
291                    },
292                },
293            }
294        } else if experiment.is_enrollment_paused {
295            Self {
296                slug: experiment.slug.clone(),
297                status: EnrollmentStatus::NotEnrolled {
298                    reason: NotEnrolledReason::EnrollmentsPaused,
299                },
300            }
301        } else {
302            let enrollment =
303                evaluate_enrollment(available_randomization_units, experiment, targeting_helper)?;
304            debug!(
305                "Evaluating experiment slug: {:?} with targeting string: {:?}",
306                experiment.slug, experiment.targeting
307            );
308            debug!(
309                "Experiment '{}' is new - enrollment status is {:?}",
310                &enrollment.slug, &enrollment
311            );
312            if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) {
313                out_enrollment_events.push(enrollment.get_change_event(Some(experiment)))
314            }
315            enrollment
316        })
317    }
318
319    /// Force enroll ourselves in an experiment.
320    #[cfg_attr(not(feature = "stateful"), allow(unused))]
321    pub(crate) fn from_explicit_opt_in(
322        experiment: &Experiment,
323        branch_slug: &str,
324        reason: EnrolledReason,
325        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
326    ) -> Result<Self> {
327        if !experiment.has_branch(branch_slug) {
328            out_enrollment_events.push(EnrollmentChangeEvent {
329                experiment_slug: experiment.slug.to_string(),
330                branch_slug: branch_slug.to_string(),
331                reason: Some("does-not-exist".to_string()),
332                change: EnrollmentChangeEventType::EnrollFailed,
333                feature_ids: experiment.get_feature_ids(),
334            });
335
336            return Err(NimbusError::NoSuchBranch(
337                branch_slug.to_owned(),
338                experiment.slug.clone(),
339            ));
340        }
341        let enrollment = Self {
342            slug: experiment.slug.clone(),
343            status: EnrollmentStatus::new_enrolled(reason, branch_slug),
344        };
345        out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
346        Ok(enrollment)
347    }
348
349    /// Update our enrollment to an experiment we have seen before.
350    #[allow(clippy::too_many_arguments)]
351    fn on_experiment_updated(
352        &self,
353        is_user_participating: bool,
354        available_randomization_units: &AvailableRandomizationUnits,
355        updated_experiment: &Experiment,
356        targeting_helper: &NimbusTargetingHelper,
357        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
358        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
359    ) -> Result<Self> {
360        #[cfg(feature = "stateful")]
361        if updated_experiment.is_firefox_labs_opt_in && !self.status.is_enrolled() {
362            return Ok(self.clone());
363        }
364
365        // Users can globally opt out of rollouts, but not out of Firefox Labs.
366        #[cfg(feature = "stateful")]
367        let is_user_participating =
368            is_user_participating || updated_experiment.is_firefox_labs_opt_in;
369
370        Ok(match &self.status {
371            EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
372                if !is_user_participating || updated_experiment.is_enrollment_paused {
373                    self.clone()
374                } else {
375                    let updated_enrollment = evaluate_enrollment(
376                        available_randomization_units,
377                        updated_experiment,
378                        targeting_helper,
379                    )?;
380                    debug!(
381                        "Experiment '{}' with enrollment {:?} is now {:?}",
382                        &self.slug, &self, updated_enrollment
383                    );
384                    if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) {
385                        out_enrollment_events
386                            .push(updated_enrollment.get_change_event(Some(updated_experiment)));
387                    }
388                    updated_enrollment
389                }
390            }
391
392            EnrollmentStatus::Enrolled { branch, reason, .. } => {
393                if !is_user_participating {
394                    debug!(
395                        "Existing experiment enrollment '{}' is now disqualified (global opt-out)",
396                        &self.slug
397                    );
398                    #[cfg(feature = "stateful")]
399                    self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
400                    let updated_enrollment =
401                        self.disqualify_from_enrolled(if updated_experiment.is_rollout {
402                            DisqualifiedReason::RolloutsOptOut
403                        } else {
404                            DisqualifiedReason::ExperimentsOptOut
405                        });
406                    out_enrollment_events
407                        .push(updated_enrollment.get_change_event(Some(updated_experiment)));
408                    updated_enrollment
409                } else if !updated_experiment.has_branch(branch) {
410                    // The branch we were in disappeared!
411                    #[cfg(feature = "stateful")]
412                    self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
413                    let updated_enrollment =
414                        self.disqualify_from_enrolled(DisqualifiedReason::Error);
415                    out_enrollment_events
416                        .push(updated_enrollment.get_change_event(Some(updated_experiment)));
417                    updated_enrollment
418                } else if matches!(reason, EnrolledReason::OptIn) {
419                    // we check if we opted-in an experiment, if so
420                    // we don't need to update our enrollment
421                    self.clone()
422                } else {
423                    let evaluated_enrollment = evaluate_enrollment(
424                        available_randomization_units,
425                        updated_experiment,
426                        targeting_helper,
427                    )?;
428
429                    #[cfg(feature = "stateful")]
430                    if let EnrollmentStatus::Enrolled {
431                        prev_gecko_pref_states: Some(prev_gecko_pref_states),
432                        ..
433                    } = &self.status
434                        && self
435                            .will_pref_experiment_change(updated_experiment, &evaluated_enrollment)
436                    {
437                        PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
438                            prev_gecko_pref_states,
439                            gecko_pref_store,
440                        );
441                    }
442                    match evaluated_enrollment.status {
443                        EnrollmentStatus::Error { .. } => {
444                            let updated_enrollment =
445                                self.disqualify_from_enrolled(DisqualifiedReason::Error);
446                            out_enrollment_events.push(
447                                updated_enrollment.get_change_event(Some(updated_experiment)),
448                            );
449                            updated_enrollment
450                        }
451                        EnrollmentStatus::NotEnrolled {
452                            reason: NotEnrolledReason::DifferentAppName,
453                        }
454                        | EnrollmentStatus::NotEnrolled {
455                            reason: NotEnrolledReason::DifferentChannel,
456                        }
457                        | EnrollmentStatus::NotEnrolled {
458                            reason: NotEnrolledReason::NotTargeted,
459                        } => {
460                            debug!(
461                                "Existing experiment enrollment '{}' is now disqualified (targeting change)",
462                                &self.slug
463                            );
464                            let updated_enrollment =
465                                self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted);
466                            out_enrollment_events.push(
467                                updated_enrollment.get_change_event(Some(updated_experiment)),
468                            );
469                            updated_enrollment
470                        }
471                        EnrollmentStatus::NotEnrolled {
472                            reason: NotEnrolledReason::NotSelected,
473                        } => {
474                            // In the case of a rollout being scaled back, we should be disqualified with NotSelected.
475                            //
476                            let updated_enrollment =
477                                self.disqualify_from_enrolled(DisqualifiedReason::NotSelected);
478                            out_enrollment_events.push(
479                                updated_enrollment.get_change_event(Some(updated_experiment)),
480                            );
481                            updated_enrollment
482                        }
483                        EnrollmentStatus::NotEnrolled { .. }
484                        | EnrollmentStatus::Enrolled { .. }
485                        | EnrollmentStatus::Disqualified { .. }
486                        | EnrollmentStatus::WasEnrolled { .. } => self.clone(),
487                    }
488                }
489            }
490            EnrollmentStatus::Disqualified { branch, reason, .. } => {
491                if !is_user_participating {
492                    debug!(
493                        "Disqualified experiment enrollment '{}' has been reset to not-enrolled (global opt-out)",
494                        &self.slug
495                    );
496                    Self {
497                        slug: self.slug.clone(),
498                        status: EnrollmentStatus::Disqualified {
499                            reason: if updated_experiment.is_rollout {
500                                DisqualifiedReason::RolloutsOptOut
501                            } else {
502                                DisqualifiedReason::ExperimentsOptOut
503                            },
504                            branch: branch.clone(),
505                        },
506                    }
507                } else if updated_experiment.is_rollout
508                    && matches!(
509                        reason,
510                        DisqualifiedReason::NotSelected
511                            | DisqualifiedReason::NotTargeted
512                            | DisqualifiedReason::RolloutsOptOut,
513                    )
514                {
515                    let updated_enrollment = evaluate_enrollment(
516                        available_randomization_units,
517                        updated_experiment,
518                        targeting_helper,
519                    )?;
520                    match updated_enrollment.status {
521                        EnrollmentStatus::Enrolled { .. } => {
522                            out_enrollment_events.push(
523                                updated_enrollment.get_change_event(Some(updated_experiment)),
524                            );
525                            updated_enrollment
526                        }
527                        _ => self.clone(),
528                    }
529                } else {
530                    self.clone()
531                }
532            }
533            EnrollmentStatus::WasEnrolled { .. } => self.clone(),
534        })
535    }
536
537    /// Transition our enrollment to WasEnrolled (Option::Some) or delete it (Option::None)
538    /// after an experiment has disappeared from the server.
539    ///
540    /// If we transitioned to WasEnrolled, our enrollment will be garbage collected
541    /// from the database after `PREVIOUS_ENROLLMENTS_GC_TIME`.
542    fn on_experiment_ended(
543        &self,
544        experiment: &Experiment,
545        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
546        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
547    ) -> Option<Self> {
548        debug!(
549            "Experiment '{}' vanished while we had enrollment status of {:?}",
550            self.slug, self
551        );
552        let branch = match self.status {
553            EnrollmentStatus::Enrolled { ref branch, .. }
554            | EnrollmentStatus::Disqualified { ref branch, .. } => branch,
555            EnrollmentStatus::NotEnrolled { .. }
556            | EnrollmentStatus::WasEnrolled { .. }
557            | EnrollmentStatus::Error { .. } => return None, // We were never enrolled anyway, simply delete the enrollment record from the DB.
558        };
559        #[cfg(feature = "stateful")]
560        self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
561
562        let enrollment = Self {
563            slug: self.slug.clone(),
564            status: EnrollmentStatus::WasEnrolled {
565                branch: branch.to_owned(),
566                experiment_ended_at: now_secs(),
567            },
568        };
569        out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
570        Some(enrollment)
571    }
572
573    /// Force unenroll ourselves from an experiment.
574    #[allow(clippy::unnecessary_wraps)]
575    #[cfg_attr(not(feature = "stateful"), allow(unused))]
576    pub(crate) fn on_explicit_opt_out(
577        &self,
578        experiment: Option<&Experiment>,
579        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
580        reason: DisqualifiedReason,
581        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
582    ) -> ExperimentEnrollment {
583        match self.status {
584            EnrollmentStatus::Enrolled { .. } => {
585                #[cfg(feature = "stateful")]
586                self.maybe_revert_all_gecko_pref_states(gecko_pref_store);
587
588                let enrollment = self.disqualify_from_enrolled(reason);
589                out_enrollment_events.push(enrollment.get_change_event(experiment));
590                enrollment
591            }
592            EnrollmentStatus::NotEnrolled { .. }
593            | EnrollmentStatus::Disqualified { .. }
594            | EnrollmentStatus::WasEnrolled { .. }
595            | EnrollmentStatus::Error { .. } => {
596                // Nothing to do here.
597                self.clone()
598            }
599        }
600    }
601
602    #[cfg(feature = "stateful")]
603    pub(crate) fn on_pref_unenroll(
604        &self,
605        pref_unenroll_reason: PrefUnenrollReason,
606        experiment: Option<&Experiment>,
607        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
608    ) -> ExperimentEnrollment {
609        match self.status {
610            EnrollmentStatus::Enrolled { .. } => {
611                let enrollment =
612                    self.disqualify_from_enrolled(DisqualifiedReason::PrefUnenrollReason {
613                        reason: pref_unenroll_reason,
614                    });
615                out_enrollment_events.push(enrollment.get_change_event(experiment));
616                enrollment
617            }
618            _ => self.clone(),
619        }
620    }
621
622    // The previous gecko pref states are only settable on Enrolled experiments
623    #[cfg(feature = "stateful")]
624    pub(crate) fn on_add_gecko_pref_states(
625        &self,
626        prev_gecko_pref_states: Vec<PreviousGeckoPrefState>,
627    ) -> ExperimentEnrollment {
628        let mut next = self.clone();
629        if let EnrollmentStatus::Enrolled { reason, branch, .. } = &self.status {
630            next.status = EnrollmentStatus::Enrolled {
631                prev_gecko_pref_states: Some(prev_gecko_pref_states),
632                reason: reason.clone(),
633                branch: branch.clone(),
634            };
635        }
636        next
637    }
638
639    #[cfg(feature = "stateful")]
640    // Used for reverting every pref except the one that changed outside of Nimbus. The pref that changed outside of Nimbus should not be reverted.
641    pub(crate) fn maybe_revert_unchanged_gecko_pref_states(
642        &self,
643        non_reverting_pref_name: &str,
644        gecko_pref_store: Option<&GeckoPrefStore>,
645    ) {
646        if let EnrollmentStatus::Enrolled {
647            prev_gecko_pref_states: Some(prev_gecko_pref_states),
648            ..
649        } = &self.status
650        {
651            PreviousGeckoPrefState::on_partially_revert_to_prev_gecko_pref_states(
652                prev_gecko_pref_states,
653                non_reverting_pref_name,
654                gecko_pref_store,
655            );
656        }
657    }
658
659    #[cfg(feature = "stateful")]
660    // Used for reverting all prefs when an enrollment discontinues and no prefs were changed outside of Nimbus.
661    pub(crate) fn maybe_revert_all_gecko_pref_states(
662        &self,
663        gecko_pref_store: Option<&GeckoPrefStore>,
664    ) {
665        if let EnrollmentStatus::Enrolled {
666            prev_gecko_pref_states: Some(prev_gecko_pref_states),
667            ..
668        } = &self.status
669        {
670            PreviousGeckoPrefState::on_revert_all_to_prev_gecko_pref_states(
671                prev_gecko_pref_states,
672                gecko_pref_store,
673            );
674        }
675    }
676
677    /// Reset identifiers in response to application-level telemetry reset.
678    ///
679    /// We move any enrolled experiments to the "disqualified" state, since their further
680    /// partipation would submit partial data that could skew analysis.
681    ///
682    #[cfg_attr(not(feature = "stateful"), allow(unused))]
683    pub fn reset_telemetry_identifiers(
684        &self,
685        experiment: Option<&Experiment>,
686        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
687    ) -> Self {
688        let updated = match self.status {
689            EnrollmentStatus::Enrolled { .. } => {
690                if let Some(experiment) = experiment
691                    && experiment.is_firefox_labs_opt_in
692                {
693                    // Firefox Labs is unrelated to telemetry.
694                    self.clone()
695                } else {
696                    let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
697                    out_enrollment_events.push(disqualified.get_change_event(experiment));
698                    disqualified
699                }
700            }
701            EnrollmentStatus::NotEnrolled { .. }
702            | EnrollmentStatus::Disqualified { .. }
703            | EnrollmentStatus::WasEnrolled { .. }
704            | EnrollmentStatus::Error { .. } => self.clone(),
705        };
706        ExperimentEnrollment {
707            status: updated.status.clone(),
708            ..updated
709        }
710    }
711
712    /// Garbage collect old experiments we've kept a WasEnrolled enrollment from.
713    /// Returns Option::None if the enrollment should be nuked from the db.
714    fn maybe_garbage_collect(&self) -> Option<Self> {
715        if let EnrollmentStatus::WasEnrolled {
716            experiment_ended_at,
717            ..
718        } = self.status
719        {
720            let time_since_transition = Duration::from_secs(now_secs() - experiment_ended_at);
721            if time_since_transition < PREVIOUS_ENROLLMENTS_GC_TIME {
722                return Some(self.clone());
723            }
724        }
725        debug!("Garbage collecting enrollment '{}'", self.slug);
726        None
727    }
728
729    // Create a telemetry event describing the transition
730    // to the current enrollment state.
731    fn get_change_event(&self, experiment: Option<&Experiment>) -> EnrollmentChangeEvent {
732        match &self.status {
733            EnrollmentStatus::Enrolled { branch, .. } => EnrollmentChangeEvent::new(
734                &self.slug,
735                branch,
736                None,
737                EnrollmentChangeEventType::Enrollment,
738                experiment,
739            ),
740            EnrollmentStatus::WasEnrolled { branch, .. } => EnrollmentChangeEvent::new(
741                &self.slug,
742                branch,
743                None,
744                EnrollmentChangeEventType::Unenrollment,
745                experiment,
746            ),
747            EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
748                &self.slug,
749                branch,
750                Some(reason.for_enrollment_change_event()),
751                EnrollmentChangeEventType::Disqualification,
752                experiment,
753            ),
754            EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => {
755                unreachable!()
756            }
757        }
758    }
759
760    /// If the current state is `Enrolled`, move to `Disqualified` with the given reason.
761    fn disqualify_from_enrolled(&self, reason: DisqualifiedReason) -> Self {
762        match self.status {
763            EnrollmentStatus::Enrolled { ref branch, .. } => ExperimentEnrollment {
764                status: EnrollmentStatus::Disqualified {
765                    reason,
766                    branch: branch.to_owned(),
767                },
768                ..self.clone()
769            },
770            EnrollmentStatus::NotEnrolled { .. }
771            | EnrollmentStatus::Disqualified { .. }
772            | EnrollmentStatus::WasEnrolled { .. }
773            | EnrollmentStatus::Error { .. } => self.clone(),
774        }
775    }
776
777    #[cfg(feature = "stateful")]
778    pub(crate) fn will_pref_experiment_change(
779        &self,
780        updated_experiment: &Experiment,
781        updated_enrollment: &ExperimentEnrollment,
782    ) -> bool {
783        let EnrollmentStatus::Enrolled {
784            prev_gecko_pref_states: Some(original_prev_gecko_pref_states),
785            branch: original_branch_slug,
786            ..
787        } = &self.status
788        else {
789            // Can't change if it isn't a pref experiment
790            return false;
791        };
792
793        let EnrollmentStatus::Enrolled {
794            branch: updated_branch_slug,
795            ..
796        } = &updated_enrollment.status
797        else {
798            // If we are no longer going to be enrolled, then a change happened
799            return true;
800        };
801
802        // Branch changed
803        if updated_branch_slug != original_branch_slug {
804            return true;
805        }
806
807        // Couldn't get a branch, something changed
808        let Some(updated_branch) = updated_experiment.get_branch(updated_branch_slug) else {
809            return true;
810        };
811
812        let original_feature_ids: HashSet<&String> = original_prev_gecko_pref_states
813            .iter()
814            .map(|state| &state.feature_id)
815            .collect();
816        let updated_features = updated_branch.get_feature_configs();
817
818        // Amount of features should be the same
819        if updated_features.len() != original_feature_ids.len() {
820            return true;
821        }
822
823        for original_state in original_prev_gecko_pref_states {
824            let Some(updated_feature) = updated_features
825                .iter()
826                .find(|config| config.feature_id == original_state.feature_id)
827            else {
828                // If original feature isn't present, then something changed
829                return true;
830            };
831
832            // Property key should still exist in the feature's value map
833            if !updated_feature.value.contains_key(&original_state.variable) {
834                return true;
835            }
836        }
837        false
838    }
839}
840
841// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
842// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
843#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
844pub enum EnrollmentStatus {
845    Enrolled {
846        reason: EnrolledReason,
847        branch: String,
848        #[cfg(feature = "stateful")]
849        #[serde(skip_serializing_if = "Option::is_none")]
850        prev_gecko_pref_states: Option<Vec<PreviousGeckoPrefState>>,
851    },
852    NotEnrolled {
853        reason: NotEnrolledReason,
854    },
855    Disqualified {
856        reason: DisqualifiedReason,
857        branch: String,
858    },
859    WasEnrolled {
860        branch: String,
861        experiment_ended_at: u64, // unix timestamp in sec, used to GC old enrollments
862    },
863    // There was some error opting in.
864    Error {
865        // Ideally this would be an Error, but then we'd need to make Error
866        // serde compatible, which isn't trivial nor desirable.
867        reason: String,
868    },
869}
870
871impl EnrollmentStatus {
872    pub fn name(&self) -> String {
873        match self {
874            EnrollmentStatus::Enrolled { .. } => "Enrolled",
875            EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled",
876            EnrollmentStatus::Disqualified { .. } => "Disqualified",
877            EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled",
878            EnrollmentStatus::Error { .. } => "Error",
879        }
880        .into()
881    }
882
883    // Note that for now, we only support a single feature_id per experiment,
884    // so this code is expected to shift once we start supporting multiple.
885    pub fn new_enrolled(reason: EnrolledReason, branch: &str) -> Self {
886        EnrollmentStatus::Enrolled {
887            reason,
888            branch: branch.to_owned(),
889            #[cfg(feature = "stateful")]
890            prev_gecko_pref_states: None,
891        }
892    }
893
894    pub fn is_enrolled(&self) -> bool {
895        matches!(self, EnrollmentStatus::Enrolled { .. })
896    }
897
898    pub fn is_enrolled_with_reason(&self, expected_reason: EnrolledReason) -> bool {
899        matches!(
900            self,
901            EnrollmentStatus::Enrolled { reason, ..}
902            if *reason == expected_reason
903        )
904    }
905}
906
907pub(crate) trait ExperimentMetadata {
908    fn get_slug(&self) -> String;
909
910    fn is_rollout(&self) -> bool;
911}
912
913pub(crate) struct EnrollmentsEvolver<'a> {
914    available_randomization_units: &'a AvailableRandomizationUnits,
915    targeting_helper: &'a mut NimbusTargetingHelper,
916    coenrolling_feature_ids: &'a HashSet<&'a str>,
917}
918
919impl<'a> EnrollmentsEvolver<'a> {
920    pub(crate) fn new(
921        available_randomization_units: &'a AvailableRandomizationUnits,
922        targeting_helper: &'a mut NimbusTargetingHelper,
923        coenrolling_feature_ids: &'a HashSet<&str>,
924    ) -> Self {
925        Self {
926            available_randomization_units,
927            targeting_helper,
928            coenrolling_feature_ids,
929        }
930    }
931
932    pub(crate) fn evolve_enrollments(
933        &mut self,
934        participation: Participation,
935        prev_experiments: &[Experiment],
936        next_experiments: &[Experiment],
937        prev_enrollments: &[ExperimentEnrollment],
938        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
939    ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
940        let mut enrollments: Vec<ExperimentEnrollment> = Default::default();
941        let mut events: Vec<EnrollmentChangeEvent> = Default::default();
942
943        // Do rollouts first.
944        // At the moment, we only allow one rollout per feature, so we can re-use the same machinery as experiments
945        let (prev_rollouts, ro_enrollments) = filter_experiments_and_enrollments(
946            prev_experiments,
947            prev_enrollments,
948            ExperimentMetadata::is_rollout,
949        );
950        let next_rollouts = filter_experiments(next_experiments, ExperimentMetadata::is_rollout);
951
952        let (next_ro_enrollments, ro_events) = self.evolve_enrollment_recipes(
953            participation.in_rollouts,
954            &prev_rollouts,
955            &next_rollouts,
956            &ro_enrollments,
957            #[cfg(feature = "stateful")]
958            gecko_pref_store,
959        )?;
960
961        enrollments.extend(next_ro_enrollments);
962        events.extend(ro_events);
963
964        let ro_slugs: HashSet<String> = ro_enrollments.iter().map(|e| e.slug.clone()).collect();
965
966        // Now we do the experiments.
967        // We need to mop up all the enrollments that aren't rollouts (not just belonging to experiments that aren't rollouts)
968        // because some of them don't belong to any experiments recipes, and evolve_enrollment_recipes will handle the error
969        // states for us.
970        let prev_experiments = filter_experiments(prev_experiments, |exp| !exp.is_rollout());
971        let next_experiments = filter_experiments(next_experiments, |exp| !exp.is_rollout());
972        let prev_enrollments: Vec<ExperimentEnrollment> = prev_enrollments
973            .iter()
974            .filter(|e| !ro_slugs.contains(&e.slug))
975            .map(|e| e.to_owned())
976            .collect();
977
978        let (next_exp_enrollments, exp_events) = self.evolve_enrollment_recipes(
979            participation.in_experiments,
980            &prev_experiments,
981            &next_experiments,
982            &prev_enrollments,
983            #[cfg(feature = "stateful")]
984            gecko_pref_store,
985        )?;
986
987        enrollments.extend(next_exp_enrollments);
988        events.extend(exp_events);
989
990        Ok((enrollments, events))
991    }
992
993    /// Evolve and calculate the new set of enrollments, using the
994    /// previous and current state of experiments and current enrollments.
995    pub(crate) fn evolve_enrollment_recipes(
996        &mut self,
997        is_user_participating: bool,
998        prev_experiments: &[Experiment],
999        next_experiments: &[Experiment],
1000        prev_enrollments: &[ExperimentEnrollment],
1001        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1002    ) -> Result<(Vec<ExperimentEnrollment>, Vec<EnrollmentChangeEvent>)> {
1003        let mut enrollment_events = vec![];
1004        let prev_experiments_map = map_experiments(prev_experiments);
1005        let next_experiments_map = map_experiments(next_experiments);
1006        let prev_enrollments_map = map_enrollments(prev_enrollments);
1007
1008        // Step 1. Build an initial active_features to keep track of
1009        // the features that are being experimented upon.
1010        let mut enrolled_features = HashMap::with_capacity(next_experiments.len());
1011        let mut coenrolling_features = HashMap::with_capacity(next_experiments.len());
1012
1013        let mut next_enrollments = Vec::with_capacity(next_experiments.len());
1014
1015        // Step 2.
1016        // Evolve the experiments with previous enrollments first (except for
1017        // those that already have a feature conflict).  While we're doing so,
1018        // start building up active_features, the map of feature_ids under
1019        // experiment to EnrolledFeatureConfigs, and next_enrollments.
1020
1021        for prev_enrollment in prev_enrollments {
1022            if matches!(
1023                &prev_enrollment.status,
1024                EnrollmentStatus::NotEnrolled {
1025                    reason: NotEnrolledReason::FeatureConflict { conflict_slug },
1026                }
1027                if conflict_slug.is_some()
1028            ) {
1029                continue;
1030            }
1031            let slug = &prev_enrollment.slug;
1032
1033            let next_enrollment = match self.evolve_enrollment(
1034                is_user_participating,
1035                prev_experiments_map.get(slug).copied(),
1036                next_experiments_map.get(slug).copied(),
1037                Some(prev_enrollment),
1038                &mut enrollment_events,
1039                #[cfg(feature = "stateful")]
1040                gecko_pref_store,
1041            ) {
1042                Ok(enrollment) => enrollment,
1043                Err(e) => {
1044                    // It would be a fine thing if we had counters that
1045                    // collected the number of errors here, and at the
1046                    // place in this function where enrollments could be
1047                    // dropped.  We could then send those errors to
1048                    // telemetry so that they could be monitored (SDK-309)
1049                    warn!(
1050                        "{} in evolve_enrollment (with prev_enrollment) returned None; (slug: {}, prev_enrollment: {:?}); ",
1051                        e, slug, prev_enrollment
1052                    );
1053                    None
1054                }
1055            };
1056
1057            #[cfg(feature = "stateful")]
1058            if let Some(ref enrollment) = next_enrollment.clone() {
1059                if self.targeting_helper.update_enrollment(enrollment) {
1060                    debug!("Enrollment updated for {}", enrollment.slug);
1061                } else {
1062                    debug!("Enrollment unchanged for {}", enrollment.slug);
1063                }
1064            }
1065
1066            self.reserve_enrolled_features(
1067                next_enrollment,
1068                &next_experiments_map,
1069                &mut enrolled_features,
1070                &mut coenrolling_features,
1071                &mut next_enrollments,
1072            );
1073        }
1074
1075        // Step 3. Evolve the remaining enrollments with the previous and
1076        // next data.
1077        let next_experiments = sort_experiments_by_published_date(next_experiments);
1078        for next_experiment in next_experiments {
1079            let slug = &next_experiment.slug;
1080
1081            // Check that the feature ids that this experiment needs are available.  If not, then declare
1082            // the enrollment as NotEnrolled; and we continue to the next
1083            // experiment.
1084            // `needed_features_in_use` are the features needed for this experiment, but already in use.
1085            // If this is not empty, then the experiment is either already enrolled, or cannot be enrolled.
1086            let needed_features_in_use: Vec<&EnrolledFeatureConfig> = next_experiment
1087                .get_feature_ids()
1088                .iter()
1089                .filter_map(|id| enrolled_features.get(id))
1090                .collect();
1091            if !needed_features_in_use.is_empty() {
1092                let is_our_experiment = needed_features_in_use.iter().any(|f| &f.slug == slug);
1093                if is_our_experiment {
1094                    // At least one of these conflicted features are in use by this experiment.
1095                    // Unless the experiment has changed midflight, all the features will be from
1096                    // this experiment.
1097                    assert!(needed_features_in_use.iter().all(|f| &f.slug == slug));
1098                    // N.B. If this experiment is enrolled already, then we called
1099                    // evolve_enrollment() on this enrollment and this experiment above.
1100                } else {
1101                    // At least one feature needed for this experiment is already in use by another experiment.
1102                    // Thus, we cannot proceed with an enrollment other than as a `FeatureConflict`.
1103                    next_enrollments.push(ExperimentEnrollment {
1104                        slug: slug.clone(),
1105                        status: EnrollmentStatus::NotEnrolled {
1106                            reason: NotEnrolledReason::FeatureConflict {
1107                                conflict_slug: Some(needed_features_in_use[0].slug.clone()),
1108                            },
1109                        },
1110                    });
1111
1112                    enrollment_events.push(EnrollmentChangeEvent {
1113                        experiment_slug: slug.clone(),
1114                        branch_slug: "N/A".to_string(),
1115                        reason: Some("feature-conflict".to_string()),
1116                        change: EnrollmentChangeEventType::EnrollFailed,
1117                        feature_ids: next_experiment.get_feature_ids(),
1118                    })
1119                }
1120                // Whether it's our experiment or not that is using these features, no further enrollment can
1121                // happen.
1122                // Because no change has happened to this experiment's enrollment status, we don't need
1123                // to log an enrollment event.
1124                // All we can do is continue to the next experiment.
1125                continue;
1126            }
1127
1128            // If we got here, then the features are not already active.
1129            // But we evolved all the existing enrollments in step 2,
1130            // (except the feature conflicted ones)
1131            // so we should be mindful that we don't evolve them a second time.
1132            let prev_enrollment = prev_enrollments_map.get(slug).copied();
1133
1134            if prev_enrollment.is_none()
1135                || matches!(
1136                    &prev_enrollment.unwrap().status,
1137                    EnrollmentStatus::NotEnrolled {
1138                        reason: NotEnrolledReason::FeatureConflict { conflict_slug }
1139                    }
1140                    if conflict_slug.is_some()
1141                )
1142            {
1143                let next_enrollment = match self.evolve_enrollment(
1144                    is_user_participating,
1145                    prev_experiments_map.get(slug).copied(),
1146                    Some(next_experiment),
1147                    prev_enrollment,
1148                    &mut enrollment_events,
1149                    #[cfg(feature = "stateful")]
1150                    gecko_pref_store,
1151                ) {
1152                    Ok(enrollment) => enrollment,
1153                    Err(e) => {
1154                        // It would be a fine thing if we had counters that
1155                        // collected the number of errors here, and at the
1156                        // place in this function where enrollments could be
1157                        // dropped.  We could then send those errors to
1158                        // telemetry so that they could be monitored (SDK-309)
1159                        warn!(
1160                            "{} in evolve_enrollment (with no feature conflict) returned None; (slug: {}, prev_enrollment: {:?}); ",
1161                            e, slug, prev_enrollment
1162                        );
1163                        None
1164                    }
1165                };
1166
1167                #[cfg(feature = "stateful")]
1168                if let Some(ref enrollment) = next_enrollment.clone() {
1169                    if self.targeting_helper.update_enrollment(enrollment) {
1170                        debug!("Enrollment updated for {}", enrollment.slug);
1171                    } else {
1172                        debug!("Enrollment unchanged for {}", enrollment.slug);
1173                    }
1174                }
1175
1176                self.reserve_enrolled_features(
1177                    next_enrollment,
1178                    &next_experiments_map,
1179                    &mut enrolled_features,
1180                    &mut coenrolling_features,
1181                    &mut next_enrollments,
1182                );
1183            }
1184        }
1185
1186        enrolled_features.extend(coenrolling_features);
1187
1188        // Check that we generate the enrolled feature map from the new
1189        // enrollments and new experiments.  Perhaps this should just be an
1190        // assert.
1191        let updated_enrolled_features = map_features(
1192            &next_enrollments,
1193            &next_experiments_map,
1194            self.coenrolling_feature_ids,
1195        );
1196        if enrolled_features != updated_enrolled_features {
1197            Err(NimbusError::InternalError(
1198                "Next enrollment calculation error",
1199            ))
1200        } else {
1201            Ok((next_enrollments, enrollment_events))
1202        }
1203    }
1204
1205    // Book-keeping method used in evolve_enrollments.
1206    fn reserve_enrolled_features(
1207        &self,
1208        latest_enrollment: Option<ExperimentEnrollment>,
1209        experiments: &HashMap<String, &Experiment>,
1210        enrolled_features: &mut HashMap<String, EnrolledFeatureConfig>,
1211        coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1212        enrollments: &mut Vec<ExperimentEnrollment>,
1213    ) {
1214        if let Some(enrollment) = latest_enrollment {
1215            // Now we have an enrollment object!
1216            // If it's an enrolled enrollment, then get the FeatureConfigs
1217            // from the experiment and store them in the enrolled_features or coenrolling_features maps.
1218            for enrolled_feature in get_enrolled_feature_configs(&enrollment, experiments) {
1219                populate_feature_maps(
1220                    enrolled_feature,
1221                    self.coenrolling_feature_ids,
1222                    enrolled_features,
1223                    coenrolling_features,
1224                );
1225            }
1226            // Also, record the enrollment for our return value
1227            enrollments.push(enrollment);
1228        }
1229    }
1230
1231    /// Evolve a single enrollment using the previous and current state of an
1232    /// experiment and maybe garbage collect at least a subset of invalid
1233    /// experiments.
1234    ///
1235    /// XXX need to verify the exact set of gc-related side-effects and
1236    /// document them here.
1237    ///
1238    /// Returns an Option-wrapped version of the updated enrollment.  None
1239    /// means that the enrollment has been/should be discarded.
1240    pub(crate) fn evolve_enrollment(
1241        &mut self,
1242        is_user_participating: bool,
1243        prev_experiment: Option<&Experiment>,
1244        next_experiment: Option<&Experiment>,
1245        prev_enrollment: Option<&ExperimentEnrollment>,
1246        out_enrollment_events: &mut Vec<EnrollmentChangeEvent>, // out param containing the events we'd like to emit to glean.
1247        #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
1248    ) -> Result<Option<ExperimentEnrollment>> {
1249        let is_already_enrolled = prev_enrollment
1250            .map(|e| e.status.is_enrolled())
1251            .unwrap_or_default();
1252
1253        // XXX This is not pretty, however, we need to re-write the way sticky targeting strings are generated in
1254        // experimenter. Once https://github.com/mozilla/experimenter/issues/8661 is fixed, we can remove the calculation
1255        // for `is_already_enrolled` above, the `put` call here and the `put` method declaration, and replace it with
1256        // let th = self.targeting_helper;
1257        let targeting_helper = self
1258            .targeting_helper
1259            .put("is_already_enrolled", is_already_enrolled);
1260
1261        Ok(match (prev_experiment, next_experiment, prev_enrollment) {
1262            // New experiment.
1263            (None, Some(experiment), None) => Some(ExperimentEnrollment::from_new_experiment(
1264                is_user_participating,
1265                self.available_randomization_units,
1266                experiment,
1267                &targeting_helper,
1268                out_enrollment_events,
1269            )?),
1270            // Experiment deleted remotely.
1271            (Some(prev_experiment), None, Some(enrollment)) => enrollment.on_experiment_ended(
1272                prev_experiment,
1273                #[cfg(feature = "stateful")]
1274                gecko_pref_store,
1275                out_enrollment_events,
1276            ),
1277            // Known experiment.
1278            (Some(_), Some(experiment), Some(enrollment)) => {
1279                Some(enrollment.on_experiment_updated(
1280                    is_user_participating,
1281                    self.available_randomization_units,
1282                    experiment,
1283                    &targeting_helper,
1284                    #[cfg(feature = "stateful")]
1285                    gecko_pref_store,
1286                    out_enrollment_events,
1287                )?)
1288            }
1289            (None, None, Some(enrollment)) => enrollment.maybe_garbage_collect(),
1290            (None, Some(_), Some(_)) => {
1291                return Err(NimbusError::InternalError(
1292                    "New experiment but enrollment already exists.",
1293                ));
1294            }
1295            (Some(_), None, None) | (Some(_), Some(_), None) => {
1296                return Err(NimbusError::InternalError(
1297                    "Experiment in the db did not have an associated enrollment record.",
1298                ));
1299            }
1300            (None, None, None) => {
1301                return Err(NimbusError::InternalError(
1302                    "evolve_experiment called with nothing that could evolve or be evolved",
1303                ));
1304            }
1305        })
1306    }
1307}
1308
1309fn map_experiments<E>(experiments: &[E]) -> HashMap<String, &E>
1310where
1311    E: ExperimentMetadata + Clone,
1312{
1313    let mut map_experiments = HashMap::with_capacity(experiments.len());
1314    for e in experiments {
1315        map_experiments.insert(e.get_slug(), e);
1316    }
1317    map_experiments
1318}
1319
1320pub fn map_enrollments(
1321    enrollments: &[ExperimentEnrollment],
1322) -> HashMap<String, &ExperimentEnrollment> {
1323    let mut map_enrollments = HashMap::with_capacity(enrollments.len());
1324    for e in enrollments {
1325        map_enrollments.insert(e.slug.clone(), e);
1326    }
1327    map_enrollments
1328}
1329
1330pub(crate) fn filter_experiments_and_enrollments<E>(
1331    experiments: &[E],
1332    enrollments: &[ExperimentEnrollment],
1333    filter_fn: fn(&E) -> bool,
1334) -> (Vec<E>, Vec<ExperimentEnrollment>)
1335where
1336    E: ExperimentMetadata + Clone,
1337{
1338    let experiments: Vec<E> = filter_experiments(experiments, filter_fn);
1339
1340    let slugs: HashSet<String> = experiments.iter().map(|e| e.get_slug()).collect();
1341
1342    let enrollments: Vec<ExperimentEnrollment> = enrollments
1343        .iter()
1344        .filter(|e| slugs.contains(&e.slug))
1345        .map(|e| e.to_owned())
1346        .collect();
1347
1348    (experiments, enrollments)
1349}
1350
1351fn filter_experiments<E>(experiments: &[E], filter_fn: fn(&E) -> bool) -> Vec<E>
1352where
1353    E: ExperimentMetadata + Clone,
1354{
1355    experiments
1356        .iter()
1357        .filter(|e| filter_fn(e))
1358        .cloned()
1359        .collect()
1360}
1361
1362pub(crate) fn sort_experiments_by_published_date(experiments: &[Experiment]) -> Vec<&Experiment> {
1363    let mut experiments: Vec<_> = experiments.iter().collect();
1364    experiments.sort_by_key(|e| e.published_date);
1365    experiments
1366}
1367
1368/// Take a list of enrollments and a map of experiments, and generate mapping of `feature_id` to
1369/// `EnrolledFeatureConfig` structs.
1370fn map_features(
1371    enrollments: &[ExperimentEnrollment],
1372    experiments: &HashMap<String, &Experiment>,
1373    coenrolling_ids: &HashSet<&str>,
1374) -> HashMap<String, EnrolledFeatureConfig> {
1375    let mut colliding_features = HashMap::with_capacity(enrollments.len());
1376    let mut coenrolling_features = HashMap::with_capacity(enrollments.len());
1377    for enrolled_feature_config in enrollments
1378        .iter()
1379        .flat_map(|e| get_enrolled_feature_configs(e, experiments))
1380    {
1381        populate_feature_maps(
1382            enrolled_feature_config,
1383            coenrolling_ids,
1384            &mut colliding_features,
1385            &mut coenrolling_features,
1386        );
1387    }
1388    colliding_features.extend(coenrolling_features.drain());
1389
1390    colliding_features
1391}
1392
1393pub fn map_features_by_feature_id(
1394    enrollments: &[ExperimentEnrollment],
1395    experiments: &[Experiment],
1396    coenrolling_ids: &HashSet<&str>,
1397) -> HashMap<String, EnrolledFeatureConfig> {
1398    let (rollouts, ro_enrollments) = filter_experiments_and_enrollments(
1399        experiments,
1400        enrollments,
1401        ExperimentMetadata::is_rollout,
1402    );
1403    let (experiments, exp_enrollments) =
1404        filter_experiments_and_enrollments(experiments, enrollments, |exp| !exp.is_rollout());
1405
1406    let features_under_rollout = map_features(
1407        &ro_enrollments,
1408        &map_experiments(&rollouts),
1409        coenrolling_ids,
1410    );
1411    let features_under_experiment = map_features(
1412        &exp_enrollments,
1413        &map_experiments(&experiments),
1414        coenrolling_ids,
1415    );
1416
1417    features_under_experiment
1418        .defaults(&features_under_rollout)
1419        .unwrap()
1420}
1421
1422pub(crate) fn populate_feature_maps(
1423    enrolled_feature: EnrolledFeatureConfig,
1424    coenrolling_feature_ids: &HashSet<&str>,
1425    colliding_features: &mut HashMap<String, EnrolledFeatureConfig>,
1426    coenrolling_features: &mut HashMap<String, EnrolledFeatureConfig>,
1427) {
1428    let feature_id = &enrolled_feature.feature_id;
1429    if !coenrolling_feature_ids.contains(feature_id.as_str()) {
1430        // If we're not allowing co-enrollment for this feature, then add it to enrolled_features.
1431        // We'll use this map to prevent collisions.
1432        colliding_features.insert(feature_id.clone(), enrolled_feature);
1433    } else if let Some(existing) = coenrolling_features.get(feature_id) {
1434        // Otherwise, we'll add to the coenrolling_features map.
1435        // In this branch, we've enrolled in one experiment already before this one.
1436        // We take care to merge this one with the existing one.
1437        let merged = enrolled_feature
1438            .defaults(existing)
1439            .expect("A feature config hasn't been able to merge; this is a bug in Nimbus");
1440
1441        // We change the branch to None, so we don't send exposure events from this feature.
1442        // This is the subject of the ADR for https://mozilla-hub.atlassian.net/browse/EXP-3630.
1443        let merged = EnrolledFeatureConfig {
1444            // We make up the slug by appending. This is only for debugging reasons.
1445            slug: format!("{}+{}", &existing.slug, &enrolled_feature.slug),
1446            branch: None,
1447            ..merged
1448        };
1449        coenrolling_features.insert(feature_id.clone(), merged);
1450    } else {
1451        // In this branch, this is the first time we've added this feature to the coenrolling_features map.
1452        coenrolling_features.insert(feature_id.clone(), enrolled_feature);
1453    }
1454}
1455
1456fn get_enrolled_feature_configs(
1457    enrollment: &ExperimentEnrollment,
1458    experiments: &HashMap<String, &Experiment>,
1459) -> Vec<EnrolledFeatureConfig> {
1460    // If status is not enrolled, then we can leave early.
1461    let branch_slug = match &enrollment.status {
1462        EnrollmentStatus::Enrolled { branch, .. } => branch,
1463        _ => return Vec::new(),
1464    };
1465
1466    let experiment_slug = &enrollment.slug;
1467
1468    let experiment = match experiments.get(experiment_slug).copied() {
1469        Some(exp) => exp,
1470        _ => return Vec::new(),
1471    };
1472
1473    // Get the branch from the experiment, and then get the feature configs
1474    // from there.
1475    let mut branch_features = match &experiment.get_branch(branch_slug) {
1476        Some(branch) => branch.get_feature_configs(),
1477        _ => Default::default(),
1478    };
1479
1480    branch_features.iter_mut().for_each(|f| {
1481        json::replace_str_in_map(&mut f.value, SLUG_REPLACEMENT_PATTERN, experiment_slug);
1482    });
1483
1484    let branch_feature_ids = &branch_features
1485        .iter()
1486        .map(|f| &f.feature_id)
1487        .collect::<HashSet<_>>();
1488
1489    // The experiment might have other branches that deal with different features.
1490    // We don't want them getting involved in other experiments, so we'll make default
1491    // FeatureConfigs.
1492    let non_branch_features: Vec<FeatureConfig> = experiment
1493        .get_feature_ids()
1494        .into_iter()
1495        .filter(|feature_id| !branch_feature_ids.contains(feature_id))
1496        .map(|feature_id| FeatureConfig {
1497            feature_id,
1498            ..Default::default()
1499        })
1500        .collect();
1501
1502    // Now we've got the feature configs for all features in this experiment,
1503    // we can make EnrolledFeatureConfigs with them.
1504    branch_features
1505        .iter()
1506        .chain(non_branch_features.iter())
1507        .map(|f| EnrolledFeatureConfig {
1508            feature: f.to_owned(),
1509            slug: experiment_slug.clone(),
1510            branch: if !experiment.is_rollout() {
1511                Some(branch_slug.clone())
1512            } else {
1513                None
1514            },
1515            feature_id: f.feature_id.clone(),
1516        })
1517        .collect()
1518}
1519
1520/// Small transitory struct to contain all the information needed to configure a feature with the Feature API.
1521/// By design, we don't want to store it on the disk. Instead we calculate it from experiments
1522/// and enrollments.
1523#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1524#[serde(rename_all = "camelCase")]
1525pub struct EnrolledFeatureConfig {
1526    pub feature: FeatureConfig,
1527    pub slug: String,
1528    pub branch: Option<String>,
1529    pub feature_id: String,
1530}
1531
1532impl Defaults for EnrolledFeatureConfig {
1533    fn defaults(&self, fallback: &Self) -> Result<Self> {
1534        if self.feature_id != fallback.feature_id {
1535            // This is unlikely to happen, but if it does it's a bug in Nimbus
1536            Err(NimbusError::InternalError(
1537                "Cannot merge enrolled feature configs from different features",
1538            ))
1539        } else {
1540            Ok(Self {
1541                slug: self.slug.to_owned(),
1542                feature_id: self.feature_id.to_owned(),
1543                // Merge the actual feature config.
1544                feature: self.feature.defaults(&fallback.feature)?,
1545                // If this is an experiment, then this will be Some(_).
1546                // The feature is involved in zero or one experiments, and 0 or more rollouts.
1547                // So we can clone this Option safely.
1548                branch: self.branch.to_owned(),
1549            })
1550        }
1551    }
1552}
1553
1554impl ExperimentMetadata for EnrolledFeatureConfig {
1555    fn get_slug(&self) -> String {
1556        self.slug.clone()
1557    }
1558
1559    fn is_rollout(&self) -> bool {
1560        self.branch.is_none()
1561    }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq)]
1565pub struct EnrolledFeature {
1566    pub slug: String,
1567    pub branch: Option<String>,
1568    pub feature_id: String,
1569}
1570
1571impl From<&EnrolledFeatureConfig> for EnrolledFeature {
1572    fn from(value: &EnrolledFeatureConfig) -> Self {
1573        Self {
1574            slug: value.slug.clone(),
1575            branch: value.branch.clone(),
1576            feature_id: value.feature_id.clone(),
1577        }
1578    }
1579}
1580
1581#[derive(Serialize, Deserialize, Debug, Clone)]
1582#[cfg_attr(test, derive(Eq, PartialEq))]
1583pub struct EnrollmentChangeEvent {
1584    pub experiment_slug: String,
1585    pub branch_slug: String,
1586    pub reason: Option<String>,
1587    pub change: EnrollmentChangeEventType,
1588    pub feature_ids: Vec<String>,
1589}
1590
1591impl EnrollmentChangeEvent {
1592    pub(crate) fn new(
1593        slug: &str,
1594        branch: &str,
1595        reason: Option<&str>,
1596        change: EnrollmentChangeEventType,
1597        experiment: Option<&Experiment>,
1598    ) -> Self {
1599        Self {
1600            experiment_slug: slug.to_owned(),
1601            branch_slug: branch.to_owned(),
1602            reason: reason.map(|s| s.to_owned()),
1603            change,
1604            feature_ids: experiment.map(|e| e.get_feature_ids()).unwrap_or_default(),
1605        }
1606    }
1607}
1608
1609#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1610pub enum EnrollmentChangeEventType {
1611    Enrollment,
1612    EnrollFailed,
1613    Disqualification,
1614    Unenrollment,
1615    #[cfg_attr(not(feature = "stateful"), allow(unused))]
1616    UnenrollFailed,
1617}
1618
1619pub(crate) fn now_secs() -> u64 {
1620    SystemTime::now()
1621        .duration_since(UNIX_EPOCH)
1622        .expect("Current date before Unix Epoch.")
1623        .as_secs()
1624}