nimbus/stateful/
dbcache.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::collections::{HashMap, HashSet};
6use std::sync::{Arc, RwLock};
7
8use crate::enrollment::{
9    EnrolledFeature, EnrolledFeatureConfig, ExperimentEnrollment, map_features_by_feature_id,
10};
11use crate::error::{NimbusError, Result, debug, warn};
12use crate::evaluator::{CanEnrollResult, can_enroll};
13use crate::stateful::enrollment::get_enrollments;
14use crate::stateful::firefox_labs::FirefoxLabsMetadata;
15use crate::stateful::gecko_prefs::GeckoPrefStore;
16use crate::stateful::persistence::{Database, StoreId, Writer};
17use crate::targeting::NimbusTargetingHelper;
18use crate::{AvailableRandomizationUnits, EnrolledExperiment, Experiment};
19
20// This module manages an in-memory cache of the database, so that some
21// functions exposed by nimbus can return results without blocking on any
22// IO. Consumers are expected to call our public `update()` function whenever
23// the database might have changed.
24
25// This struct is the cached data. This is never mutated, but instead
26// recreated every time the cache is updated.
27struct CachedData {
28    pub experiments: Vec<Experiment>,
29    pub enrollments: Vec<ExperimentEnrollment>,
30    pub experiments_by_slug: HashMap<String, EnrolledExperiment>,
31    pub features_by_feature_id: HashMap<String, EnrolledFeatureConfig>,
32    pub gecko_pref_to_enrollment_slugs: Option<HashMap<String, HashSet<String>>>,
33}
34
35// This is the public cache API. Each NimbusClient can create one of these and
36// it lives as long as the client - it encapsulates the synchronization needed
37// to allow the cache to work correctly.
38#[derive(Default)]
39pub struct DatabaseCache {
40    data: RwLock<Option<CachedData>>,
41}
42
43impl DatabaseCache {
44    // Call this function whenever it's possible that anything cached by this
45    // struct (eg, our enrollments) might have changed.
46    //
47    // This function must be passed a `&Database` and a `Writer`, which it
48    // will commit before updating the in-memory cache. This is a slightly weird
49    // API but it helps enforce two important properties:
50    //
51    //  * By requiring a `Writer`, we ensure mutual exclusion of other db writers
52    //    and thus prevent the possibility of caching stale data.
53    //  * By taking ownership of the `Writer`, we ensure that the calling code
54    //    updates the cache after all of its writes have been performed.
55    //  * `update_gecko_prefs` - Pass true for regular enrollment changes. Pass false
56    //     when the Gecko prefs do not need to be synced with Gecko.
57    pub fn commit_and_update(
58        &self,
59        db: &Database,
60        writer: Writer,
61        coenrolling_ids: &HashSet<&str>,
62        gecko_pref_store: Option<Arc<GeckoPrefStore>>,
63        update_gecko_prefs: bool,
64    ) -> Result<()> {
65        // By passing in the active `writer` we read the state of enrollments
66        // as written by the calling code, before it's committed to the db.
67        let enrollments = get_enrollments(db, &writer)?;
68
69        // Build a lookup table for experiments by experiment slug.
70        // This will be used for get_experiment_branch() and get_active_experiments()
71        let mut experiments_by_slug = HashMap::with_capacity(enrollments.len());
72        for e in enrollments {
73            experiments_by_slug.insert(e.slug.clone(), e);
74        }
75
76        let enrollments: Vec<ExperimentEnrollment> =
77            db.get_store(StoreId::Enrollments).collect_all(&writer)?;
78        let experiments: Vec<Experiment> =
79            db.get_store(StoreId::Experiments).collect_all(&writer)?;
80
81        let features_by_feature_id =
82            map_features_by_feature_id(&enrollments, &experiments, coenrolling_ids);
83
84        let gecko_pref_to_enrollment_slugs = gecko_pref_store.map(|store| {
85            store.map_gecko_prefs_to_enrollment_slugs_and_update_store(
86                &experiments,
87                &enrollments,
88                &experiments_by_slug,
89                update_gecko_prefs,
90            )
91        });
92
93        // This is where testing tools would override i.e. replace experimental feature configurations.
94        // i.e. testing tools would cause custom feature configs to be stored in a Store.
95        // Here, we get those overrides out of the store, and merge it with this map.
96
97        // This is where rollouts (promoted experiments on a given feature) will be merged in to the feature variables.
98
99        let data = CachedData {
100            experiments,
101            enrollments,
102            experiments_by_slug,
103            features_by_feature_id,
104            gecko_pref_to_enrollment_slugs,
105        };
106
107        // Try to commit the change to disk and update the cache as close
108        // together in time as possible. This leaves a small window where another
109        // thread could read new data from disk but see old data in the cache,
110        // but that seems benign in practice given the way we use the cache.
111        // The alternative would be to lock the cache while we commit to disk,
112        // and we don't want to risk blocking the main thread.
113        writer.commit()?;
114        let mut cached = self.data.write().unwrap();
115        cached.replace(data);
116        Ok(())
117    }
118
119    // Abstracts safely referencing our cached data.
120    //
121    // WARNING: because this manages locking, the callers of this need to be
122    // careful regarding deadlocks - if the callback takes other own locks then
123    // there's a risk of locks being taken in an inconsistent order. However,
124    // there's nothing this code specifically can do about that.
125    fn get_data<T, F>(&self, func: F) -> Result<T>
126    where
127        F: FnOnce(&CachedData) -> T,
128    {
129        match *self.data.read().unwrap() {
130            None => {
131                warn!("DatabaseCache attempting to read data before initialization is completed");
132                Err(NimbusError::DatabaseNotReady)
133            }
134            Some(ref data) => Ok(func(data)),
135        }
136    }
137
138    pub fn get_experiment_branch(&self, id: &str) -> Result<Option<String>> {
139        self.get_data(|data| -> Option<String> {
140            data.experiments_by_slug
141                .get(id)
142                .map(|experiment| experiment.branch_slug.clone())
143        })
144    }
145
146    // This gives access to the feature JSON. We pass it as a string because uniffi doesn't
147    // support JSON yet.
148    pub fn get_feature_config_variables(&self, feature_id: &str) -> Result<Option<String>> {
149        self.get_data(|data| {
150            let enrolled_feature = data.features_by_feature_id.get(feature_id)?;
151            let string = serde_json::to_string(&enrolled_feature.feature.value).unwrap();
152            Some(string)
153        })
154    }
155
156    pub fn get_enrollment_by_feature(&self, feature_id: &str) -> Result<Option<EnrolledFeature>> {
157        self.get_data(|data| {
158            data.features_by_feature_id
159                .get(feature_id)
160                .map(|feature| feature.into())
161        })
162    }
163
164    pub fn get_active_experiments(&self) -> Result<Vec<EnrolledExperiment>> {
165        self.get_data(|data| {
166            data.experiments_by_slug
167                .values()
168                .map(|e| e.to_owned())
169                .collect::<Vec<EnrolledExperiment>>()
170        })
171    }
172
173    pub fn get_experiments(&self) -> Result<Vec<Experiment>> {
174        self.get_data(|data| data.experiments.to_vec())
175    }
176
177    pub fn get_enrollments(&self) -> Result<Vec<ExperimentEnrollment>> {
178        self.get_data(|data| data.enrollments.to_owned())
179    }
180
181    pub fn get_enrollments_for_pref(&self, pref: &str) -> Result<Option<HashSet<String>>> {
182        self.get_data(|data| {
183            if let Some(a) = &data.gecko_pref_to_enrollment_slugs {
184                Ok(a.get(pref).cloned())
185            } else {
186                Ok(None)
187            }
188        })?
189    }
190
191    pub fn check_for_feature_conflict(
192        &self,
193        slug: &str,
194        coenrolling_feature_ids: &[String],
195    ) -> Result<Option<bool>> {
196        self.get_data(|data| {
197            if data.experiments_by_slug.contains_key(slug) {
198                // Cannot conflict with itself.
199                return Some(false);
200            }
201
202            if let Some(experiment) = data.experiments.iter().find(|e| e.slug == slug) {
203                let coenrolling_feature_ids: HashSet<&str> =
204                    coenrolling_feature_ids.iter().map(|s| s.as_ref()).collect();
205
206                let enrolled_feature_ids =
207                    compute_enrolled_feature_ids(&data.experiments_by_slug, true);
208
209                Some(!features_available(
210                    experiment,
211                    &enrolled_feature_ids,
212                    &coenrolling_feature_ids,
213                ))
214            } else {
215                None
216            }
217        })
218    }
219
220    pub fn get_available_firefox_labs_metadata(
221        &self,
222        available_randomization_units: &AvailableRandomizationUnits,
223        targeting_helper: &NimbusTargetingHelper,
224        coenrolling_feature_ids: &[String],
225    ) -> Result<Vec<FirefoxLabsMetadata>> {
226        let mut all_labs: Vec<_> = self.get_data(|data| {
227            let enrolled_feature_ids =
228                compute_enrolled_feature_ids(&data.experiments_by_slug, true);
229
230            let coenrolling_feature_ids: HashSet<&str> =
231                coenrolling_feature_ids.iter().map(|s| s.as_ref()).collect();
232
233            debug!("firefox labs: querying experiments...");
234            let available = data
235                .experiments
236                .iter()
237                .filter_map(|experiment| {
238                    if !experiment.is_firefox_labs_opt_in {
239                        debug!(
240                            "firefox labs: {}: not a firefox labs opt-in",
241                            experiment.slug
242                        );
243                        return None;
244                    }
245
246                    let enrolled = data.experiments_by_slug.contains_key(&experiment.slug);
247                    match can_enroll(available_randomization_units, targeting_helper, experiment) {
248                        CanEnrollResult::Enrollable { .. } => {}
249
250                        CanEnrollResult::Unavailable { reason } => {
251                            debug!("firefox labs: {}: unavailable: {}", experiment.slug, reason);
252                            return None;
253                        }
254
255                        CanEnrollResult::TargetingError { reason } => {
256                            debug!(
257                                "firefox labs: {}: targeting error: {}",
258                                experiment.slug, reason
259                            );
260                            return None;
261                        }
262
263                        CanEnrollResult::NotTargeted => {
264                            debug!("firefox labs: {}: not targeted", experiment.slug);
265                            return None;
266                        }
267
268                        CanEnrollResult::NotSelected => {
269                            debug!("firefox labs: {}: not selected", experiment.slug);
270                            return None;
271                        }
272
273                        CanEnrollResult::NoRandomizationUnit => {
274                            debug!("firefox labs: {}: no randomization unit", experiment.slug);
275                            return None;
276                        }
277                    }
278
279                    if !enrolled {
280                        let feature_conflict = !features_available(
281                            experiment,
282                            &enrolled_feature_ids,
283                            &coenrolling_feature_ids,
284                        );
285
286                        if feature_conflict {
287                            debug!("firefox labs: {}: feature conflict", experiment.slug);
288                            return None;
289                        }
290
291                        if experiment.is_enrollment_paused {
292                            debug!("firefox labs: {}: enrollment paused", experiment.slug);
293                            return None;
294                        }
295                    }
296
297                    let metadata = experiment.get_firefox_labs_metadata(enrolled);
298                    if metadata.is_none() {
299                        debug!("firefox labs: {}: invalid lab", experiment.slug);
300                    } else {
301                        debug!("firefox labs: {}: available", experiment.slug);
302                    }
303
304                    metadata
305                })
306                .collect();
307
308            debug!("firefox labs: finished querying experiments");
309
310            available
311        })?;
312
313        // XXX: This is maybe only useful for tests, but at least we get a
314        // stable order.
315        all_labs.sort_by(|e1, e2| Ord::cmp(&e1.slug, &e2.slug));
316
317        Ok(all_labs)
318    }
319
320    #[cfg(test)]
321    pub fn get_experiment_enrollment(&self, slug: &str) -> Result<Option<ExperimentEnrollment>> {
322        self.get_data(|data| data.enrollments.iter().find(|e| e.slug == slug).cloned())
323    }
324}
325
326fn compute_enrolled_feature_ids(
327    experiments_by_slug: &HashMap<String, EnrolledExperiment>,
328    is_rollout: bool,
329) -> HashSet<&str> {
330    experiments_by_slug
331        .values()
332        .filter(|e| e.is_rollout == is_rollout)
333        .flat_map(|e| e.feature_ids.iter())
334        .map(|f| f.as_ref())
335        .collect()
336}
337
338fn features_available(
339    experiment: &Experiment,
340    enrolled_feature_ids: &HashSet<&str>,
341    coenrolling_feature_ids: &HashSet<&str>,
342) -> bool {
343    for feature_id in &experiment.feature_ids {
344        if enrolled_feature_ids.contains(&**feature_id)
345            && !coenrolling_feature_ids.contains(&**feature_id)
346        {
347            return false;
348        }
349    }
350
351    true
352}