1use std::{
6 collections::{HashMap, HashSet},
7 sync::{Arc, Weak},
8};
9
10use camino::{Utf8Path, Utf8PathBuf};
11use error_support::trace;
12use parking_lot::Mutex;
13use serde::Deserialize;
14use url::Url;
15use viaduct::{Client, ClientSettings, Request};
16
17use crate::{
18 client::RemoteState, config::BaseUrl, error::Error, storage::Storage,
19 telemetry::RemoteSettingsTelemetryWrapper, RemoteSettingsClient, RemoteSettingsConfig,
20 RemoteSettingsContext, RemoteSettingsServer, Result,
21};
22
23pub struct RemoteSettingsService {
25 storage_dir: Utf8PathBuf,
26 sync_client: Mutex<SyncClient>,
36 telemetry: Mutex<RemoteSettingsTelemetryWrapper>,
37 client_state: Mutex<ClientState>,
38}
39
40#[derive(Clone)]
41struct RemoteSettingsServiceConfig {
42 base_url: BaseUrl,
43 bucket_name: String,
44 app_context: Option<RemoteSettingsContext>,
45}
46
47struct ClientState {
53 config: RemoteSettingsServiceConfig,
54 clients: Vec<Weak<RemoteSettingsClient>>,
60}
61
62struct SyncClient {
64 client: viaduct::Client,
65 remote_state: RemoteState,
66}
67
68impl RemoteSettingsService {
69 pub fn new(storage_dir: String, config: RemoteSettingsConfig) -> Self {
73 let storage_dir = storage_dir.into();
74 let base_url = config
75 .server
76 .unwrap_or(RemoteSettingsServer::Prod)
77 .get_base_url_with_prod_fallback();
78 let bucket_name = config.bucket_name.unwrap_or_else(|| String::from("main"));
79
80 Self {
81 storage_dir,
82 client_state: Mutex::new(ClientState {
83 clients: vec![],
84 config: RemoteSettingsServiceConfig {
85 base_url,
86 bucket_name,
87 app_context: config.app_context,
88 },
89 }),
90 sync_client: Mutex::new(SyncClient {
91 client: Client::new(ClientSettings::default()),
92 remote_state: RemoteState::default(),
93 }),
94 telemetry: Mutex::new(RemoteSettingsTelemetryWrapper::noop()),
95 }
96 }
97
98 fn telemetry(&self) -> RemoteSettingsTelemetryWrapper {
99 self.telemetry.lock().clone()
100 }
101
102 pub fn set_telemetry(&self, telemetry: RemoteSettingsTelemetryWrapper) {
103 *self.telemetry.lock() = telemetry;
104 }
105
106 pub fn make_client(&self, collection_name: String) -> Arc<RemoteSettingsClient> {
107 self.client_state
108 .lock()
109 .make_client(&self.storage_dir, collection_name)
110 }
111
112 pub fn sync(&self) -> Result<Vec<String>> {
114 let mut synced_collections = HashSet::new();
116
117 let config = self.client_state.lock().config.clone();
118 let telemetry = self.telemetry();
119
120 let changes = self
121 .sync_client
122 .lock()
123 .fetch_changes(config.base_url, &telemetry)?;
124 let change_map: HashMap<_, _> = changes
125 .changes
126 .iter()
127 .map(|c| ((c.collection.as_str(), &c.bucket), c.last_modified))
128 .collect();
129 let bucket_name = &config.bucket_name;
130
131 let active_clients = self.client_state.lock().active_clients();
132 for client in &active_clients {
133 let client = &client.internal;
134 let collection_name = client.collection_name();
135 let cid = format!("{bucket_name}/{collection_name}");
136 if let Some(client_last_modified) = client.get_last_modified_timestamp()? {
137 if let Some(server_last_modified) = change_map.get(&(collection_name, bucket_name))
138 {
139 if client_last_modified == *server_last_modified {
140 trace!("skipping up-to-date collection: {collection_name}");
141 telemetry.report_uptake_up_to_date(&cid, None);
142 continue;
143 }
144 }
145 }
146 if synced_collections.insert(collection_name.to_string()) {
147 trace!("syncing collection: {collection_name}");
148 let start_time = std::time::Instant::now();
149 let sync_result = client.sync();
150 let duration: u64 = start_time.elapsed().as_millis().try_into().unwrap_or(0);
151 match &sync_result {
152 Ok(()) => telemetry.report_uptake_success(&cid, Some(duration)),
153 Err(e) => telemetry.report_uptake_error(e, &cid),
154 }
155 sync_result?;
156 }
157 }
158
159 for client in &active_clients {
162 let client = &client.internal;
163 let collection_name = client.collection_name();
164
165 if synced_collections.contains(collection_name) {
166 trace!("running maintenance for collection: {collection_name}");
167 client.run_maintenance()?;
168 }
169 }
170
171 Ok(synced_collections.into_iter().collect())
172 }
173
174 pub fn update_config(&self, config: RemoteSettingsConfig) -> Result<()> {
175 self.client_state.lock().update_config(config)
176 }
177
178 pub fn client_url(&self) -> Url {
179 self.client_state.lock().config.base_url.url().clone()
180 }
181}
182
183impl ClientState {
184 pub fn make_client(
185 &mut self,
186 storage_dir: &Utf8Path,
187 collection_name: String,
188 ) -> Arc<RemoteSettingsClient> {
189 let storage = if storage_dir == ":memory:" {
191 Storage::new(storage_dir.to_path_buf())
192 } else {
193 Storage::new(storage_dir.join(format!("{collection_name}.sql")))
194 };
195
196 let client = Arc::new(RemoteSettingsClient::new(
197 self.config.base_url.clone(),
198 self.config.bucket_name.clone(),
199 collection_name.clone(),
200 self.config.app_context.clone(),
201 storage,
202 ));
203 self.clients.push(Arc::downgrade(&client));
204 client
205 }
206
207 pub fn update_config(&mut self, config: RemoteSettingsConfig) -> Result<()> {
212 let base_url = config
213 .server
214 .unwrap_or(RemoteSettingsServer::Prod)
215 .get_base_url()?;
216 let bucket_name = config.bucket_name.unwrap_or_else(|| String::from("main"));
217 for client in self.active_clients() {
218 client.internal.update_config(
219 base_url.clone(),
220 bucket_name.clone(),
221 config.app_context.clone(),
222 );
223 }
224 self.config = RemoteSettingsServiceConfig {
225 base_url,
226 bucket_name,
227 app_context: config.app_context,
228 };
229 Ok(())
230 }
231
232 fn active_clients(&mut self) -> Vec<Arc<RemoteSettingsClient>> {
233 let mut active_clients = vec![];
234 self.clients.retain(|weak| {
235 if let Some(client) = weak.upgrade() {
236 active_clients.push(client);
237 true
238 } else {
239 false
240 }
241 });
242 active_clients
243 }
244}
245
246impl RemoteSettingsService {}
252
253impl SyncClient {
254 fn fetch_changes(
255 &mut self,
256 mut url: BaseUrl,
257 telemetry: &RemoteSettingsTelemetryWrapper,
258 ) -> Result<Changes> {
259 url.path_segments_mut()
260 .push("buckets")
261 .push("monitor")
262 .push("collections")
263 .push("changes")
264 .push("changeset");
265 url.query_pairs_mut().append_pair("_expected", "0");
271 let url = url.into_inner();
272 trace!("make_request: {url}");
273 self.remote_state.ensure_no_backoff()?;
274
275 let start_time = std::time::Instant::now();
276 let req = Request::get(url);
277 let resp = self.client.send_sync(req)?;
278
279 self.remote_state.handle_backoff_hint(&resp)?;
280
281 const TELEMETRY_SOURCE_POLL: &str = "settings-changes-monitoring";
282 if resp.is_success() {
283 let body = resp.json()?;
284 let duration: u64 = start_time.elapsed().as_millis().try_into().unwrap_or(0);
285 telemetry.report_uptake_success(TELEMETRY_SOURCE_POLL, Some(duration));
286 Ok(body)
287 } else {
288 let e = Error::response_error(&resp.url, format!("status code: {}", resp.status));
289 telemetry.report_uptake_error(&e, TELEMETRY_SOURCE_POLL);
290 Err(e)
291 }
292 }
293}
294
295#[derive(Debug, Deserialize)]
299struct Changes {
300 changes: Vec<ChangesCollection>,
301}
302
303#[derive(Debug, Deserialize)]
304struct ChangesCollection {
305 collection: String,
306 bucket: String,
307 last_modified: u64,
308}
309
310#[cfg(test)]
311mod test {
312 use super::*;
313 use crate::telemetry::UptakeEventExtras;
314 use crate::{RemoteSettingsConfig, RemoteSettingsServer};
315 use mockito::{mock, Matcher};
316 use std::sync::Arc;
317
318 struct FakeTelemetry {
320 events: std::sync::Mutex<Vec<UptakeEventExtras>>,
321 }
322
323 impl FakeTelemetry {
324 fn new() -> Self {
325 Self {
326 events: std::sync::Mutex::new(Vec::new()),
327 }
328 }
329 }
330
331 impl crate::telemetry::RemoteSettingsTelemetry for FakeTelemetry {
332 fn report_uptake(&self, extras: UptakeEventExtras) {
333 self.events.lock().unwrap().push(extras);
334 }
335 }
336
337 fn make_service(server_url: &str) -> (RemoteSettingsService, Arc<FakeTelemetry>) {
338 let service = RemoteSettingsService::new(
339 ":memory:".into(),
340 RemoteSettingsConfig {
341 server: Some(RemoteSettingsServer::Custom {
342 url: server_url.into(),
343 }),
344 ..Default::default()
345 },
346 );
347 let telemetry: Arc<FakeTelemetry> = Arc::new(FakeTelemetry::new());
348 service.set_telemetry(RemoteSettingsTelemetryWrapper::new(telemetry.clone()));
349 (service, telemetry)
350 }
351
352 fn mock_monitor_changes(collection: &str, timestamp: u64) -> mockito::Mock {
353 mock("GET", "/v2/buckets/monitor/collections/changes/changeset")
354 .match_query(Matcher::Any)
355 .with_status(200)
356 .with_header("content-type", "application/json")
357 .with_body(format!(
358 r#"{{"timestamp": {timestamp}, "changes": [{{"collection": "{collection}", "bucket": "main", "last_modified": {timestamp}}}]}}"#
359 ))
360 .create()
361 }
362
363 fn mock_changeset(collection: &str, timestamp: u64) -> mockito::Mock {
364 mock(
365 "GET",
366 format!("/v2/buckets/main/collections/{collection}/changeset").as_str(),
367 )
368 .match_query(Matcher::Any)
369 .with_status(200)
370 .with_header("content-type", "application/json")
371 .with_body(format!(
372 r#"{{"changes": [], "timestamp": {timestamp}, "metadata": {{"bucket": "main", "signatures": []}}}}"#
373 ))
374 .create()
375 }
376
377 fn mock_changeset_error(bucket: &str, collection: &str) -> mockito::Mock {
378 mock(
379 "GET",
380 format!("/v2/buckets/{bucket}/collections/{collection}/changeset").as_str(),
381 )
382 .match_query(Matcher::Any)
383 .with_status(500)
384 .with_body("server error")
385 .create()
386 }
387
388 #[test]
389 fn test_telemetry_network_error_on_changes_failure() {
390 viaduct_dev::init_backend_dev();
391 mock_changeset_error("monitor", "changes");
392
393 let (service, telemetry) = make_service(&mockito::server_url());
394 let _ = service.sync();
395
396 let events = telemetry.events.lock().unwrap();
397 assert_eq!(events.len(), 1);
398 assert_eq!(
399 events[0].source,
400 Some("settings-changes-monitoring".to_string())
401 );
402 assert_eq!(events[0].value, Some("network_error".to_string()));
403 assert_eq!(events[0].error_name, Some("ResponseError".to_string()));
404 assert!(events[0].error_name.is_some());
405 }
406
407 #[test]
408 fn test_telemetry_on_changes_success() {
409 viaduct_dev::init_backend_dev();
410 let _changes = mock_monitor_changes("cid", 42);
411
412 let (service, telemetry) = make_service(&mockito::server_url());
413 let _ = service.sync();
414
415 let events = telemetry.events.lock().unwrap();
416 assert_eq!(events.len(), 1);
417 assert_eq!(
418 events[0].source,
419 Some("settings-changes-monitoring".to_string())
420 );
421 assert_eq!(events[0].value, Some("success".to_string()));
422 assert!(events[0].duration.is_some());
423 }
424
425 #[cfg(not(feature = "signatures"))]
426 #[test]
427 fn test_telemetry_on_collection_success() {
428 viaduct_dev::init_backend_dev();
429 let collection = "cid";
430 let timestamp = 1774420582054u64;
431 let _changes = mock_monitor_changes(collection, timestamp);
432 let _changeset = mock_changeset(collection, timestamp);
433
434 let (service, telemetry) = make_service(&mockito::server_url());
435 let _client = service.make_client(collection.into());
436 let _ = service.sync();
437
438 let events = telemetry.events.lock().unwrap();
439 assert_eq!(events.len(), 2);
440 assert_eq!(
441 events[0].source,
442 Some("settings-changes-monitoring".to_string())
443 );
444 assert_eq!(events[1].source, Some(format!("main/{collection}")));
445 assert_eq!(events[1].value, Some("success".to_string()));
446 assert!(events[1].duration.is_some());
447 }
448
449 #[cfg(not(feature = "signatures"))]
450 #[test]
451 fn test_telemetry_on_collection_up_to_date() {
452 viaduct_dev::init_backend_dev();
453 let collection = "cid";
454 let timestamp = 1774420582054u64;
455 let _changes = mock_monitor_changes(collection, timestamp);
456 let _changeset = mock_changeset(collection, timestamp);
457
458 let (service, telemetry) = make_service(&mockito::server_url());
459 let _client = service.make_client(collection.into());
460
461 let _ = service.sync();
463 let events_before = telemetry.events.lock().unwrap().len();
464 let _ = service.sync();
466
467 let events = telemetry.events.lock().unwrap();
468 assert_eq!(events.len() - events_before, 2);
469 assert_eq!(
470 events[events_before].source,
471 Some("settings-changes-monitoring".to_string())
472 );
473 assert_eq!(
474 events[events_before + 1].source,
475 Some(format!("main/{collection}"))
476 );
477 assert_eq!(
478 events[events_before + 1].value,
479 Some("up_to_date".to_string())
480 );
481 }
482
483 #[test]
484 fn test_telemetry_on_collection_error() {
485 viaduct_dev::init_backend_dev();
486 let collection = "cid";
487 let timestamp = 1774420582054u64;
488 let _changes = mock_monitor_changes(collection, timestamp);
489 let _changeset = mock_changeset_error("main", collection);
490
491 let (service, telemetry) = make_service(&mockito::server_url());
492 let _client = service.make_client(collection.into());
493 let _ = service.sync();
494
495 let events = telemetry.events.lock().unwrap();
496 assert_eq!(events.len(), 2);
497 assert_eq!(
498 events[0].source,
499 Some("settings-changes-monitoring".to_string())
500 );
501 assert_eq!(events[0].value, Some("success".to_string()));
502 assert_eq!(events[1].source, Some(format!("main/{collection}")));
503 assert_eq!(events[1].value, Some("network_error".to_string()));
504 assert_eq!(events[1].error_name, Some("ResponseError".to_string()));
505 }
506
507 #[cfg(feature = "signatures")]
508 #[test]
509 fn test_telemetry_on_collection_signature_error() {
510 viaduct_dev::init_backend_dev();
511 let collection = "cid";
512 let timestamp = 1774420582054u64;
513 let _changes = mock_monitor_changes(collection, timestamp);
514 let _changeset = mock_changeset(collection, timestamp);
515
516 let (service, telemetry) = make_service(&mockito::server_url());
517 let _client = service.make_client(collection.into());
518 let _ = service.sync();
519
520 let events = telemetry.events.lock().unwrap();
521 assert_eq!(events.len(), 2);
522 assert_eq!(
523 events[0].source,
524 Some("settings-changes-monitoring".to_string())
525 );
526 assert_eq!(events[1].source, Some(format!("main/{collection}")));
527 assert_eq!(events[1].value, Some("signature_error".to_string()));
528 assert_eq!(
529 events[1].error_name,
530 Some("IncompleteSignatureDataError".to_string())
531 );
532 }
533
534 #[cfg(not(feature = "signatures"))]
535 #[test]
536 fn test_sync_maintenance_shrinks_db_after_attachment_cleanup() -> Result<()> {
537 use crate::RemoteSettingsRecord;
538 use sha2::Digest;
539 viaduct_dev::init_backend_dev();
540
541 let collection = "cid";
542 let temp_dir = tempfile::tempdir().expect("create temp dir");
543 let db_path = temp_dir.path().join(format!("{collection}.sql"));
544
545 let attachment_data = vec![0x41; 5 * 1024 * 1024];
546 let attachment_hash = format!("{:x}", sha2::Sha256::digest(&attachment_data));
547
548 let attachment_record = format!(
549 r#"{{
550 "id": "record-with-attachment",
551 "last_modified": 100,
552 "attachment": {{
553 "filename": "big.bin",
554 "mimetype": "application/octet-stream",
555 "location": "attachments/big.bin",
556 "hash": "{attachment_hash}",
557 "size": {}
558 }}
559 }}"#,
560 attachment_data.len()
561 );
562
563 let _changes_1 = mock("GET", "/v2/buckets/monitor/collections/changes/changeset")
565 .match_query(Matcher::Any)
566 .with_status(200)
567 .with_header("content-type", "application/json")
568 .with_body(format!(
569 r#"{{
570 "timestamp": 100,
571 "changes": [
572 {{"collection": "{collection}", "bucket": "main", "last_modified": 100}}
573 ]
574 }}"#
575 ))
576 .create();
577
578 let _changeset_1 = mock(
579 "GET",
580 format!("/v2/buckets/main/collections/{collection}/changeset").as_str(),
581 )
582 .match_query(Matcher::Any)
583 .with_status(200)
584 .with_header("content-type", "application/json")
585 .with_body(format!(
586 r#"{{
587 "changes": [{attachment_record}],
588 "timestamp": 100,
589 "metadata": {{"bucket": "main", "signatures": []}}
590 }}"#
591 ))
592 .create();
593
594 let service = RemoteSettingsService::new(
595 temp_dir.path().to_string_lossy().to_string(),
596 RemoteSettingsConfig {
597 server: Some(RemoteSettingsServer::Custom {
598 url: mockito::server_url(),
599 }),
600 ..Default::default()
601 },
602 );
603
604 let client = service.make_client(collection.into());
605
606 service.sync()?;
607
608 let _root = mock("GET", "/v2/")
610 .with_status(200)
611 .with_header("content-type", "application/json")
612 .with_body(format!(
613 r#"{{
614 "capabilities": {{
615 "attachments": {{
616 "base_url": "{}/"
617 }}
618 }}
619 }}"#,
620 mockito::server_url()
621 ))
622 .create();
623
624 let _attachment = mock("GET", "/attachments/big")
626 .with_status(200)
627 .with_body(attachment_data.clone())
628 .create();
629
630 client.internal.get_attachment(&RemoteSettingsRecord {
632 id: "record-with-attachment".to_string(),
633 last_modified: 100,
634 deleted: false,
635 attachment: Some(crate::Attachment {
636 filename: "big".to_string(),
637 mimetype: "application/octet-stream".to_string(),
638 location: "attachments/big".to_string(),
639 hash: attachment_hash.clone(),
640 size: attachment_data.len() as u64,
641 }),
642 fields: serde_json::Map::new(),
643 })?;
644
645 let size_with_attachment = std::fs::metadata(&db_path)
646 .expect("db exists after first sync")
647 .len();
648
649 assert!(
650 size_with_attachment > 4 * 1024 * 1024,
651 "DB should contain the large attachment; size={size_with_attachment}"
652 );
653
654 drop(_changes_1);
658 drop(_changeset_1);
659
660 let _changes_2 = mock("GET", "/v2/buckets/monitor/collections/changes/changeset")
663 .match_query(Matcher::Any)
664 .with_status(200)
665 .with_header("content-type", "application/json")
666 .with_body(format!(
667 r#"{{
668 "timestamp": 200,
669 "changes": [
670 {{"collection": "{collection}", "bucket": "main", "last_modified": 200}}
671 ]
672 }}"#
673 ))
674 .create();
675
676 let _changeset_2 = mock(
677 "GET",
678 format!("/v2/buckets/main/collections/{collection}/changeset").as_str(),
679 )
680 .match_query(Matcher::Any)
681 .with_status(200)
682 .with_header("content-type", "application/json")
683 .with_body(
684 r#"{
685 "changes": [
686 {
687 "id": "record-with-attachment",
688 "last_modified": 200,
689 "deleted": true
690 }
691 ],
692 "timestamp": 200,
693 "metadata": {"bucket": "main", "signatures": []}
694 }"#,
695 )
696 .create();
697
698 service.sync()?;
699
700 let size_after_cleanup_and_maintenance = std::fs::metadata(&db_path)
701 .expect("db exists after second sync")
702 .len();
703
704 assert!(
705 size_after_cleanup_and_maintenance < size_with_attachment,
706 "maintenance should reclaim at least some space after deleting attachment; before={size_with_attachment}, after={size_after_cleanup_and_maintenance}"
707 );
708
709 let conn = rusqlite::Connection::open(&db_path).expect("open collection db");
711 let auto_vacuum: u32 = conn
712 .query_row("PRAGMA auto_vacuum", [], |row| row.get(0))
713 .expect("query auto_vacuum");
714
715 assert_eq!(auto_vacuum, 2);
716
717 Ok(())
718 }
719}