nimbus/stateful/
dbcache.rs1use 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::evaluator::{ExperimentAvailable, is_experiment_available};
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::{EnrolledExperiment, Experiment};
19
20struct 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#[derive(Default)]
39pub struct DatabaseCache {
40 data: RwLock<Option<CachedData>>,
41}
42
43impl DatabaseCache {
44 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 let enrollments = get_enrollments(db, &writer)?;
68
69 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 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 writer.commit()?;
114 let mut cached = self.data.write().unwrap();
115 cached.replace(data);
116 Ok(())
117 }
118
119 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 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 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 targeting_helper: &NimbusTargetingHelper,
223 coenrolling_feature_ids: &[String],
224 ) -> Result<Vec<FirefoxLabsMetadata>> {
225 let mut all_labs: Vec<_> = self.get_data(|data| {
226 let enrolled_feature_ids =
227 compute_enrolled_feature_ids(&data.experiments_by_slug, true);
228
229 let coenrolling_feature_ids: HashSet<&str> =
230 coenrolling_feature_ids.iter().map(|s| s.as_ref()).collect();
231
232 data.experiments
233 .iter()
234 .filter_map(|experiment| {
235 if !experiment.is_firefox_labs_opt_in {
236 return None;
237 }
238
239 let enrolled = data.experiments_by_slug.contains_key(&experiment.slug);
240
241 if is_experiment_available(targeting_helper, experiment, true)
246 == ExperimentAvailable::Available
247 && (enrolled
248 || (features_available(
249 experiment,
250 &enrolled_feature_ids,
251 &coenrolling_feature_ids,
252 ) && !experiment.is_enrollment_paused))
253 {
254 experiment.get_firefox_labs_metadata(enrolled)
255 } else {
256 None
257 }
258 })
259 .collect()
260 })?;
261
262 all_labs.sort_by(|e1, e2| Ord::cmp(&e1.slug, &e2.slug));
265
266 Ok(all_labs)
267 }
268
269 #[cfg(test)]
270 pub fn get_experiment_enrollment(&self, slug: &str) -> Result<Option<ExperimentEnrollment>> {
271 self.get_data(|data| data.enrollments.iter().find(|e| e.slug == slug).cloned())
272 }
273}
274
275fn compute_enrolled_feature_ids(
276 experiments_by_slug: &HashMap<String, EnrolledExperiment>,
277 is_rollout: bool,
278) -> HashSet<&str> {
279 experiments_by_slug
280 .values()
281 .filter(|e| e.is_rollout == is_rollout)
282 .flat_map(|e| e.feature_ids.iter())
283 .map(|f| f.as_ref())
284 .collect()
285}
286
287fn features_available(
288 experiment: &Experiment,
289 enrolled_feature_ids: &HashSet<&str>,
290 coenrolling_feature_ids: &HashSet<&str>,
291) -> bool {
292 for feature_id in &experiment.feature_ids {
293 if enrolled_feature_ids.contains(&**feature_id)
294 && !coenrolling_feature_ids.contains(&**feature_id)
295 {
296 return false;
297 }
298 }
299
300 true
301}