diff --git a/components/ads-client/src/ads_store.rs b/components/ads-client/src/ads_store.rs new file mode 100644 index 00000000000..8b52783f1d4 --- /dev/null +++ b/components/ads-client/src/ads_store.rs @@ -0,0 +1,270 @@ +use crate::mars::ad_response::{AdImage, AdSpoc, AdTile}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +const DEFAULT_TTL: Duration = Duration::from_secs(300); + +// 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. +#[derive(Debug)] +pub struct AdsStore { + ttl: Duration, + + image_ads: HashMap>, + spoc_ads: HashMap>>, + tile_ads: HashMap>, +} + +/// Identification of placement sent and returned from MARS (eg: `mock_spoc_1`) +#[derive(Debug, Hash, PartialEq, Eq, Clone)] +pub struct PlacementId(String); + +impl Default for AdsStore { + fn default() -> Self { + Self::new() + } +} + +impl AdsStore { + pub fn new() -> Self { + AdsStore::new_with_ttl(DEFAULT_TTL) + } + + pub fn new_with_ttl(ttl: Duration) -> Self { + AdsStore { + ttl, + image_ads: HashMap::new(), + spoc_ads: HashMap::new(), + tile_ads: HashMap::new(), + } + } + + pub fn store_ads( + &mut self, + ads: HashMap, + timestamp: Instant, + ) { + T::store_ads(ads, self, timestamp); + } + + pub fn get_stored_ads<'a, T: AdsStorable>( + &'a self, + placement: &PlacementId, + ) -> Option<&'a T::StorageType> { + T::fetch_stored_ads(self, placement) + } +} + +pub trait AdsStorable: Sized { + // The ad(s) to store (eg: this may be a single ad, or an array of ads) + type StorageType; + + fn store_ads( + ads: HashMap, + ads_cache: &mut AdsStore, + timestamp: Instant, + ); + fn fetch_stored_ads<'a>( + ads_cache: &'a AdsStore, + id: &PlacementId, + ) -> Option<&'a Self::StorageType>; +} + +impl AdsStorable for AdImage { + type StorageType = AdImage; + fn store_ads(ads: HashMap, ads_cache: &mut AdsStore, timestamp: Instant) { + ads_cache.image_ads.extend( + ads.into_iter() + .map(|(key, ad)| (key, CacheEntry::new(ad, timestamp))), + ); + ads_cache + .image_ads + .retain(|_, x| !x.is_expired(ads_cache.ttl)); + } + + fn fetch_stored_ads<'a>(ads_cache: &'a AdsStore, id: &PlacementId) -> Option<&'a AdImage> { + ads_cache.image_ads.get(id).map(|ads| ads.get_value()) + } +} + +impl AdsStorable for AdSpoc { + type StorageType = Vec; + fn store_ads( + ads: HashMap>, + ads_cache: &mut AdsStore, + timestamp: Instant, + ) { + ads_cache.spoc_ads.extend( + ads.into_iter() + .map(|(key, ad)| (key, CacheEntry::new(ad, timestamp))), + ); + ads_cache + .spoc_ads + .retain(|_, x| !x.is_expired(ads_cache.ttl)); + } + fn fetch_stored_ads<'a>(ads_cache: &'a AdsStore, id: &PlacementId) -> Option<&'a Vec> { + ads_cache.spoc_ads.get(id).map(|ads| ads.get_value()) + } +} + +impl AdsStorable for AdTile { + type StorageType = AdTile; + fn store_ads(ads: HashMap, ads_cache: &mut AdsStore, timestamp: Instant) { + ads_cache.tile_ads.extend( + ads.into_iter() + .map(|(key, ad)| (key, CacheEntry::new(ad, timestamp))), + ); + ads_cache + .tile_ads + .retain(|_, x| !x.is_expired(ads_cache.ttl)); + } + fn fetch_stored_ads<'a>(ads_cache: &'a AdsStore, id: &PlacementId) -> Option<&'a AdTile> { + ads_cache.tile_ads.get(id).map(|ads| ads.get_value()) + } +} + +#[derive(Debug)] +struct CacheEntry { + inserted_at: Instant, + value: T, +} + +impl CacheEntry { + fn new(value: T, instant: Instant) -> CacheEntry { + CacheEntry { + inserted_at: instant, + value, + } + } + + fn is_expired(&self, ttl: Duration) -> bool { + self.inserted_at.elapsed() >= ttl + } + + fn get_value(&self) -> &T { + &self.value + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + time::{Duration, Instant}, + }; + + use crate::{ + ads_store::{AdsStore, PlacementId}, + mars::ad_response::{AdImage, AdSpoc, AdTile}, + test_utils, + }; + + #[test] + fn test_store_image_ad() { + let one_min_ago = Instant::now() + .checked_sub(Duration::from_secs(60)) + .expect("Could not create `Instant` for 5 minutes ago"); + let five_sec_ago = Instant::now() + .checked_sub(Duration::from_secs(5)) + .expect("Could not create `Instant` for 1 minute ago"); + let mut ads_store = AdsStore::new_with_ttl(Duration::from_secs(30)); + + let demo_ads = test_utils::get_example_happy_image_response().data; + let demo_ads: HashMap = demo_ads + .clone() + .into_iter() + .filter_map(|(k, v)| Some((PlacementId(k), v.into_iter().next()?))) + .collect(); + let first_key = demo_ads + .iter() + .next() + .expect("No test data in `get_example_happy_image_response`") + .0; + + ads_store.store_ads::(demo_ads.clone(), one_min_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_none(), + "Old data past TTL date must not be returned." + ); + + ads_store.store_ads::(demo_ads.clone(), five_sec_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_some(), + "Could not fetch fresh ad from ads store." + ); + } + + #[test] + fn test_store_spocs_ad() { + let one_min_ago = Instant::now() + .checked_sub(Duration::from_secs(60)) + .expect("Could not create `Instant` for 5 minutes ago"); + let five_sec_ago = Instant::now() + .checked_sub(Duration::from_secs(5)) + .expect("Could not create `Instant` for 1 minute ago"); + let mut ads_store = AdsStore::new_with_ttl(Duration::from_secs(30)); + + let demo_ads = test_utils::get_example_happy_spoc_response().data; + let demo_ads: HashMap> = demo_ads + .clone() + .into_iter() + .map(|(k, v)| (PlacementId(k), v)) + .collect(); + let first_key = demo_ads + .iter() + .next() + .expect("No test data in `get_example_happy_spoc_response`") + .0; + + ads_store.store_ads::(demo_ads.clone(), one_min_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_none(), + "Old data past TTL date must not be returned." + ); + + ads_store.store_ads::(demo_ads.clone(), five_sec_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_some(), + "Could not fetch fresh ad from ads store." + ); + } + + #[test] + + fn test_store_tiles_ad() { + let one_min_ago = Instant::now() + .checked_sub(Duration::from_secs(60)) + .expect("Could not create `Instant` for 5 minutes ago"); + let five_sec_ago = Instant::now() + .checked_sub(Duration::from_secs(5)) + .expect("Could not create `Instant` for 1 minute ago"); + let mut ads_store = AdsStore::new_with_ttl(Duration::from_secs(30)); + + let demo_ads = test_utils::get_example_happy_uatile_response().data; + let demo_ads: HashMap = demo_ads + .clone() + .into_iter() + .filter_map(|(k, v)| Some((PlacementId(k), v.into_iter().next()?))) + .collect(); + let first_key = demo_ads + .iter() + .next() + .expect("No test data in `get_example_happy_uatile_response`") + .0; + + ads_store.store_ads::(demo_ads.clone(), one_min_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_none(), + "Old data past TTL date must not be returned." + ); + + ads_store.store_ads::(demo_ads.clone(), five_sec_ago); + assert!( + ads_store.get_stored_ads::(first_key).is_some(), + "Could not fetch fresh ad from ads store." + ); + } +} diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 6f510c55617..4c43a30665e 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::time::Duration; +use crate::ads_store::{AdsStorable, AdsStore, PlacementId}; use crate::http_cache::{ByteSize, CachePolicy, HttpCache}; use crate::mars::ad_request::{AdPlacementRequest, AdRequestFlags}; use crate::mars::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile}; @@ -42,6 +43,7 @@ where client: MARSClient, context_id_provider: Box, telemetry: T, + ads_store: AdsStore, } impl AdsClient @@ -91,6 +93,7 @@ where client, context_id_provider, telemetry: telemetry.clone(), + ads_store: AdsStore::new(), } } @@ -110,6 +113,20 @@ where Ok(()) } + #[allow(dead_code)] + pub fn store_ads(&mut self, ads: HashMap) { + let now = std::time::Instant::now(); + self.ads_store.store_ads::(ads, now); + } + + #[allow(dead_code)] + pub fn get_stored_ads( + &self, + placement_id: &PlacementId, + ) -> Option<&A::StorageType> { + self.ads_store.get_stored_ads::(placement_id) + } + pub fn get_context_id(&self) -> context_id::ApiResult { self.context_id_provider.context_id() } @@ -301,6 +318,7 @@ mod tests { Box::new(DefaultContextIdCallback), )), telemetry, + ads_store: AdsStore::new(), } } diff --git a/components/ads-client/src/lib.rs b/components/ads-client/src/lib.rs index 87cecb2a5f8..b3aab4381d1 100644 --- a/components/ads-client/src/lib.rs +++ b/components/ads-client/src/lib.rs @@ -15,6 +15,8 @@ use client::AdsClient; use error_support::error; use http_cache::CachePolicy; use mars::ad_request::{AdPlacementRequest, AdRequestFlags}; + +pub mod ads_store; mod client; mod ffi; pub mod http_cache;