1use 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 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 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 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 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 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
81pub 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 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 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 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 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
452pub 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 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 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}