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, warn};
12use crate::stateful::enrollment::get_enrollments;
13use crate::stateful::gecko_prefs::GeckoPrefStore;
14use crate::stateful::persistence::{Database, StoreId, Writer};
15use crate::{EnrolledExperiment, Experiment};
16
17// This module manages an in-memory cache of the database, so that some
18// functions exposed by nimbus can return results without blocking on any
19// IO. Consumers are expected to call our public `update()` function whenever
20// the database might have changed.
21
22// This struct is the cached data. This is never mutated, but instead
23// recreated every time the cache is updated.
24struct CachedData {
25 pub experiments: Vec<Experiment>,
26 pub enrollments: Vec<ExperimentEnrollment>,
27 pub experiments_by_slug: HashMap<String, EnrolledExperiment>,
28 pub features_by_feature_id: HashMap<String, EnrolledFeatureConfig>,
29 pub gecko_pref_to_enrollment_slugs: Option<HashMap<String, HashSet<String>>>,
30}
31
32// This is the public cache API. Each NimbusClient can create one of these and
33// it lives as long as the client - it encapsulates the synchronization needed
34// to allow the cache to work correctly.
35#[derive(Default)]
36pub struct DatabaseCache {
37 data: RwLock<Option<CachedData>>,
38}
39
40impl DatabaseCache {
41 // Call this function whenever it's possible that anything cached by this
42 // struct (eg, our enrollments) might have changed.
43 //
44 // This function must be passed a `&Database` and a `Writer`, which it
45 // will commit before updating the in-memory cache. This is a slightly weird
46 // API but it helps enforce two important properties:
47 //
48 // * By requiring a `Writer`, we ensure mutual exclusion of other db writers
49 // and thus prevent the possibility of caching stale data.
50 // * By taking ownership of the `Writer`, we ensure that the calling code
51 // updates the cache after all of its writes have been performed.
52 // * `update_gecko_prefs` - Pass true for regular enrollment changes. Pass false
53 // when the Gecko prefs do not need to be synced with Gecko.
54 pub fn commit_and_update(
55 &self,
56 db: &Database,
57 writer: Writer,
58 coenrolling_ids: &HashSet<&str>,
59 gecko_pref_store: Option<Arc<GeckoPrefStore>>,
60 update_gecko_prefs: bool,
61 ) -> Result<()> {
62 // By passing in the active `writer` we read the state of enrollments
63 // as written by the calling code, before it's committed to the db.
64 let enrollments = get_enrollments(db, &writer)?;
65
66 // Build a lookup table for experiments by experiment slug.
67 // This will be used for get_experiment_branch() and get_active_experiments()
68 let mut experiments_by_slug = HashMap::with_capacity(enrollments.len());
69 for e in enrollments {
70 experiments_by_slug.insert(e.slug.clone(), e);
71 }
72
73 let enrollments: Vec<ExperimentEnrollment> =
74 db.get_store(StoreId::Enrollments).collect_all(&writer)?;
75 let experiments: Vec<Experiment> =
76 db.get_store(StoreId::Experiments).collect_all(&writer)?;
77
78 let features_by_feature_id =
79 map_features_by_feature_id(&enrollments, &experiments, coenrolling_ids);
80
81 let gecko_pref_to_enrollment_slugs = gecko_pref_store.map(|store| {
82 store.map_gecko_prefs_to_enrollment_slugs_and_update_store(
83 &experiments,
84 &enrollments,
85 &experiments_by_slug,
86 update_gecko_prefs,
87 )
88 });
89
90 // This is where testing tools would override i.e. replace experimental feature configurations.
91 // i.e. testing tools would cause custom feature configs to be stored in a Store.
92 // Here, we get those overrides out of the store, and merge it with this map.
93
94 // This is where rollouts (promoted experiments on a given feature) will be merged in to the feature variables.
95
96 let data = CachedData {
97 experiments,
98 enrollments,
99 experiments_by_slug,
100 features_by_feature_id,
101 gecko_pref_to_enrollment_slugs,
102 };
103
104 // Try to commit the change to disk and update the cache as close
105 // together in time as possible. This leaves a small window where another
106 // thread could read new data from disk but see old data in the cache,
107 // but that seems benign in practice given the way we use the cache.
108 // The alternative would be to lock the cache while we commit to disk,
109 // and we don't want to risk blocking the main thread.
110 writer.commit()?;
111 let mut cached = self.data.write().unwrap();
112 cached.replace(data);
113 Ok(())
114 }
115
116 // Abstracts safely referencing our cached data.
117 //
118 // WARNING: because this manages locking, the callers of this need to be
119 // careful regarding deadlocks - if the callback takes other own locks then
120 // there's a risk of locks being taken in an inconsistent order. However,
121 // there's nothing this code specifically can do about that.
122 fn get_data<T, F>(&self, func: F) -> Result<T>
123 where
124 F: FnOnce(&CachedData) -> T,
125 {
126 match *self.data.read().unwrap() {
127 None => {
128 warn!("DatabaseCache attempting to read data before initialization is completed");
129 Err(NimbusError::DatabaseNotReady)
130 }
131 Some(ref data) => Ok(func(data)),
132 }
133 }
134
135 pub fn get_experiment_branch(&self, id: &str) -> Result<Option<String>> {
136 self.get_data(|data| -> Option<String> {
137 data.experiments_by_slug
138 .get(id)
139 .map(|experiment| experiment.branch_slug.clone())
140 })
141 }
142
143 // This gives access to the feature JSON. We pass it as a string because uniffi doesn't
144 // support JSON yet.
145 pub fn get_feature_config_variables(&self, feature_id: &str) -> Result<Option<String>> {
146 self.get_data(|data| {
147 let enrolled_feature = data.features_by_feature_id.get(feature_id)?;
148 let string = serde_json::to_string(&enrolled_feature.feature.value).unwrap();
149 Some(string)
150 })
151 }
152
153 pub fn get_enrollment_by_feature(&self, feature_id: &str) -> Result<Option<EnrolledFeature>> {
154 self.get_data(|data| {
155 data.features_by_feature_id
156 .get(feature_id)
157 .map(|feature| feature.into())
158 })
159 }
160
161 pub fn get_active_experiments(&self) -> Result<Vec<EnrolledExperiment>> {
162 self.get_data(|data| {
163 data.experiments_by_slug
164 .values()
165 .map(|e| e.to_owned())
166 .collect::<Vec<EnrolledExperiment>>()
167 })
168 }
169
170 pub fn get_experiments(&self) -> Result<Vec<Experiment>> {
171 self.get_data(|data| data.experiments.to_vec())
172 }
173
174 pub fn get_enrollments(&self) -> Result<Vec<ExperimentEnrollment>> {
175 self.get_data(|data| data.enrollments.to_owned())
176 }
177
178 pub fn get_enrollments_for_pref(&self, pref: &str) -> Result<Option<HashSet<String>>> {
179 self.get_data(|data| {
180 if let Some(a) = &data.gecko_pref_to_enrollment_slugs {
181 Ok(a.get(pref).cloned())
182 } else {
183 Ok(None)
184 }
185 })?
186 }
187}