firebase_rs_sdk/remote_config/
storage.rs

1//! Remote Config storage cache and metadata handling.
2//!
3//! This mirrors the behaviour of the JavaScript `Storage` + `StorageCache` pair found in
4//! `packages/remote-config/src/storage/`. The Rust implementation keeps everything in-process for now
5//! but exposes a trait so a persistent backend can be plugged in later.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::fs;
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex};
12
13use crate::remote_config::error::{internal_error, RemoteConfigResult};
14use serde::{Deserialize, Serialize};
15
16/// Outcome of the last Remote Config fetch attempt.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum FetchStatus {
19    NoFetchYet,
20    Success,
21    Failure,
22    Throttle,
23}
24
25impl Default for FetchStatus {
26    fn default() -> Self {
27        FetchStatus::NoFetchYet
28    }
29}
30
31impl FetchStatus {
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            FetchStatus::NoFetchYet => "no-fetch-yet",
35            FetchStatus::Success => "success",
36            FetchStatus::Failure => "failure",
37            FetchStatus::Throttle => "throttle",
38        }
39    }
40}
41
42/// Abstraction over the persistence layer used to store Remote Config metadata.
43///
44/// A synchronous interface keeps usage ergonomic for the in-memory stub while still allowing
45/// different backends to be introduced later on.
46pub trait RemoteConfigStorage: Send + Sync {
47    fn get_last_fetch_status(&self) -> RemoteConfigResult<Option<FetchStatus>>;
48    fn set_last_fetch_status(&self, status: FetchStatus) -> RemoteConfigResult<()>;
49
50    fn get_last_successful_fetch_timestamp_millis(&self) -> RemoteConfigResult<Option<u64>>;
51    fn set_last_successful_fetch_timestamp_millis(&self, timestamp: u64) -> RemoteConfigResult<()>;
52
53    fn get_active_config(&self) -> RemoteConfigResult<Option<HashMap<String, String>>>;
54    fn set_active_config(&self, config: HashMap<String, String>) -> RemoteConfigResult<()>;
55
56    fn get_active_config_etag(&self) -> RemoteConfigResult<Option<String>>;
57    fn set_active_config_etag(&self, etag: Option<String>) -> RemoteConfigResult<()>;
58
59    fn get_active_config_template_version(&self) -> RemoteConfigResult<Option<u64>>;
60    fn set_active_config_template_version(
61        &self,
62        template_version: Option<u64>,
63    ) -> RemoteConfigResult<()>;
64}
65
66/// In-memory storage backend backing the current stub implementation.
67#[derive(Default)]
68pub struct InMemoryRemoteConfigStorage {
69    inner: Mutex<StorageRecord>,
70}
71
72#[derive(Clone, Debug, Default, Serialize, Deserialize)]
73struct StorageRecord {
74    last_fetch_status: Option<FetchStatus>,
75    last_successful_fetch_timestamp_millis: Option<u64>,
76    active_config: Option<HashMap<String, String>>,
77    active_config_etag: Option<String>,
78    active_config_template_version: Option<u64>,
79}
80
81impl RemoteConfigStorage for InMemoryRemoteConfigStorage {
82    fn get_last_fetch_status(&self) -> RemoteConfigResult<Option<FetchStatus>> {
83        Ok(self.inner.lock().unwrap().last_fetch_status)
84    }
85
86    fn set_last_fetch_status(&self, status: FetchStatus) -> RemoteConfigResult<()> {
87        self.inner.lock().unwrap().last_fetch_status = Some(status);
88        Ok(())
89    }
90
91    fn get_last_successful_fetch_timestamp_millis(&self) -> RemoteConfigResult<Option<u64>> {
92        Ok(self
93            .inner
94            .lock()
95            .unwrap()
96            .last_successful_fetch_timestamp_millis)
97    }
98
99    fn set_last_successful_fetch_timestamp_millis(&self, timestamp: u64) -> RemoteConfigResult<()> {
100        self.inner
101            .lock()
102            .unwrap()
103            .last_successful_fetch_timestamp_millis = Some(timestamp);
104        Ok(())
105    }
106
107    fn get_active_config(&self) -> RemoteConfigResult<Option<HashMap<String, String>>> {
108        Ok(self.inner.lock().unwrap().active_config.clone())
109    }
110
111    fn set_active_config(&self, config: HashMap<String, String>) -> RemoteConfigResult<()> {
112        self.inner.lock().unwrap().active_config = Some(config);
113        Ok(())
114    }
115
116    fn get_active_config_etag(&self) -> RemoteConfigResult<Option<String>> {
117        Ok(self.inner.lock().unwrap().active_config_etag.clone())
118    }
119
120    fn set_active_config_etag(&self, etag: Option<String>) -> RemoteConfigResult<()> {
121        self.inner.lock().unwrap().active_config_etag = etag;
122        Ok(())
123    }
124
125    fn get_active_config_template_version(&self) -> RemoteConfigResult<Option<u64>> {
126        Ok(self.inner.lock().unwrap().active_config_template_version)
127    }
128
129    fn set_active_config_template_version(
130        &self,
131        template_version: Option<u64>,
132    ) -> RemoteConfigResult<()> {
133        self.inner.lock().unwrap().active_config_template_version = template_version;
134        Ok(())
135    }
136}
137
138/// Memory cache mirroring the JS SDK `StorageCache` abstraction.
139pub struct RemoteConfigStorageCache {
140    storage: Arc<dyn RemoteConfigStorage>,
141    last_fetch_status: Mutex<FetchStatus>,
142    last_successful_fetch_timestamp_millis: Mutex<Option<u64>>,
143    active_config: Mutex<HashMap<String, String>>,
144    active_config_etag: Mutex<Option<String>>,
145    active_config_template_version: Mutex<Option<u64>>,
146}
147
148/// File-backed Remote Config storage suitable for desktop environments.
149pub struct FileRemoteConfigStorage {
150    path: PathBuf,
151    inner: Mutex<StorageRecord>,
152}
153
154impl fmt::Debug for RemoteConfigStorageCache {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("RemoteConfigStorageCache")
157            .field("last_fetch_status", &self.last_fetch_status())
158            .field(
159                "last_successful_fetch_timestamp_millis",
160                &self.last_successful_fetch_timestamp_millis(),
161            )
162            .field("active_config_size", &self.active_config().len())
163            .field("active_config_etag", &self.active_config_etag())
164            .finish()
165    }
166}
167
168impl RemoteConfigStorageCache {
169    pub fn new(storage: Arc<dyn RemoteConfigStorage>) -> Self {
170        let cache = Self {
171            storage,
172            last_fetch_status: Mutex::new(FetchStatus::NoFetchYet),
173            last_successful_fetch_timestamp_millis: Mutex::new(None),
174            active_config: Mutex::new(HashMap::new()),
175            active_config_etag: Mutex::new(None),
176            active_config_template_version: Mutex::new(None),
177        };
178        cache.load_from_storage();
179        cache
180    }
181
182    fn load_from_storage(&self) {
183        if let Ok(Some(status)) = self.storage.get_last_fetch_status() {
184            *self.last_fetch_status.lock().unwrap() = status;
185        }
186        if let Ok(Some(timestamp)) = self.storage.get_last_successful_fetch_timestamp_millis() {
187            *self.last_successful_fetch_timestamp_millis.lock().unwrap() = Some(timestamp);
188        }
189        if let Ok(Some(config)) = self.storage.get_active_config() {
190            *self.active_config.lock().unwrap() = config;
191        }
192        if let Ok(Some(etag)) = self.storage.get_active_config_etag() {
193            *self.active_config_etag.lock().unwrap() = Some(etag);
194        }
195        if let Ok(Some(template_version)) = self.storage.get_active_config_template_version() {
196            *self.active_config_template_version.lock().unwrap() = Some(template_version);
197        }
198    }
199
200    pub fn last_fetch_status(&self) -> FetchStatus {
201        *self.last_fetch_status.lock().unwrap()
202    }
203
204    pub fn set_last_fetch_status(&self, status: FetchStatus) -> RemoteConfigResult<()> {
205        self.storage.set_last_fetch_status(status)?;
206        *self.last_fetch_status.lock().unwrap() = status;
207        Ok(())
208    }
209
210    pub fn last_successful_fetch_timestamp_millis(&self) -> Option<u64> {
211        *self.last_successful_fetch_timestamp_millis.lock().unwrap()
212    }
213
214    pub fn set_last_successful_fetch_timestamp_millis(
215        &self,
216        timestamp: u64,
217    ) -> RemoteConfigResult<()> {
218        self.storage
219            .set_last_successful_fetch_timestamp_millis(timestamp)?;
220        *self.last_successful_fetch_timestamp_millis.lock().unwrap() = Some(timestamp);
221        Ok(())
222    }
223
224    pub fn active_config(&self) -> HashMap<String, String> {
225        self.active_config.lock().unwrap().clone()
226    }
227
228    pub fn set_active_config(&self, config: HashMap<String, String>) -> RemoteConfigResult<()> {
229        self.storage.set_active_config(config.clone())?;
230        *self.active_config.lock().unwrap() = config;
231        Ok(())
232    }
233
234    pub fn active_config_etag(&self) -> Option<String> {
235        self.active_config_etag.lock().unwrap().clone()
236    }
237
238    pub fn set_active_config_etag(&self, etag: Option<String>) -> RemoteConfigResult<()> {
239        self.storage.set_active_config_etag(etag.clone())?;
240        *self.active_config_etag.lock().unwrap() = etag;
241        Ok(())
242    }
243
244    pub fn storage(&self) -> Arc<dyn RemoteConfigStorage> {
245        Arc::clone(&self.storage)
246    }
247
248    pub fn active_config_template_version(&self) -> Option<u64> {
249        *self.active_config_template_version.lock().unwrap()
250    }
251
252    pub fn set_active_config_template_version(
253        &self,
254        template_version: Option<u64>,
255    ) -> RemoteConfigResult<()> {
256        self.storage
257            .set_active_config_template_version(template_version)?;
258        *self.active_config_template_version.lock().unwrap() = template_version;
259        Ok(())
260    }
261}
262
263impl FileRemoteConfigStorage {
264    pub fn new(path: PathBuf) -> RemoteConfigResult<Self> {
265        let record = if path.exists() {
266            Self::load_record(&path)?
267        } else {
268            StorageRecord::default()
269        };
270        Ok(Self {
271            path,
272            inner: Mutex::new(record),
273        })
274    }
275
276    fn load_record(path: &PathBuf) -> RemoteConfigResult<StorageRecord> {
277        let data = fs::read(path)
278            .map_err(|err| internal_error(format!("failed to read storage file: {err}")))?;
279        serde_json::from_slice(&data)
280            .map_err(|err| internal_error(format!("failed to parse storage file as JSON: {err}")))
281    }
282
283    fn persist(&self, record: &StorageRecord) -> RemoteConfigResult<()> {
284        if let Some(parent) = self.path.parent() {
285            fs::create_dir_all(parent).map_err(|err| {
286                internal_error(format!("failed to create storage directory: {err}"))
287            })?;
288        }
289        let serialized = serde_json::to_vec_pretty(record)
290            .map_err(|err| internal_error(format!("failed to serialize storage record: {err}")))?;
291        fs::write(&self.path, serialized)
292            .map_err(|err| internal_error(format!("failed to write storage file: {err}")))?;
293        Ok(())
294    }
295}
296
297impl RemoteConfigStorage for FileRemoteConfigStorage {
298    fn get_last_fetch_status(&self) -> RemoteConfigResult<Option<FetchStatus>> {
299        Ok(self.inner.lock().unwrap().last_fetch_status)
300    }
301
302    fn set_last_fetch_status(&self, status: FetchStatus) -> RemoteConfigResult<()> {
303        let mut record = self.inner.lock().unwrap();
304        record.last_fetch_status = Some(status);
305        self.persist(&record)
306    }
307
308    fn get_last_successful_fetch_timestamp_millis(&self) -> RemoteConfigResult<Option<u64>> {
309        Ok(self
310            .inner
311            .lock()
312            .unwrap()
313            .last_successful_fetch_timestamp_millis)
314    }
315
316    fn set_last_successful_fetch_timestamp_millis(&self, timestamp: u64) -> RemoteConfigResult<()> {
317        let mut record = self.inner.lock().unwrap();
318        record.last_successful_fetch_timestamp_millis = Some(timestamp);
319        self.persist(&record)
320    }
321
322    fn get_active_config(&self) -> RemoteConfigResult<Option<HashMap<String, String>>> {
323        Ok(self.inner.lock().unwrap().active_config.clone())
324    }
325
326    fn set_active_config(&self, config: HashMap<String, String>) -> RemoteConfigResult<()> {
327        let mut record = self.inner.lock().unwrap();
328        record.active_config = Some(config);
329        self.persist(&record)
330    }
331
332    fn get_active_config_etag(&self) -> RemoteConfigResult<Option<String>> {
333        Ok(self.inner.lock().unwrap().active_config_etag.clone())
334    }
335
336    fn set_active_config_etag(&self, etag: Option<String>) -> RemoteConfigResult<()> {
337        let mut record = self.inner.lock().unwrap();
338        record.active_config_etag = etag;
339        self.persist(&record)
340    }
341
342    fn get_active_config_template_version(&self) -> RemoteConfigResult<Option<u64>> {
343        Ok(self.inner.lock().unwrap().active_config_template_version)
344    }
345
346    fn set_active_config_template_version(
347        &self,
348        template_version: Option<u64>,
349    ) -> RemoteConfigResult<()> {
350        let mut record = self.inner.lock().unwrap();
351        record.active_config_template_version = template_version;
352        self.persist(&record)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::sync::atomic::{AtomicUsize, Ordering};
360
361    #[test]
362    fn cache_roundtrips_metadata() {
363        let storage: Arc<dyn RemoteConfigStorage> =
364            Arc::new(InMemoryRemoteConfigStorage::default());
365        let cache = RemoteConfigStorageCache::new(storage.clone());
366
367        assert_eq!(cache.last_fetch_status(), FetchStatus::NoFetchYet);
368        assert_eq!(cache.last_successful_fetch_timestamp_millis(), None);
369
370        cache.set_last_fetch_status(FetchStatus::Success).unwrap();
371        cache
372            .set_last_successful_fetch_timestamp_millis(1234)
373            .unwrap();
374        cache
375            .set_active_config(HashMap::from([(
376                String::from("feature"),
377                String::from("on"),
378            )]))
379            .unwrap();
380        cache
381            .set_active_config_etag(Some(String::from("etag")))
382            .unwrap();
383        cache.set_active_config_template_version(Some(42)).unwrap();
384
385        assert_eq!(cache.last_fetch_status(), FetchStatus::Success);
386        assert_eq!(cache.last_successful_fetch_timestamp_millis(), Some(1234));
387        let active = cache.active_config();
388        assert_eq!(active.get("feature"), Some(&String::from("on")));
389        assert_eq!(cache.active_config_etag(), Some(String::from("etag")));
390        assert_eq!(cache.active_config_template_version(), Some(42));
391
392        // Creating a new cache on top of the same storage should hydrate state.
393        let cache2 = RemoteConfigStorageCache::new(storage);
394        assert_eq!(cache2.last_fetch_status(), FetchStatus::Success);
395        assert_eq!(cache2.last_successful_fetch_timestamp_millis(), Some(1234));
396        assert_eq!(
397            cache2.active_config().get("feature"),
398            Some(&String::from("on"))
399        );
400        assert_eq!(cache2.active_config_etag(), Some(String::from("etag")));
401        assert_eq!(cache2.active_config_template_version(), Some(42));
402    }
403
404    #[test]
405    fn file_storage_persists_state() {
406        static COUNTER: AtomicUsize = AtomicUsize::new(0);
407        let path = std::env::temp_dir().join(format!(
408            "firebase-remote-config-storage-{}.json",
409            COUNTER.fetch_add(1, Ordering::SeqCst)
410        ));
411
412        let storage = Arc::new(FileRemoteConfigStorage::new(path.clone()).unwrap());
413        let cache = RemoteConfigStorageCache::new(storage.clone());
414
415        cache.set_last_fetch_status(FetchStatus::Success).unwrap();
416        cache
417            .set_last_successful_fetch_timestamp_millis(4321)
418            .unwrap();
419        cache
420            .set_active_config(HashMap::from([(
421                String::from("color"),
422                String::from("blue"),
423            )]))
424            .unwrap();
425        cache
426            .set_active_config_etag(Some(String::from("persist-etag")))
427            .unwrap();
428        cache.set_active_config_template_version(Some(99)).unwrap();
429
430        drop(cache);
431
432        let storage2 = Arc::new(FileRemoteConfigStorage::new(path.clone()).unwrap());
433        let cache2 = RemoteConfigStorageCache::new(storage2);
434        assert_eq!(cache2.last_fetch_status(), FetchStatus::Success);
435        assert_eq!(cache2.last_successful_fetch_timestamp_millis(), Some(4321));
436        assert_eq!(cache2.active_config().get("color"), Some(&"blue".into()));
437        assert_eq!(
438            cache2.active_config_etag(),
439            Some(String::from("persist-etag"))
440        );
441        assert_eq!(cache2.active_config_template_version(), Some(99));
442
443        let _ = fs::remove_file(path);
444    }
445}