diff --git a/components/ads-client/integration-tests/tests/mars_async.rs b/components/ads-client/integration-tests/tests/mars_async.rs new file mode 100644 index 00000000000..4e58954dad5 --- /dev/null +++ b/components/ads-client/integration-tests/tests/mars_async.rs @@ -0,0 +1,428 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use ads_client::MozAdsIABContent; +use ads_client::MozAdsIABContentTaxonomy; +use ads_client::MozAdsPlacementRequestWithCount; +use ads_client::{ + MozAdsClient, MozAdsClientBuilder, MozAdsEnvironment, MozAdsPlacementRequest, + MozAdsReportReason, MozAdsRequestOptions, MozAdsTile, +}; +use std::sync::Arc; + +pub const TEST_TIMEOUT_DURATION: std::time::Duration = std::time::Duration::from_secs(10); + +fn init_backend() { + viaduct_hyper::viaduct_init_backend_hyper(); +} + +fn prod_client() -> ads_client::MozAdsClient { + Arc::new(MozAdsClientBuilder::new()) + .environment(MozAdsEnvironment::Prod) + .build() +} + +// Reusable helper to prefetches a tile ad, wait for completion, and query it. +// Should mimic the `test_contract_tile_prod_async` test. +fn generate_tile_ad_async_helper(client: &MozAdsClient) -> MozAdsTile { + // Prefetch + let placement_id = "mock_tile_1".to_string(); + let result = client.prefetch_ads( + vec![], + vec![], + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_id.clone(), + }], + None, + ); + + assert!( + result.is_ok(), + "Tile ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_tile_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + result + .unwrap() + .expect("`query_tile_ads` in `generate_tile_ad_sync` should return Some") +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_image_prod_async() { + init_backend(); + + // Prefetch + let placement_id = "mock_billboard_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_id.clone(), + }], + vec![], + vec![], + None, + ); + + assert!( + result.is_ok(), + "Image ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_image_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + + assert!(placements.is_some()); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_image_with_categories_prod_async() { + init_backend(); + + // Prefetch + let placement_id = "mock_billboard_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![MozAdsPlacementRequest { + iab_content: Some(MozAdsIABContent { + category_ids: vec!["338".to_string()], + taxonomy: MozAdsIABContentTaxonomy::IAB3_0, + }), + placement_id: placement_id.clone(), + }], + vec![], + vec![], + Some(MozAdsRequestOptions { + flags: std::collections::HashMap::from([("contextual_placement".to_string(), true)]), + ..Default::default() + }), + ); + + assert!( + result.is_ok(), + "Image ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_image_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + + let placements = result.unwrap(); + assert!(placements.is_some()); + let ad = placements.unwrap(); + assert!(!ad.url.is_empty(), "destination url should be populated"); + assert!(!ad.image_url.is_empty(), "image url should be populated"); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_spoc_prod_async() { + init_backend(); + + // Prefetch + let placement_id = "mock_spoc_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![], + vec![MozAdsPlacementRequestWithCount { + count: 3, + iab_content: None, + placement_id: placement_id.clone(), + }], + vec![], + None, + ); + + assert!( + result.is_ok(), + "Spoc ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_spoc_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + assert!(placements.is_some()); + assert!(placements.unwrap().len() == 3); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_tile_prod_async() { + init_backend(); + + // Prefetch + let placement_id = "mock_tile_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![], + vec![], + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_id.clone(), + }], + None, + ); + + assert!( + result.is_ok(), + "Tile ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_tile_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + + assert!(placements.is_some()); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_record_impression_async() { + init_backend(); + + let client = prod_client(); + let ad = generate_tile_ad_async_helper(&client); + + // Dispatch record_impression asynchronously + let result = client.dispatch_record_impression(ad.callbacks.impression.to_string(), None); + assert!( + result.is_ok(), + "record_impression failed: {:?}", + result.err() + ); + + // Ping (waits for queue to clear) + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + // TODO: This doesn't actually guarantee the background worker call was successful, doing so requires a callback. +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_record_click_async() { + init_backend(); + let client = prod_client(); + let ad = generate_tile_ad_async_helper(&client); + + // Dispatch record_click asynchronously + let result = client.dispatch_record_click(ad.callbacks.click.to_string(), None); + assert!(result.is_ok(), "record_click failed: {:?}", result.err()); + + // Ping (waits for queue to clear) + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + // TODO: This doesn't actually guarantee the background worker call was successful, doing so requires a callback. +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_report_ad_async() { + init_backend(); + + let client = prod_client(); + let ad = generate_tile_ad_async_helper(&client); + + let report_url = ad + .callbacks + .report + .as_ref() + .expect("mock_tile_1 should have a report URL"); + + let pairs: Vec<(_, _)> = report_url.query_pairs().collect(); + let placement_id_count = pairs.iter().filter(|(k, _)| k == "placement_id").count(); + let position_count = pairs.iter().filter(|(k, _)| k == "position").count(); + assert_eq!(placement_id_count, 1, "expected exactly one placement_id"); + assert_eq!(position_count, 1, "expected exactly one position"); + + // Dispatch report_ad asynchronously + let result = client.dispatch_report_ad( + report_url.to_string(), + MozAdsReportReason::NotInterested, + None, + ); + assert!(result.is_ok(), "report_ad failed: {:?}", result.err()); + + // Ping (waits for queue to clear) + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // TODO: This doesn't actually guarantee the background call was successful, doing so requires a callback. +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_tile_ohttp_prod_async() { + init_backend(); + viaduct::ohttp::configure_ohttp_channel( + "ads-client".to_string(), + viaduct::ohttp::OhttpConfig { + relay_url: "https://mozilla-ohttp.fastly-edge.com/".to_string(), + gateway_host: "prod.ohttp-gateway.prod.webservices.mozgcp.net".to_string(), + }, + ) + .expect("OHTTP channel configuration should succeed"); + + // Prefetch + let placement_id = "mock_tile_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![], + vec![], + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_id.clone(), + }], + Some(MozAdsRequestOptions { + ohttp: true, + ..Default::default() + }), + ); + + assert!( + result.is_ok(), + "Tile ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_tile_ads(placement_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + + assert!( + placements.is_some(), + "OHTTP response should contain mock_tile_1" + ); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_multi_ad_type_prod_async() { + init_backend(); + + // Prefetch + let placement_image_id = "mock_billboard_1".to_string(); + let placement_spoc_id = "mock_spoc_1".to_string(); + let placement_tile_id = "mock_tile_1".to_string(); + let client = prod_client(); + let result = client.prefetch_ads( + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_image_id.clone(), + }], + vec![MozAdsPlacementRequestWithCount { + count: 4, + iab_content: None, + placement_id: placement_spoc_id.clone(), + }], + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: placement_tile_id.clone(), + }], + None, + ); + + assert!( + result.is_ok(), + "Image ad dispatch request failed: {:?}", + result.err() + ); + + // Ping + let ping = client.ping_background_worker(Some(TEST_TIMEOUT_DURATION)); + assert!(ping.is_ok(), "Ping failed: {:?}", ping.err()); + + // Query + let result = client.query_image_ads(placement_image_id); + assert!( + result.is_ok(), + "Querying for image ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + assert!(placements.is_some()); + + let result = client.query_spoc_ads(placement_spoc_id); + assert!( + result.is_ok(), + "Querying for spoc ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + assert!(placements.is_some()); + assert!(placements.unwrap().len() == 4); + + let result = client.query_tile_ads(placement_tile_id); + assert!( + result.is_ok(), + "Querying for ads failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + + assert!(placements.is_some()); +} diff --git a/components/ads-client/src/ads_cache.rs b/components/ads-client/src/ads_cache.rs new file mode 100644 index 00000000000..9b5120b8c2a --- /dev/null +++ b/components/ads-client/src/ads_cache.rs @@ -0,0 +1,99 @@ +use crate::mars::ad_response::{AdImage, AdSpoc, AdTile}; +use std::{collections::HashMap, time::Duration}; + +// TODO: This is an intentionally naive in-memory cache implementation of the ads cache. +// It functions as a skeleton to store ads fetched in the background, and has a naive expiration mechanism. +// The subsequent vertical slice will replace this in its entirety with the http_cache sqlite database instead, with TTLs, persistent storage, etc. +const DEFAULT_TTL: Duration = Duration::from_secs(300); + +#[derive(Debug)] +pub struct AdsCache { + image_ads: HashMap, + spoc_ads: HashMap)>, + tile_ads: HashMap, +} + +impl Default for AdsCache { + fn default() -> Self { + Self::new() + } +} + +impl AdsCache { + pub fn new() -> Self { + AdsCache { + image_ads: HashMap::new(), + spoc_ads: HashMap::new(), + tile_ads: HashMap::new(), + } + } + + pub fn cache_ads( + &mut self, + ads: HashMap, + timestamp: u64, + ) { + T::cache_ads(ads, self, timestamp); + } + + pub fn get_cached_ads<'a, T: AdsCacheable>( + &'a self, + placement: &str, + ) -> Option<&'a T::StorageType> { + T::fetch_cached_ads(self, placement) + } +} + +pub trait AdsCacheable: Sized { + // The cached ad(s) to store (eg: this may be a single ad, or an array of ads) + type StorageType; + + fn cache_ads(ads: HashMap, ads_cache: &mut AdsCache, timestamp: u64); + fn fetch_cached_ads<'a>(ads_cache: &'a AdsCache, id: &str) -> Option<&'a Self::StorageType>; +} + +impl AdsCacheable for AdImage { + type StorageType = AdImage; + fn cache_ads(ads: HashMap, ads_cache: &mut AdsCache, timestamp: u64) { + ads_cache + .image_ads + .extend(ads.into_iter().map(|(key, ad)| (key, (timestamp, ad)))); + ads_cache + .image_ads + .retain(|_, (x, _)| *x - timestamp < DEFAULT_TTL.as_secs()); + } + + fn fetch_cached_ads<'a>(ads_cache: &'a AdsCache, id: &str) -> Option<&'a AdImage> { + ads_cache.image_ads.get(id).map(|(_, ads)| ads) + } +} + +impl AdsCacheable for AdSpoc { + type StorageType = Vec; + fn cache_ads(ads: HashMap>, ads_cache: &mut AdsCache, timestamp: u64) { + ads_cache + .spoc_ads + .extend(ads.into_iter().map(|(key, ad)| (key, (timestamp, ad)))); + ads_cache + .spoc_ads + .retain(|_, (x, _)| *x - timestamp < DEFAULT_TTL.as_secs()); + } + fn fetch_cached_ads<'a>(ads_cache: &'a AdsCache, id: &str) -> Option<&'a Vec> { + ads_cache.spoc_ads.get(id).map(|(_, ads)| ads) + } +} + +impl AdsCacheable for AdTile { + type StorageType = AdTile; + fn cache_ads(ads: HashMap, ads_cache: &mut AdsCache, timestamp: u64) { + ads_cache + .tile_ads + .extend(ads.into_iter().map(|(key, ad)| (key, (timestamp, ad)))); + ads_cache + .tile_ads + .retain(|_, (x, _)| *x - timestamp < DEFAULT_TTL.as_secs()); + } + fn fetch_cached_ads<'a>(ads_cache: &'a AdsCache, id: &str) -> Option<&'a AdTile> { + ads_cache.tile_ads.get(id).map(|(_, ads)| ads) + } +} diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 6f510c55617..901c32fd954 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -3,9 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use std::collections::HashMap; -use std::time::Duration; - +use crate::ads_cache::{AdsCache, AdsCacheable}; use crate::http_cache::{ByteSize, CachePolicy, HttpCache}; use crate::mars::ad_request::{AdPlacementRequest, AdRequestFlags}; use crate::mars::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile}; @@ -15,6 +13,8 @@ use crate::telemetry::Telemetry; use config::AdsClientConfig; use context_id::{ContextIDComponent, DefaultContextIdCallback}; use error::RequestAdsError; +use std::collections::HashMap; +use std::time::Duration; use url::Url; use uuid::Uuid; @@ -42,6 +42,7 @@ where client: MARSClient, context_id_provider: Box, telemetry: T, + ads_cache: AdsCache, } impl AdsClient @@ -91,6 +92,7 @@ where client, context_id_provider, telemetry: telemetry.clone(), + ads_cache: AdsCache::new(), } } @@ -110,6 +112,15 @@ where Ok(()) } + pub fn cache_ads(&mut self, ads: HashMap) { + let now = chrono::Utc::now().timestamp().unsigned_abs(); + self.ads_cache.cache_ads::(ads, now); + } + + pub fn get_cached_ads(&self, placement_id: &str) -> Option<&A::StorageType> { + self.ads_cache.get_cached_ads::(placement_id) + } + pub fn get_context_id(&self) -> context_id::ApiResult { self.context_id_provider.context_id() } @@ -264,6 +275,7 @@ where } } +// Event fires in both sync and background strategies. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ClientOperationEvent { New, @@ -273,6 +285,35 @@ pub enum ClientOperationEvent { RequestAds, } +// Event fires when dispatch is fired, not when the event resolves. +pub enum CommandDispatchedOperationEvent { + RecordClick, + RecordImpression, + ReportAd, + RequestAds, +} + +// Event fires when the corresponding background event resolves. +pub enum CommandProcessedOperationEvent { + RecordClick, + RecordImpression, + ReportAd, + RequestAds, +} + +// Event fires when the corresponding background event fails to resolve. +pub enum CommandFailedOperationEvent { + RecordClick, + RecordImpression, + ReportAd, + RequestAds, +} + +pub enum WorkerMetaEvent { + Start, + Stop, +} + #[cfg(test)] mod tests { use std::{assert_eq, assert_ne, sync::Arc}; @@ -301,6 +342,7 @@ mod tests { Box::new(DefaultContextIdCallback), )), telemetry, + ads_cache: AdsCache::new(), } } diff --git a/components/ads-client/src/client/error.rs b/components/ads-client/src/client/error.rs index 2542939493f..480a1c0bc73 100644 --- a/components/ads-client/src/client/error.rs +++ b/components/ads-client/src/client/error.rs @@ -3,7 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use crate::mars::error::{FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError}; +use crate::{ + mars::error::{FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError}, + worker::command, +}; +use std::sync::mpsc::{RecvTimeoutError, TrySendError}; #[derive(Debug, thiserror::Error)] pub enum ComponentError { @@ -18,6 +22,9 @@ pub enum ComponentError { #[error("Error requesting ads: {0}")] RequestAds(#[from] RequestAdsError), + + #[error("Error requesting ads from worker: {0}")] + BackgroundWorker(#[from] BackgroundWorkerError), } #[derive(Debug, thiserror::Error)] @@ -28,3 +35,28 @@ pub enum RequestAdsError { #[error("Error requesting ads from MARS: {0}")] FetchAds(#[from] FetchAdsError), } + +#[derive(Debug, thiserror::Error)] +pub enum BackgroundWorkerError { + #[error("Error requesting new ads from the background worker: worker full")] + WorkerFull, + + #[error("Error requesting new ads from the background worker: worker closed")] + WorkerClosed, + + #[error("Worker timed out waiting for response: {0}")] + WorkerTimedOut(#[from] RecvTimeoutError), + + #[error("Error sending pong back from background worker")] + PongFailure(Box>), +} + +impl From> for BackgroundWorkerError { + // TODO: For future vertical slice (for retries), we may want to keep the failed dispatch for retrying + fn from(value: TrySendError) -> Self { + match value { + TrySendError::Disconnected(_) => BackgroundWorkerError::WorkerClosed, + TrySendError::Full(_) => BackgroundWorkerError::WorkerFull, + } + } +} diff --git a/components/ads-client/src/ffi.rs b/components/ads-client/src/ffi.rs index ba50352fdc1..e4c7e8de79b 100644 --- a/components/ads-client/src/ffi.rs +++ b/components/ads-client/src/ffi.rs @@ -6,8 +6,6 @@ pub mod error; pub mod telemetry; -use std::sync::Arc; - use crate::client::config::{AdsCacheConfig, AdsClientConfig}; use crate::client::{AdsClient, ContextIdProvider}; use crate::ffi::telemetry::MozAdsTelemetryWrapper; @@ -20,14 +18,14 @@ use crate::mars::ad_response::{ }; use crate::mars::Environment; use crate::mars::ReportReason; -use crate::AdsClientUrl; use crate::MozAdsClient; +use crate::{worker, AdsClientUrl}; use parking_lot::Mutex; use std::collections::HashMap; +use std::sync::Arc; pub use error::{AdsClientApiResult, MozAdsClientApiError}; pub use telemetry::MozAdsTelemetry; - // TODO: Temporary workaround for HNT requirements — do not use for new integrations. // Context ID management should remain internal to the ads client and this interface should be removed. #[uniffi::export(with_foreign)] @@ -55,7 +53,7 @@ impl From for Box { } } -#[derive(Default, uniffi::Record)] +#[derive(Default, uniffi::Record, Clone)] pub struct MozAdsRequestOptions { pub cache_policy: Option, #[uniffi(default)] @@ -124,6 +122,11 @@ impl MozAdsClientBuilder { pub fn build(&self) -> MozAdsClient { let inner = self.0.lock(); + let telemetry = inner + .telemetry + .clone() + .map(MozAdsTelemetryWrapper::new) + .unwrap_or_else(MozAdsTelemetryWrapper::noop); let client_config = AdsClientConfig { cache_config: inner.cache_config.clone().map(Into::into), context_id_provider: inner @@ -132,16 +135,12 @@ impl MozAdsClientBuilder { .map(MozAdsContextIdProviderWrapper::new) .map(Into::into), environment: inner.environment.unwrap_or_default().into(), - telemetry: inner - .telemetry - .clone() - .map(MozAdsTelemetryWrapper::new) - .unwrap_or_else(MozAdsTelemetryWrapper::noop), + telemetry: telemetry.clone(), }; let client = AdsClient::new(client_config); - MozAdsClient { - inner: Mutex::new(client), - } + let inner = Arc::new(Mutex::new(client)); + let worker = worker::AdsClientWorkerWrapper::new(inner.clone(), telemetry); + MozAdsClient { inner, worker } } pub fn cache_config(self: Arc, cache_config: MozAdsCacheConfig) -> Arc { diff --git a/components/ads-client/src/ffi/telemetry.rs b/components/ads-client/src/ffi/telemetry.rs index 02a6fee2e46..cbefd154622 100644 --- a/components/ads-client/src/ffi/telemetry.rs +++ b/components/ads-client/src/ffi/telemetry.rs @@ -9,7 +9,10 @@ use std::sync::Arc; use parking_lot::RwLock; use crate::client::error::RequestAdsError; -use crate::client::ClientOperationEvent; +use crate::client::{ + ClientOperationEvent, CommandDispatchedOperationEvent, CommandFailedOperationEvent, + CommandProcessedOperationEvent, WorkerMetaEvent, +}; use crate::http_cache::{CacheOutcome, HttpCacheBuilderError}; use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError}; use crate::telemetry::Telemetry; @@ -101,6 +104,55 @@ impl Telemetry for MozAdsTelemetryWrapper { }); return; } + + if let Some(client_op) = event.downcast_ref::() { + inner.record_client_operation_total(match client_op { + CommandDispatchedOperationEvent::RecordClick => { + "cmd_dispatch_record_click".to_string() + } + CommandDispatchedOperationEvent::RecordImpression => { + "cmd_dispatch_record_impression".to_string() + } + CommandDispatchedOperationEvent::ReportAd => "cmd_dispatch_report_ad".to_string(), + CommandDispatchedOperationEvent::RequestAds => "cmd_dispatch_report_ad".to_string(), + }); + return; + } + + if let Some(client_op) = event.downcast_ref::() { + inner.record_client_operation_total(match client_op { + CommandProcessedOperationEvent::RecordClick => { + "cmd_processed_record_click".to_string() + } + CommandProcessedOperationEvent::RecordImpression => { + "cmd_processed_record_impression".to_string() + } + CommandProcessedOperationEvent::ReportAd => "cmd_processed_report_ad".to_string(), + CommandProcessedOperationEvent::RequestAds => "cmd_processed_report_ad".to_string(), + }); + return; + } + + if let Some(client_op) = event.downcast_ref::() { + inner.record_client_operation_total(match client_op { + CommandFailedOperationEvent::RecordClick => "cmd_failed_record_click".to_string(), + CommandFailedOperationEvent::RecordImpression => { + "cmd_failed_record_impression".to_string() + } + CommandFailedOperationEvent::ReportAd => "cmd_failed_report_ad".to_string(), + CommandFailedOperationEvent::RequestAds => "cmd_failed_report_ad".to_string(), + }); + return; + } + + if let Some(client_op) = event.downcast_ref::() { + inner.record_client_operation_total(match client_op { + WorkerMetaEvent::Start => "worker_started".to_string(), + WorkerMetaEvent::Stop => "worker_ended".to_string(), + }); + return; + } + if let Some(cache_builder_error) = event.downcast_ref::() { inner.record_build_cache_error( match cache_builder_error { diff --git a/components/ads-client/src/lib.rs b/components/ads-client/src/lib.rs index 87cecb2a5f8..e35688a93ea 100644 --- a/components/ads-client/src/lib.rs +++ b/components/ads-client/src/lib.rs @@ -3,7 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use std::collections::HashMap; +use std::{ + collections::HashMap, + sync::{mpsc, Arc}, + time::Duration, +}; use client::error::ComponentError; use error_support::handle_error; @@ -15,15 +19,22 @@ use client::AdsClient; use error_support::error; use http_cache::CachePolicy; use mars::ad_request::{AdPlacementRequest, AdRequestFlags}; +pub mod ads_cache; mod client; mod ffi; pub mod http_cache; mod mars; pub mod telemetry; +pub mod worker; pub use ffi::*; -use crate::ffi::telemetry::MozAdsTelemetryWrapper; +use crate::{ + client::error::BackgroundWorkerError, + ffi::telemetry::MozAdsTelemetryWrapper, + mars::ad_response::{AdImage, AdSpoc, AdTile}, + worker::{command::DispatchCommand, AdsClientWorkerWrapper}, +}; #[cfg(test)] mod test_utils; @@ -38,9 +49,12 @@ uniffi::custom_type!(AdsClientUrl, String, { #[derive(uniffi::Object)] pub struct MozAdsClient { - inner: Mutex>, + inner: MozAdsClientInner, + worker: AdsClientWorkerWrapper, } +pub type MozAdsClientInner = Arc>>; + #[uniffi::export] impl MozAdsClient { pub fn clear_cache(&self) -> AdsClientApiResult<()> { @@ -173,4 +187,143 @@ impl MozAdsClient { .map_err(ComponentError::RequestAds)?; Ok(response.into_iter().map(|(k, v)| (k, v.into())).collect()) } + + #[handle_error(ComponentError)] + #[uniffi::method(default(image_ad_requests = [], spoc_ad_requests = [], tile_ad_requests = [], options = None))] + pub fn prefetch_ads( + &self, + image_ad_requests: Vec, + spoc_ad_requests: Vec, + tile_ad_requests: Vec, + options: Option, + ) -> AdsClientApiResult<()> { + let options = options.unwrap_or_default(); + let flags = AdRequestFlags::from(&options); + let ohttp = options.ohttp; + let cache_policy: CachePolicy = options.into(); + + // Dispatch image requests + if !image_ad_requests.is_empty() { + self.worker.dispatch(DispatchCommand::RequestImageAds { + image_ad_requests, + ohttp, + cache_policy, + flags: flags.clone(), + })?; + } + // Dispatch spoc requests + if !spoc_ad_requests.is_empty() { + self.worker.dispatch(DispatchCommand::RequestSpocAds { + spoc_ad_requests, + ohttp, + cache_policy, + flags: flags.clone(), + })?; + } + + // Dispatch tiles requests + if !tile_ad_requests.is_empty() { + self.worker.dispatch(DispatchCommand::RequestTileAds { + tile_ad_requests, + ohttp, + cache_policy, + flags: flags.clone(), + })?; + } + + Ok(()) + } + + #[handle_error(ComponentError)] + #[uniffi::method()] + pub fn query_image_ads(&self, placement_id: String) -> AdsClientApiResult> { + let inner = self.inner.lock(); + let image_ads: Option<&AdImage> = inner.get_cached_ads::(&placement_id); + Ok(image_ads.map(|ad| ad.clone().into())) + } + + #[handle_error(ComponentError)] + #[uniffi::method()] + pub fn query_spoc_ads( + &self, + placement_id: String, + ) -> AdsClientApiResult>> { + let inner = self.inner.lock(); + let spoc_ads: Option<&Vec> = inner.get_cached_ads::(&placement_id); + Ok(spoc_ads.map(|res| res.iter().map(|ad| ad.clone().into()).collect())) + } + + #[handle_error(ComponentError)] + #[uniffi::method()] + pub fn query_tile_ads(&self, placement_id: String) -> AdsClientApiResult> { + let inner = self.inner.lock(); + let image_ads: Option<&AdTile> = inner.get_cached_ads::(&placement_id); + Ok(image_ads.map(|ad| ad.clone().into())) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn dispatch_record_click( + &self, + click_url: String, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&click_url) + .map_err(|e| ComponentError::RecordClick(CallbackRequestError::InvalidUrl(e).into()))?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + + self.worker + .dispatch(DispatchCommand::RecordClick { url, ohttp }) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn dispatch_record_impression( + &self, + impression_url: String, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&impression_url).map_err(|e| { + ComponentError::RecordImpression(CallbackRequestError::InvalidUrl(e).into()) + })?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + + self.worker + .dispatch(DispatchCommand::RecordImpression { url, ohttp }) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn dispatch_report_ad( + &self, + report_url: String, + reason: MozAdsReportReason, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&report_url) + .map_err(|e| ComponentError::ReportAd(CallbackRequestError::InvalidUrl(e).into()))?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + self.worker.dispatch(DispatchCommand::ReportAd { + url, + reason: reason.into(), + ohttp, + }) + } + + // Pings the background worker and waits for a response back, for use in tests. + // Because the background worker is synchronous, this returns if the worker is empty, + // making it useful for integration tests to wait until all tasks have completed. + #[handle_error(ComponentError)] + pub fn ping_background_worker(&self, timeout: Option) -> AdsClientApiResult<()> { + let (tx, rx) = mpsc::sync_channel(0); + self.worker.dispatch(DispatchCommand::Ping(tx))?; + + if let Some(timeout) = timeout { + rx.recv_timeout(timeout) + .map_err(BackgroundWorkerError::from)?; + } else { + rx.recv().map_err(|_| BackgroundWorkerError::WorkerClosed)?; + } + Ok(()) + } } diff --git a/components/ads-client/src/worker.rs b/components/ads-client/src/worker.rs new file mode 100644 index 00000000000..46a3b2c8f8a --- /dev/null +++ b/components/ads-client/src/worker.rs @@ -0,0 +1,97 @@ +use crate::{ + client::{ + error::{BackgroundWorkerError, ComponentError}, + WorkerMetaEvent, + }, + telemetry::Telemetry, + worker::command::DispatchCommand, + MozAdsClientInner, +}; +use std::{ + sync::mpsc::{self, Receiver, SyncSender}, + thread::JoinHandle, +}; + +pub mod command; + +pub const ADS_CLIENT_WORKER_CHANNEL_BUFFER_SIZE: usize = 1000; +pub const ADS_CLIENT_WORKER_THREAD_NAME: &str = "ads-client.worker"; + +pub struct AdsClientWorkerWrapper +where + T: Clone + Telemetry, +{ + _worker_thread: Option>, + worker_dispatch: Option>, + + telemetry: T, +} + +impl AdsClientWorkerWrapper { + pub fn new(inner: MozAdsClientInner, telemetry: T) -> AdsClientWorkerWrapper { + let (worker_dispatch, worker_thread) = + Option::unzip(build_worker_thread(inner.clone(), telemetry.clone())); + AdsClientWorkerWrapper { + _worker_thread: worker_thread, + worker_dispatch, + telemetry, + } + } + + pub fn dispatch(&self, command: DispatchCommand) -> Result<(), ComponentError> { + let telemetry_event = command.dispatch_telemetry_event(); + if let Some(worker_dispatch) = &self.worker_dispatch { + worker_dispatch + .try_send(command) + .map_err(BackgroundWorkerError::from) + .inspect_err(|e| { + self.telemetry.record(e); + }) + .inspect(|_| { + if let Some(event) = telemetry_event { + self.telemetry.record(&event); + } + })?; + + Ok(()) + } else { + Err(BackgroundWorkerError::WorkerClosed.into()) + } + } +} + +// Spawn worker thread from a reference to the client, returning a synchronous channel transmitter to the thread, and its JoinHandle. +// Returns None if thread fails to build. +pub fn build_worker_thread( + inner_client: MozAdsClientInner, + telemetry: T, +) -> Option<(SyncSender, JoinHandle<()>)> { + let (tx, rx) = mpsc::sync_channel(ADS_CLIENT_WORKER_CHANNEL_BUFFER_SIZE); + let worker_thread_handle = std::thread::Builder::new() + .name(ADS_CLIENT_WORKER_THREAD_NAME.to_string()) + .spawn(move || crate::worker::worker(inner_client, rx, telemetry)).inspect_err(|err| { + error_support::error!("Failed to create ads-client worker thread `{ADS_CLIENT_WORKER_THREAD_NAME}` with: {err}") + }).ok()?; + Some((tx, worker_thread_handle)) +} + +fn worker( + inner_client: MozAdsClientInner, + rx: Receiver, + telemetry: T, +) { + telemetry.record(&WorkerMetaEvent::Start); + + // Synchronously run tasks in the order they are passed in this separate channel. + while let Ok(command) = rx.recv() { + let failure_telemetry_event = command.failed_telemetry_event(); + + // Error is naturally logged through `handle_error` conversion macro. + if command.run_command(&inner_client, &telemetry).is_err() { + // This telemetry logs which command fails, but does not separately record the error itself. + // Because the command hits the underlying client's method, it reuses the `.record(e)` call (eg: for RequestAdsError) + telemetry.record(&failure_telemetry_event); + } + } + telemetry.record(&WorkerMetaEvent::Stop); +} diff --git a/components/ads-client/src/worker/command.rs b/components/ads-client/src/worker/command.rs new file mode 100644 index 00000000000..5e9fb46f441 --- /dev/null +++ b/components/ads-client/src/worker/command.rs @@ -0,0 +1,201 @@ +use std::{collections::HashMap, sync::mpsc::SyncSender}; + +use error_support::handle_error; +use url::Url; + +use crate::{ + client::{ + error::{BackgroundWorkerError, ComponentError}, + CommandDispatchedOperationEvent, CommandFailedOperationEvent, + CommandProcessedOperationEvent, + }, + http_cache::CachePolicy, + mars::{ + ad_request::AdPlacementRequest, + ad_response::{AdImage, AdSpoc, AdTile}, + ReportReason, + }, + telemetry::Telemetry, + AdsClientApiResult, MozAdsClientInner, MozAdsPlacementRequest, MozAdsPlacementRequestWithCount, +}; + +pub enum DispatchCommand { + RequestImageAds { + image_ad_requests: Vec, + cache_policy: CachePolicy, + ohttp: bool, + flags: HashMap, + }, + RequestSpocAds { + spoc_ad_requests: Vec, + cache_policy: CachePolicy, + ohttp: bool, + flags: HashMap, + }, + RequestTileAds { + tile_ad_requests: Vec, + cache_policy: CachePolicy, + ohttp: bool, + flags: HashMap, + }, + RecordClick { + url: Url, + ohttp: bool, + }, + RecordImpression { + url: Url, + ohttp: bool, + }, + ReportAd { + url: Url, + reason: ReportReason, + ohttp: bool, + }, + Ping(SyncSender<()>), +} + +impl DispatchCommand { + // Runs a dispatched command synchronously in it's thread. + // The dispatched command calls the corresponding `AdsClient` synchronous method, meaning that behavior between the two is shared. + // This includes telemetry calls, meaning that for a successful `RecordClick`, all of the following will get logged: + // - CommandDispatchedOperationEvent::RecordClick (on dispatch) + // - ClientOperationEvent::RecordClick (on `AdsClient` method success) + // - CommandProcessedOperationEvent::RecordClick (on process) + #[handle_error(ComponentError)] + pub fn run_command( + self, + ads_client_inner: &MozAdsClientInner, + telemetry: &T, + ) -> AdsClientApiResult<()> { + match self { + DispatchCommand::RequestImageAds { + image_ad_requests, + cache_policy, + flags, + ohttp, + } => { + let mut inner = ads_client_inner.lock(); + + // Image ads + if !image_ad_requests.is_empty() { + let image_ad_requests: Vec = + image_ad_requests.iter().map(|r| r.into()).collect(); + let image_response = inner + .request_image_ads(image_ad_requests, flags, Some(cache_policy), ohttp) + .map_err(ComponentError::RequestAds)?; + inner.cache_ads::(image_response); + } + + telemetry.record(&CommandProcessedOperationEvent::RequestAds); + Ok(()) + } + DispatchCommand::RequestSpocAds { + spoc_ad_requests, + cache_policy, + flags, + ohttp, + } => { + let mut inner = ads_client_inner.lock(); + + // Spoc ads + if !spoc_ad_requests.is_empty() { + let spoc_ad_requests: Vec = + spoc_ad_requests.iter().map(|r| r.into()).collect(); + let spoc_response = inner + .request_spoc_ads(spoc_ad_requests, flags, Some(cache_policy), ohttp) + .map_err(ComponentError::RequestAds)?; + inner.cache_ads::(spoc_response); + } + + telemetry.record(&CommandProcessedOperationEvent::RequestAds); + Ok(()) + } + DispatchCommand::RequestTileAds { + tile_ad_requests, + cache_policy, + flags, + ohttp, + } => { + let mut inner = ads_client_inner.lock(); + + // Tile ads + if !tile_ad_requests.is_empty() { + let tile_ad_requests: Vec = + tile_ad_requests.iter().map(|r| r.into()).collect(); + let tile_response = inner + .request_tile_ads(tile_ad_requests, flags, Some(cache_policy), ohttp) + .map_err(ComponentError::RequestAds)?; + inner.cache_ads::(tile_response); + } + + telemetry.record(&CommandProcessedOperationEvent::RequestAds); + Ok(()) + } + DispatchCommand::RecordClick { url, ohttp } => { + let inner = ads_client_inner.lock(); + inner + .record_click(url, ohttp) + .map_err(ComponentError::RecordClick)?; + telemetry.record(&CommandProcessedOperationEvent::RecordClick); + Ok(()) + } + DispatchCommand::RecordImpression { url, ohttp } => { + let inner = ads_client_inner.lock(); + inner + .record_impression(url, ohttp) + .map_err(ComponentError::RecordImpression)?; + telemetry.record(&CommandProcessedOperationEvent::RecordImpression); + Ok(()) + } + DispatchCommand::ReportAd { url, ohttp, reason } => { + let inner = ads_client_inner.lock(); + inner + .report_ad(url, reason, ohttp) + .map_err(ComponentError::ReportAd)?; + telemetry.record(&CommandProcessedOperationEvent::ReportAd); + Ok(()) + } + + DispatchCommand::Ping(sender) => { + sender + .try_send(()) + .map_err(|err| BackgroundWorkerError::PongFailure(Box::new(err)))?; + Ok(()) + } + } + } + + pub fn dispatch_telemetry_event(&self) -> Option { + match self { + DispatchCommand::RequestImageAds { .. } + | DispatchCommand::RequestSpocAds { .. } + | DispatchCommand::RequestTileAds { .. } => { + Some(CommandDispatchedOperationEvent::RequestAds) + } + DispatchCommand::RecordClick { .. } => { + Some(CommandDispatchedOperationEvent::RecordClick) + } + DispatchCommand::RecordImpression { .. } => { + Some(CommandDispatchedOperationEvent::RecordImpression) + } + DispatchCommand::ReportAd { .. } => Some(CommandDispatchedOperationEvent::ReportAd), + DispatchCommand::Ping(_) => None, + } + } + + pub fn failed_telemetry_event(&self) -> Option { + match self { + DispatchCommand::RequestImageAds { .. } + | DispatchCommand::RequestSpocAds { .. } + | DispatchCommand::RequestTileAds { .. } => { + Some(CommandFailedOperationEvent::RequestAds) + } + DispatchCommand::RecordClick { .. } => Some(CommandFailedOperationEvent::RecordClick), + DispatchCommand::RecordImpression { .. } => { + Some(CommandFailedOperationEvent::RecordImpression) + } + DispatchCommand::ReportAd { .. } => Some(CommandFailedOperationEvent::ReportAd), + DispatchCommand::Ping(_) => None, + } + } +}