nimbus/stateful/
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::iter;
6
7use crate::enrollment::Participation;
8use crate::enrollment::{
9    DisqualifiedReason, EnrolledReason, EnrollmentChangeEvent, EnrollmentChangeEventType,
10    EnrollmentsEvolver, ExperimentEnrollment, NotEnrolledReason, PreviousGeckoPrefState,
11    map_enrollments,
12};
13use crate::error::{Result, debug, warn};
14use crate::stateful::firefox_labs::{
15    FirefoxLabsEnrollResult, FirefoxLabsEnrollStatus, FirefoxLabsUnenrollResult,
16    FirefoxLabsUnenrollStatus,
17};
18use crate::stateful::gecko_prefs::GeckoPrefStore;
19use crate::stateful::gecko_prefs::PrefUnenrollReason;
20use crate::stateful::persistence::{
21    DB_KEY_EXPERIMENT_PARTICIPATION, DB_KEY_ROLLOUT_PARTICIPATION,
22    DEFAULT_EXPERIMENT_PARTICIPATION, DEFAULT_ROLLOUT_PARTICIPATION,
23};
24use crate::stateful::persistence::{Database, Readable, StoreId, Writer};
25use crate::{EnrolledExperiment, EnrollmentStatus, Experiment};
26
27impl EnrollmentsEvolver<'_> {
28    /// Convenient wrapper around `evolve_enrollments` that fetches the current state of experiments,
29    /// enrollments and user participation from the database.
30    pub(crate) fn evolve_enrollments_in_db(
31        &mut self,
32        db: &Database,
33        writer: &mut Writer,
34        next_experiments: &[Experiment],
35        gecko_pref_store: Option<&GeckoPrefStore>,
36    ) -> Result<Vec<EnrollmentChangeEvent>> {
37        // Get separate participation states from the db
38        let is_participating_in_experiments = get_experiment_participation(db, writer)?;
39        let is_participating_in_rollouts = get_rollout_participation(db, writer)?;
40
41        let participation = Participation {
42            in_experiments: is_participating_in_experiments,
43            in_rollouts: is_participating_in_rollouts,
44        };
45
46        let experiments_store = db.get_store(StoreId::Experiments);
47        let enrollments_store = db.get_store(StoreId::Enrollments);
48        let prev_experiments: Vec<Experiment> = experiments_store.collect_all(writer)?;
49        let prev_enrollments: Vec<ExperimentEnrollment> = enrollments_store.collect_all(writer)?;
50        // Calculate the changes.
51        let (next_enrollments, enrollments_change_events) = self.evolve_enrollments(
52            participation,
53            &prev_experiments,
54            next_experiments,
55            &prev_enrollments,
56            gecko_pref_store,
57        )?;
58        let next_enrollments = map_enrollments(&next_enrollments);
59        // Write the changes to the Database.
60        enrollments_store.clear(writer)?;
61        for enrollment in next_enrollments.values() {
62            enrollments_store.put(writer, &enrollment.slug, *enrollment)?;
63        }
64        experiments_store.clear(writer)?;
65        for experiment in next_experiments {
66            // Sanity check.
67            if !next_enrollments.contains_key(&experiment.slug) {
68                error_support::report_error!(
69                    "nimbus-evolve-enrollments",
70                    "evolve_enrollments_in_db: experiment '{}' has no enrollment, dropping to keep database consistent",
71                    &experiment.slug
72                );
73                continue;
74            }
75            experiments_store.put(writer, &experiment.slug, experiment)?;
76        }
77        Ok(enrollments_change_events)
78    }
79}
80
81/// Return information about all enrolled experiments.
82/// Note this does not include rollouts
83pub fn get_enrollments<'r>(
84    db: &Database,
85    reader: &'r impl Readable<'r>,
86) -> Result<Vec<EnrolledExperiment>> {
87    let enrollments: Vec<ExperimentEnrollment> =
88        db.get_store(StoreId::Enrollments).collect_all(reader)?;
89    let mut result = Vec::with_capacity(enrollments.len());
90    for enrollment in enrollments {
91        debug!("Have enrollment: {:?}", enrollment);
92        if let EnrollmentStatus::Enrolled { branch, .. } = &enrollment.status {
93            match db
94                .get_store(StoreId::Experiments)
95                .get::<Experiment, _>(reader, &enrollment.slug)?
96            {
97                Some(experiment) => {
98                    result.push(EnrolledExperiment {
99                        feature_ids: experiment.get_feature_ids(),
100                        slug: experiment.slug,
101                        user_facing_name: experiment.user_facing_name,
102                        user_facing_description: experiment.user_facing_description,
103                        branch_slug: branch.to_string(),
104                        is_rollout: experiment.is_rollout,
105                    });
106                }
107                _ => {
108                    warn!(
109                        "Have enrollment {:?} but no matching experiment!",
110                        enrollment
111                    );
112                }
113            };
114        }
115    }
116    Ok(result)
117}
118
119pub fn opt_in_with_branch(
120    db: &Database,
121    writer: &mut Writer,
122    experiment_slug: &str,
123    branch: &str,
124) -> Result<Vec<EnrollmentChangeEvent>> {
125    let mut events = vec![];
126    if let Ok(Some(exp)) = db
127        .get_store(StoreId::Experiments)
128        .get::<Experiment, Writer>(writer, experiment_slug)
129    {
130        let enrollment = ExperimentEnrollment::from_explicit_opt_in(
131            &exp,
132            branch,
133            EnrolledReason::OptIn,
134            &mut events,
135        );
136        db.get_store(StoreId::Enrollments)
137            .put(writer, experiment_slug, &enrollment.unwrap())?;
138    } else {
139        events.push(EnrollmentChangeEvent {
140            experiment_slug: experiment_slug.to_string(),
141            branch_slug: branch.to_string(),
142            reason: Some("does-not-exist".to_string()),
143            change: EnrollmentChangeEventType::EnrollFailed,
144            feature_ids: vec![],
145        });
146    }
147
148    Ok(events)
149}
150
151pub fn enroll_in_firefox_lab(
152    db: &Database,
153    writer: &mut Writer,
154    slug: &str,
155    feature_conflict: Option<bool>,
156) -> Result<FirefoxLabsEnrollResult> {
157    let mut events = vec![];
158
159    let status = match feature_conflict {
160        None => FirefoxLabsEnrollStatus::NoExperiment,
161
162        Some(true) => FirefoxLabsEnrollStatus::FeatureConflict,
163
164        Some(false) => match get_enrollment_and_experiment(db, writer, slug) {
165            // We computed feature_conflict via the dbcache, so we actually
166            // can't hit this case, but rewriting all the enrollment update in
167            // terms of the dbcache is a much larger endeavour.
168            //
169            // This technically could have been written in terms of the dbcache
170            // on the first pass, however, no other enrollment logic writes to
171            // the database from the cache, so it would be less obvious if we
172            // missed something.
173            //
174            // TODO(bug 2038055): rewrite in terms of the db cache
175            Ok((_, None)) => FirefoxLabsEnrollStatus::NoExperiment,
176
177            Ok((_, Some(experiment))) if !experiment.is_valid_firefox_lab() => {
178                FirefoxLabsEnrollStatus::NotFirefoxLabsOptIn
179            }
180
181            Ok((Some(enrollment), _)) if enrollment.status.is_enrolled() => {
182                FirefoxLabsEnrollStatus::AlreadyEnrolled
183            }
184
185            Ok((_, Some(experiment))) => {
186                let new_enrollment = ExperimentEnrollment::from_explicit_opt_in(
187                    &experiment,
188                    &experiment.branches[0].slug,
189                    EnrolledReason::FirefoxLabsOptIn,
190                    &mut events,
191                )?;
192                db.get_store(StoreId::Enrollments)
193                    .put(writer, slug, &new_enrollment)?;
194
195                FirefoxLabsEnrollStatus::Enrolled
196            }
197
198            Err(_) => FirefoxLabsEnrollStatus::Error,
199        },
200    };
201
202    if status != FirefoxLabsEnrollStatus::Enrolled {
203        events.push(EnrollmentChangeEvent {
204            experiment_slug: slug.to_string(),
205            branch_slug: "N/A".to_string(),
206            reason: Some(
207                match status {
208                    FirefoxLabsEnrollStatus::Enrolled => unreachable!("status != Enrolled"),
209                    FirefoxLabsEnrollStatus::AlreadyEnrolled => "already-enrolled",
210                    FirefoxLabsEnrollStatus::NoExperiment => "lab-does-not-exist",
211                    FirefoxLabsEnrollStatus::NotFirefoxLabsOptIn => "not-lab",
212                    FirefoxLabsEnrollStatus::FeatureConflict => "feature-conflict",
213                    FirefoxLabsEnrollStatus::Error => "error",
214                }
215                .into(),
216            ),
217            change: EnrollmentChangeEventType::EnrollFailed,
218            feature_ids: vec![],
219        });
220    }
221
222    Ok(FirefoxLabsEnrollResult {
223        status,
224        enrollment_change_events: events,
225    })
226}
227
228pub fn unenroll_from_firefox_lab(
229    db: &Database,
230    writer: &mut Writer,
231    slug: &str,
232    gecko_prefs: Option<&GeckoPrefStore>,
233) -> Result<FirefoxLabsUnenrollResult> {
234    let mut events = vec![];
235
236    let status = match get_enrollment_and_experiment(db, writer, slug) {
237        Ok((_, Some(experiment))) if !experiment.is_valid_firefox_lab() => {
238            FirefoxLabsUnenrollStatus::NotFirefoxLabsOptIn
239        }
240        Ok((_, None)) => FirefoxLabsUnenrollStatus::NoExperiment,
241        Ok((Some(enrollment), _)) if !enrollment.status.is_enrolled() => {
242            FirefoxLabsUnenrollStatus::AlreadyUnenrolled
243        }
244        Ok((Some(enrollment), experiment)) => {
245            let updated_enrollment = enrollment.on_explicit_opt_out(
246                experiment.as_ref(),
247                &mut events,
248                DisqualifiedReason::FirefoxLabsOptOut,
249                gecko_prefs,
250            );
251            db.get_store(StoreId::Enrollments)
252                .put(writer, slug, &updated_enrollment)?;
253
254            FirefoxLabsUnenrollStatus::Unenrolled
255        }
256        Ok((None, _)) => FirefoxLabsUnenrollStatus::NoExperiment,
257        Err(_) => FirefoxLabsUnenrollStatus::Error,
258    };
259
260    if status != FirefoxLabsUnenrollStatus::Unenrolled {
261        events.push(EnrollmentChangeEvent {
262            experiment_slug: slug.into(),
263            branch_slug: "N/A".into(),
264            reason: Some(
265                match status {
266                    FirefoxLabsUnenrollStatus::Unenrolled => unreachable!("status != Unenrolled"),
267                    FirefoxLabsUnenrollStatus::AlreadyUnenrolled => "already-unenrolled",
268                    FirefoxLabsUnenrollStatus::NoExperiment => "lab-does-not-exist",
269                    FirefoxLabsUnenrollStatus::NotFirefoxLabsOptIn => "not-lab",
270                    FirefoxLabsUnenrollStatus::Error => "error",
271                }
272                .into(),
273            ),
274            change: EnrollmentChangeEventType::UnenrollFailed,
275            feature_ids: vec![],
276        });
277    }
278
279    Ok(FirefoxLabsUnenrollResult {
280        status,
281        enrollment_change_events: events,
282    })
283}
284
285pub fn unenroll_from_all_firefox_labs(
286    db: &Database,
287    writer: &mut Writer,
288    gecko_prefs: Option<&GeckoPrefStore>,
289) -> Result<Vec<EnrollmentChangeEvent>> {
290    // TODO(bug 2038055): Compute this using the database cache.
291
292    let mut events = vec![];
293    let enrollments: Vec<ExperimentEnrollment> =
294        db.get_store(StoreId::Enrollments).collect_all(writer)?;
295
296    for enrollment in &enrollments {
297        if !enrollment
298            .status
299            .is_enrolled_with_reason(EnrolledReason::FirefoxLabsOptIn)
300        {
301            continue;
302        }
303        let experiment: Option<Experiment> = db
304            .get_store(StoreId::Experiments)
305            .get(writer, &enrollment.slug)?;
306
307        let updated_enrollment = enrollment.on_explicit_opt_out(
308            experiment.as_ref(),
309            &mut events,
310            DisqualifiedReason::FirefoxLabsOptOut,
311            gecko_prefs,
312        );
313
314        db.get_store(StoreId::Enrollments)
315            .put(writer, &enrollment.slug, &updated_enrollment)?;
316    }
317
318    Ok(events)
319}
320
321fn get_enrollment_and_experiment(
322    db: &Database,
323    writer: &mut Writer,
324    experiment_slug: &str,
325) -> Result<(Option<ExperimentEnrollment>, Option<Experiment>)> {
326    // TODO(bug 2038055): Compute this using the database cache.
327    let maybe_enrollment: Option<ExperimentEnrollment> = db
328        .get_store(StoreId::Enrollments)
329        .get(writer, experiment_slug)?;
330    let maybe_experiment: Option<Experiment> = db
331        .get_store(StoreId::Experiments)
332        .get(writer, experiment_slug)?;
333
334    // We are technically guaranteed at this time that if an active enrollment
335    // exists in the enrollments store that the corresponding experiment must
336    // also exist in the experiment store.
337    //
338    // This is only not true during apply_pending_experiments.
339    Ok((maybe_enrollment, maybe_experiment))
340}
341
342pub fn opt_out(
343    db: &Database,
344    writer: &mut Writer,
345    experiment_slug: &str,
346    gecko_prefs: Option<&GeckoPrefStore>,
347) -> Result<Vec<EnrollmentChangeEvent>> {
348    let mut events = vec![];
349
350    match get_enrollment_and_experiment(db, writer, experiment_slug) {
351        Ok((Some(existing_enrollment), maybe_experiment)) => {
352            let updated_enrollment = &existing_enrollment.on_explicit_opt_out(
353                maybe_experiment.as_ref(),
354                &mut events,
355                DisqualifiedReason::OptOut,
356                gecko_prefs,
357            );
358
359            db.get_store(StoreId::Enrollments)
360                .put(writer, experiment_slug, updated_enrollment)?;
361        }
362
363        _ => {
364            events.push(EnrollmentChangeEvent {
365                experiment_slug: experiment_slug.to_string(),
366                branch_slug: "N/A".to_string(),
367                reason: Some("does-not-exist".to_string()),
368                change: EnrollmentChangeEventType::UnenrollFailed,
369                feature_ids: vec![],
370            });
371        }
372    }
373
374    Ok(events)
375}
376
377#[cfg(feature = "stateful")]
378pub fn unenroll_for_pref(
379    db: &Database,
380    writer: &mut Writer,
381    experiment_slug: &str,
382    unenroll_reason: PrefUnenrollReason,
383    triggering_pref_name: &str,
384    gecko_pref_store: Option<&GeckoPrefStore>,
385    events: &mut Vec<EnrollmentChangeEvent>,
386) -> Result<()> {
387    match get_enrollment_and_experiment(db, writer, experiment_slug) {
388        Ok((Some(existing_enrollment), maybe_experiment)) => {
389            existing_enrollment
390                .maybe_revert_unchanged_gecko_pref_states(triggering_pref_name, gecko_pref_store);
391
392            let updated_enrollment = &existing_enrollment.on_pref_unenroll(
393                unenroll_reason,
394                maybe_experiment.as_ref(),
395                events,
396            );
397            db.get_store(StoreId::Enrollments)
398                .put(writer, experiment_slug, updated_enrollment)?;
399        }
400
401        _ => {
402            events.push(EnrollmentChangeEvent {
403                experiment_slug: experiment_slug.to_string(),
404                branch_slug: "N/A".to_string(),
405                reason: Some("does-not-exist".to_string()),
406                change: EnrollmentChangeEventType::UnenrollFailed,
407                feature_ids: vec![],
408            });
409        }
410    }
411
412    Ok(())
413}
414
415pub fn get_experiment_participation<'r>(
416    db: &Database,
417    reader: &'r impl Readable<'r>,
418) -> Result<bool> {
419    let store = db.get_store(StoreId::Meta);
420    let opted_in = store.get::<bool, _>(reader, DB_KEY_EXPERIMENT_PARTICIPATION)?;
421    if let Some(opted_in) = opted_in {
422        Ok(opted_in)
423    } else {
424        Ok(DEFAULT_EXPERIMENT_PARTICIPATION)
425    }
426}
427
428pub fn get_rollout_participation<'r>(db: &Database, reader: &'r impl Readable<'r>) -> Result<bool> {
429    let store = db.get_store(StoreId::Meta);
430    let opted_in = store.get::<bool, _>(reader, DB_KEY_ROLLOUT_PARTICIPATION)?;
431    if let Some(opted_in) = opted_in {
432        Ok(opted_in)
433    } else {
434        Ok(DEFAULT_ROLLOUT_PARTICIPATION)
435    }
436}
437
438pub fn set_experiment_participation(
439    db: &Database,
440    writer: &mut Writer,
441    opt_in: bool,
442) -> Result<()> {
443    let store = db.get_store(StoreId::Meta);
444    store.put(writer, DB_KEY_EXPERIMENT_PARTICIPATION, &opt_in)
445}
446
447pub fn set_rollout_participation(db: &Database, writer: &mut Writer, opt_in: bool) -> Result<()> {
448    let store = db.get_store(StoreId::Meta);
449    store.put(writer, DB_KEY_ROLLOUT_PARTICIPATION, &opt_in)
450}
451
452/// Reset unique identifiers in response to application-level telemetry reset.
453///
454pub fn reset_telemetry_identifiers(
455    db: &Database,
456    writer: &mut Writer,
457) -> Result<Vec<EnrollmentChangeEvent>> {
458    let mut events = vec![];
459    let store = db.get_store(StoreId::Enrollments);
460    let enrollments: Vec<ExperimentEnrollment> = store.collect_all(writer)?;
461    // TODO(bug 2038055): Compute this using the database cache.
462    let experiments: Vec<Option<Experiment>> = enrollments
463        .iter()
464        .map(|enrollment| {
465            db.get_store(StoreId::Experiments)
466                .get::<Experiment, _>(writer, &enrollment.slug)
467        })
468        .collect::<Result<_>>()?;
469
470    let updated_enrollments =
471        iter::zip(enrollments, experiments).map(|(enrollment, experiment)| {
472            enrollment.reset_telemetry_identifiers(experiment.as_ref(), &mut events)
473        });
474    store.clear(writer)?;
475    for enrollment in updated_enrollments {
476        store.put(writer, &enrollment.slug, &enrollment)?;
477    }
478    Ok(events)
479}
480
481pub mod v3 {
482    // This module contains legacy enrollment structs that mirror the schema of enrollments stored as they were in the database as of v3. These are used for deserializing pre-migration enrollments during the migration process, and should not be used outside of that context.
483
484    use super::*;
485    use serde::{Deserialize, Serialize};
486
487    #[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
488    pub enum LegacyNotEnrolledReason {
489        DifferentAppName,
490        DifferentChannel,
491        EnrollmentsPaused,
492        FeatureConflict,
493        NotSelected,
494        NotTargeted,
495        OptOut,
496    }
497
498    #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
499    pub struct LegacyExperimentEnrollment {
500        pub slug: String,
501        pub status: LegacyEnrollmentStatus,
502    }
503
504    #[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
505    pub enum LegacyEnrollmentStatus {
506        Enrolled {
507            reason: EnrolledReason,
508            branch: String,
509            #[serde(skip_serializing_if = "Option::is_none")]
510            prev_gecko_pref_states: Option<Vec<PreviousGeckoPrefState>>,
511        },
512        NotEnrolled {
513            reason: LegacyNotEnrolledReason,
514        },
515        Disqualified {
516            reason: DisqualifiedReason,
517            branch: String,
518        },
519        WasEnrolled {
520            branch: String,
521            experiment_ended_at: u64,
522        },
523        Error {
524            reason: String,
525        },
526    }
527
528    impl From<LegacyNotEnrolledReason> for NotEnrolledReason {
529        #[allow(deprecated)]
530        fn from(value: LegacyNotEnrolledReason) -> Self {
531            match value {
532                LegacyNotEnrolledReason::DifferentAppName => NotEnrolledReason::DifferentAppName,
533                LegacyNotEnrolledReason::DifferentChannel => NotEnrolledReason::DifferentChannel,
534                LegacyNotEnrolledReason::EnrollmentsPaused => NotEnrolledReason::EnrollmentsPaused,
535                LegacyNotEnrolledReason::FeatureConflict => NotEnrolledReason::FeatureConflict {
536                    conflict_slug: None,
537                },
538                LegacyNotEnrolledReason::NotSelected => NotEnrolledReason::NotSelected,
539                LegacyNotEnrolledReason::NotTargeted => NotEnrolledReason::NotTargeted,
540                LegacyNotEnrolledReason::OptOut => NotEnrolledReason::OptOut,
541            }
542        }
543    }
544
545    impl From<LegacyEnrollmentStatus> for EnrollmentStatus {
546        fn from(value: LegacyEnrollmentStatus) -> Self {
547            match value {
548                LegacyEnrollmentStatus::Enrolled {
549                    reason,
550                    branch,
551                    prev_gecko_pref_states,
552                } => EnrollmentStatus::Enrolled {
553                    reason,
554                    branch,
555                    prev_gecko_pref_states,
556                },
557                LegacyEnrollmentStatus::NotEnrolled { reason } => EnrollmentStatus::NotEnrolled {
558                    reason: reason.into(),
559                },
560                LegacyEnrollmentStatus::Disqualified { reason, branch } => {
561                    EnrollmentStatus::Disqualified { reason, branch }
562                }
563                LegacyEnrollmentStatus::WasEnrolled {
564                    branch,
565                    experiment_ended_at,
566                } => EnrollmentStatus::WasEnrolled {
567                    branch,
568                    experiment_ended_at,
569                },
570                LegacyEnrollmentStatus::Error { reason } => EnrollmentStatus::Error { reason },
571            }
572        }
573    }
574
575    impl From<LegacyExperimentEnrollment> for ExperimentEnrollment {
576        fn from(value: LegacyExperimentEnrollment) -> Self {
577            ExperimentEnrollment {
578                slug: value.slug,
579                status: value.status.into(),
580            }
581        }
582    }
583}