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