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