remote_settings/
service.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 http://mozilla.org/MPL/2.0/. */
4
5use 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
23/// Internal Remote settings service API
24pub struct RemoteSettingsService {
25    storage_dir: Utf8PathBuf,
26    // RemoteSettingsService has several mutex fields in order to get finer-grained locking.
27    // However, this means we need to use some care to avoid holding locks for too long
28    // and creating potential deadlocks.
29    //
30    // To avoid this: put functionality in inner type methods, like [ClientState::update_config].
31    // Inside `RemoteSettingsService` methods, we only lock the field temporarily
32    // to call those inner methods or access the fields.
33    // Don't hold the lock for longer than that single statement
34    // and don't lock more than one field in that statement.
35    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
47/// Current config and client list
48///
49/// These are stored in the same mutex because we want to update them at the same time.
50/// For example, we want to serialize calls to `update_config` and `make_client` so that the new
51/// client gets the updated config.
52struct ClientState {
53    config: RemoteSettingsServiceConfig,
54    /// Weakrefs for all clients that we've created.  Note: this stores the
55    /// top-level/public `RemoteSettingsClient` structs rather than `client::RemoteSettingsClient`.
56    /// The reason for this is that we return Arcs to the public struct to the foreign code, so we
57    /// need to use the same type for our weakrefs.  The alternative would be to create 2 Arcs for
58    /// each client, which is wasteful.
59    clients: Vec<Weak<RemoteSettingsClient>>,
60}
61
62/// Handles the `RemoteSettingsService::sync` method
63struct SyncClient {
64    client: viaduct::Client,
65    remote_state: RemoteState,
66}
67
68impl RemoteSettingsService {
69    /// Construct a [RemoteSettingsService]
70    ///
71    /// This is typically done early in the application-startup process
72    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    /// Sync collections for all active clients
113    pub fn sync(&self) -> Result<Vec<String>> {
114        // Make sure we only sync each collection once, even if there are multiple clients
115        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        // Run SQLite maintenance after sync so SQLite can reclaim pages freed by
160        // attachment cleanup and enable/use incremental auto-vacuum.
161        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        // Allow using in-memory databases for testing of external crates.
190        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    /// Update the remote settings config
208    ///
209    /// This will cause all current and future clients to use new config and will delete any stored
210    /// records causing the clients to return new results from the new config.
211    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
246// RemoteSettingsService methods that lock the `telemetry` field.
247//
248// Let's keep all the calls in one place so that we can ensure that the lock will not be held for a
249// long time and these methods can be considered non-blocking.  For example, we will never hold the
250// lock while making a network request.
251impl 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        // For now, always use `0` for the expected value.  This means we'll get updates based on
266        // the default TTL of 1 hour.
267        //
268        // Eventually, we should add support for push notifications and use the timestamp from the
269        // notification.
270        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/// Data from the changes endpoint
296///
297/// https://remote-settings.readthedocs.io/en/latest/client-specifications.html#endpoints
298#[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    /// Telemetry implementation that records all events for later assertion.
319    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        // First sync: populates local storage with timestamp.
462        let _ = service.sync();
463        let events_before = telemetry.events.lock().unwrap().len();
464        // Second sync.
465        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        // First sync creates a record that references the big attachment.
564        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        // Mock attachment discovery and download.
609        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        // Path matches `location: "attachments/big"` joined against the base URL above.
625        let _attachment = mock("GET", "/attachments/big")
626            .with_status(200)
627            .with_body(attachment_data.clone())
628            .create();
629
630        // Store the large attachment so the DB becomes bloated.
631        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 first-sync mocks explicitly so mockito doesn't re-match the second sync's
655        // changeset request against them. Mockito matches by registration order, so leftover
656        // mocks for the same URL would shadow the second-sync mocks.
657        drop(_changes_1);
658        drop(_changeset_1);
659
660        // Second sync tombstones the record. This deletes the attachment row, and
661        // post-sync maintenance should compact the database.
662        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        // Sanity-check that maintenance enabled incremental auto-vacuum.
710        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}