nimbus/stateful/
gecko_prefs.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/. */
4use std::cmp::Ordering;
5use std::collections::{HashMap, HashSet};
6use std::fmt::{Display, Formatter};
7use std::sync::{Arc, Mutex, MutexGuard};
8
9use serde_derive::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::enrollment::{EnrollmentStatus, ExperimentEnrollment, PreviousGeckoPrefState};
13use crate::error::Result;
14use crate::json::PrefValue;
15use crate::{EnrolledExperiment, Experiment, NimbusError};
16
17#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq, Copy)]
18#[serde(rename_all = "lowercase")]
19pub enum PrefBranch {
20    Default,
21    User,
22}
23
24impl Display for PrefBranch {
25    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
26        match self {
27            PrefBranch::Default => f.write_str("default"),
28            PrefBranch::User => f.write_str("user"),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct GeckoPref {
35    pub pref: String,
36    pub branch: PrefBranch,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PrefEnrollmentData {
41    pub experiment_slug: String,
42    pub pref_value: PrefValue,
43    pub feature_id: String,
44    pub variable: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct GeckoPrefState {
49    pub gecko_pref: GeckoPref,
50    pub gecko_value: Option<PrefValue>,
51    pub enrollment_value: Option<PrefEnrollmentData>,
52    pub is_user_set: bool,
53}
54
55impl GeckoPrefState {
56    pub fn new(pref: &str, branch: Option<PrefBranch>) -> Self {
57        Self {
58            gecko_pref: GeckoPref {
59                pref: pref.into(),
60                branch: branch.unwrap_or(PrefBranch::Default),
61            },
62            gecko_value: None,
63            enrollment_value: None,
64            is_user_set: false,
65        }
66    }
67
68    pub fn with_gecko_value(mut self, value: PrefValue) -> Self {
69        self.gecko_value = Some(value);
70        self
71    }
72
73    pub fn with_enrollment_value(mut self, pref_enrollment_data: PrefEnrollmentData) -> Self {
74        self.enrollment_value = Some(pref_enrollment_data);
75        self
76    }
77
78    pub fn set_by_user(mut self) -> Self {
79        self.is_user_set = true;
80        self
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq, Copy)]
85pub enum PrefUnenrollReason {
86    Changed,
87    FailedToSet,
88}
89
90// The pre-experiment original state of a Gecko pref. Values may be used to set on Gecko to restore the pref to the original state.
91// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
92// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
93#[derive(Deserialize, Serialize, Debug, Clone, Hash, Eq, PartialEq)]
94pub struct OriginalGeckoPref {
95    pub pref: String,
96    pub branch: PrefBranch,
97    pub value: Option<PrefValue>,
98}
99
100impl<'a> From<&'a GeckoPrefState> for OriginalGeckoPref {
101    fn from(state: &'a GeckoPrefState) -> Self {
102        Self {
103            pref: state.gecko_pref.pref.clone(),
104            branch: state.gecko_pref.branch,
105            value: state.gecko_value.clone(),
106        }
107    }
108}
109
110pub type MapOfFeatureIdToPropertyNameToGeckoPrefState =
111    HashMap<String, HashMap<String, GeckoPrefState>>;
112
113pub fn create_feature_prop_pref_map(
114    list: Vec<(&str, &str, GeckoPrefState)>,
115) -> MapOfFeatureIdToPropertyNameToGeckoPrefState {
116    list.iter().fold(
117        HashMap::new(),
118        |mut feature_map, (feature_id, prop_name, pref_state)| {
119            feature_map
120                .entry(feature_id.to_string())
121                .or_default()
122                .insert(prop_name.to_string(), pref_state.clone());
123            feature_map
124        },
125    )
126}
127
128pub trait GeckoPrefHandler: Send + Sync {
129    /// Used to obtain the prefs values from Gecko
130    fn get_prefs_with_state(&self) -> MapOfFeatureIdToPropertyNameToGeckoPrefState;
131
132    /// Used to set the state for each pref based on enrollments
133    fn set_gecko_prefs_state(&self, new_prefs_state: Vec<GeckoPrefState>);
134
135    /// Used to set back to the original state for each pref based on the original Gecko value
136    fn set_gecko_prefs_original_values(&self, original_gecko_prefs: Vec<OriginalGeckoPref>);
137}
138
139#[derive(Default)]
140pub struct GeckoPrefStoreState {
141    pub gecko_prefs_with_state: MapOfFeatureIdToPropertyNameToGeckoPrefState,
142}
143
144impl GeckoPrefStoreState {
145    pub fn update_pref_state(&mut self, new_pref_state: &GeckoPrefState) -> bool {
146        self.gecko_prefs_with_state
147            .iter_mut()
148            .find_map(|(_, props)| {
149                props.iter_mut().find_map(|(_, pref_state)| {
150                    if pref_state.gecko_pref.pref == new_pref_state.gecko_pref.pref {
151                        *pref_state = new_pref_state.clone();
152                        Some(true)
153                    } else {
154                        None
155                    }
156                })
157            })
158            .is_some()
159    }
160}
161
162pub struct GeckoPrefStore {
163    // This is Arc<Box<_>> because of FFI
164    pub handler: Arc<Box<dyn GeckoPrefHandler>>,
165    pub state: Mutex<GeckoPrefStoreState>,
166}
167
168impl GeckoPrefStore {
169    pub fn new(handler: Arc<Box<dyn GeckoPrefHandler>>) -> Self {
170        Self {
171            handler,
172            state: Mutex::new(GeckoPrefStoreState::default()),
173        }
174    }
175
176    pub fn initialize(&self) -> Result<()> {
177        let prefs = self.handler.get_prefs_with_state();
178        let mut state = self
179            .state
180            .lock()
181            .expect("Unable to lock GeckoPrefStore state");
182        state.gecko_prefs_with_state = prefs;
183
184        Ok(())
185    }
186
187    pub fn get_mutable_pref_state(&self) -> MutexGuard<'_, GeckoPrefStoreState> {
188        self.state
189            .lock()
190            .expect("Unable to lock GeckoPrefStore state")
191    }
192
193    pub fn pref_is_user_set(&self, pref: &str) -> bool {
194        let state = self.get_mutable_pref_state();
195        state
196            .gecko_prefs_with_state
197            .iter()
198            .find_map(|(_, props)| {
199                props.iter().find_map(|(_, gecko_pref_state)| {
200                    if gecko_pref_state.gecko_pref.pref == pref {
201                        Some(gecko_pref_state.is_user_set)
202                    } else {
203                        None
204                    }
205                })
206            })
207            .unwrap_or(false)
208    }
209
210    /// This method accomplishes a number of tasks important to the Gecko pref enrollment workflow.
211    /// 1. It returns a map of pref string to a vector of enrolled recipes in which the value for
212    ///    the enrolled branch's feature values includes the property of that feature that sets the
213    ///    aforementioned pref.
214    /// 2. It updates the GeckoPrefStore state, such that the appropriate GeckoPrefState's
215    ///    `enrollment_value` reflects the appropriate value.
216    pub fn map_gecko_prefs_to_enrollment_slugs_and_update_store(
217        &self,
218        // contains full experiment metadata
219        experiments: &[Experiment],
220        // contains enrollment status for a given experiment
221        enrollments: &[ExperimentEnrollment],
222        // contains slug of enrolled branch
223        experiments_by_slug: &HashMap<String, EnrolledExperiment>,
224    ) -> HashMap<String, HashSet<String>> {
225        struct RecipeData<'a> {
226            experiment: &'a Experiment,
227            experiment_enrollment: &'a ExperimentEnrollment,
228            branch_slug: &'a str,
229        }
230
231        let mut state = self.get_mutable_pref_state();
232
233        /* List of tuples that contain recipe slug, rollout bool, list of feature ids, and
234         * branch, in that order.
235         */
236        let mut recipe_data: Vec<RecipeData> = vec![];
237
238        for experiment_enrollment in enrollments {
239            let experiment = match experiments
240                .iter()
241                .find(|experiment| experiment.slug == experiment_enrollment.slug)
242            {
243                Some(exp) => exp,
244                None => continue,
245            };
246            recipe_data.push(RecipeData {
247                experiment,
248                experiment_enrollment,
249                branch_slug: match experiments_by_slug.get(&experiment.slug) {
250                    Some(ee) => &ee.branch_slug,
251                    None => continue,
252                },
253            });
254        }
255        // sort `recipe_data` such that rollouts are applied before experiments
256        recipe_data.sort_by(
257            |a, b| match (a.experiment.is_rollout, b.experiment.is_rollout) {
258                (true, false) => Ordering::Less,
259                (false, true) => Ordering::Greater,
260                _ => Ordering::Equal,
261            },
262        );
263
264        /* This map will ultimately be returned from the function, as a map of pref strings to
265         * relevant enrolled recipe slugs, 'relevant' meaning experiments whose enrolled branch
266         * values apply a value to a prop for which there is a Gecko pref.
267         *
268         * We start by iterating mutably over the map of features to props to gecko prefs.
269         */
270        let mut results: HashMap<String, HashSet<String>> = HashMap::new();
271
272        for (feature_name, props) in state.gecko_prefs_with_state.iter_mut() {
273            let mut has_matching_recipes = false;
274            for RecipeData {
275                experiment:
276                    Experiment {
277                        slug,
278                        feature_ids,
279                        branches,
280                        ..
281                    },
282                experiment_enrollment,
283                branch_slug,
284            } in &recipe_data
285            {
286                if feature_ids.contains(feature_name)
287                    && matches!(
288                        experiment_enrollment.status,
289                        EnrollmentStatus::Enrolled { .. }
290                    )
291                {
292                    let branch = match branches.iter().find(|branch| &branch.slug == branch_slug) {
293                        Some(b) => b,
294                        None => continue,
295                    };
296                    has_matching_recipes = true;
297                    for (feature, prop_name, prop_value) in branch.get_feature_props_and_values() {
298                        if feature == *feature_name && props.contains_key(&prop_name) {
299                            // set the enrollment_value for this gecko pref.
300                            // rollouts and experiments on the same feature will
301                            // both set the value here, but rollouts will happen
302                            // first, and will therefore be overridden by
303                            // experiments.
304                            props.entry(prop_name.clone()).and_modify(|pref_state| {
305                                pref_state.enrollment_value = Some(PrefEnrollmentData {
306                                    experiment_slug: slug.clone(),
307                                    pref_value: prop_value.clone(),
308                                    feature_id: feature,
309                                    variable: prop_name,
310                                });
311                                results
312                                    .entry(pref_state.gecko_pref.pref.clone())
313                                    .or_default()
314                                    .insert(slug.clone());
315                            });
316                        }
317                    }
318                }
319            }
320
321            if !has_matching_recipes {
322                for (_, pref_state) in props.iter_mut() {
323                    pref_state.enrollment_value = None;
324                }
325            }
326        }
327
328        // obtain a list of all Gecko pref states for which there is an enrollment value
329        let mut set_state_list = Vec::new();
330        state.gecko_prefs_with_state.iter().for_each(|(_, props)| {
331            props.iter().for_each(|(_, pref_state)| {
332                if pref_state.enrollment_value.is_some() {
333                    set_state_list.push(pref_state.clone());
334                }
335            });
336        });
337        // tell the handler to set the aforementioned Gecko prefs
338        self.handler.set_gecko_prefs_state(set_state_list);
339
340        results
341    }
342}
343
344pub fn query_gecko_pref_store(
345    gecko_pref_store: Option<Arc<GeckoPrefStore>>,
346    args: &[Value],
347) -> Result<Value> {
348    if args.len() != 1 {
349        return Err(NimbusError::TransformParameterError(
350            "gecko_pref transform preferenceIsUserSet requires exactly 1 parameter".into(),
351        ));
352    }
353
354    let gecko_pref = match serde_json::from_value::<String>(args.first().unwrap().clone()) {
355        Ok(v) => v,
356        Err(e) => return Err(NimbusError::JSONError("gecko_pref = nimbus::stateful::gecko_prefs::query_gecko_prefs_store::serde_json::from_value".into(), e.to_string()))
357    };
358
359    Ok(gecko_pref_store
360        .map(|store| Value::Bool(store.pref_is_user_set(&gecko_pref)))
361        .unwrap_or(Value::Bool(false)))
362}
363
364pub(crate) type MapOfExperimentSlugToPreviousState = HashMap<String, Vec<PreviousGeckoPrefState>>;
365pub(crate) fn build_prev_gecko_pref_states(
366    states: &[GeckoPrefState],
367) -> MapOfExperimentSlugToPreviousState {
368    let mut original_gecko_states = MapOfExperimentSlugToPreviousState::new();
369    for state in states {
370        let Some(enrollment_value) = &state.enrollment_value else {
371            continue;
372        };
373        original_gecko_states
374            .entry(enrollment_value.experiment_slug.clone())
375            .or_default()
376            .push(PreviousGeckoPrefState {
377                original_value: state.into(),
378                feature_id: enrollment_value.feature_id.clone(),
379                variable: enrollment_value.variable.clone(),
380            });
381    }
382    original_gecko_states
383}