nimbus/
enrollment.rs

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