1use 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
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 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 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}