[AC-154] implement fire and forget ads client async - #7531
Conversation
…into AC-154-Implement-fire-and-forget-ads-client-async
This reverts commit 2539df5.
…ttps://github.com/mozilla/application-services into AC-154-Implement-fire-and-forget-ads-client-async
| // 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. |
There was a problem hiding this comment.
One thing I iterated a bit earlier was adding callbacks so we can check for errors in the background processes in integration tests, but it becomes a bit problematic with the new shutdown functionality (to ensure we can immediately shutdown, we need to be able to kill all held callbacks in the process queue), meaning we need to have proper async implementation (tokio can kill held tasks in this way) or have an arena of Arc<> callbacks to shutdown on the shutdown() call.
Pinging is an internal function anyway, so we could also maintain a history of errors that gets returned on a ping (for purposes of tests), but this also adds more a bit more complexity (could separate that to another PR) and wouldn't be easily used by the surfaces.
Almaju
left a comment
There was a problem hiding this comment.
Thanks for putting this together! there's a lot of good thinking here. My main concern is scope: this combines FFI, worker/threading, caching, and telemetry changes in one PR, which made some things hard to isolate.
Would it be possible to split this into smaller, independently reviewable pieces? AdsCache as its own PR with unit tests, worker/dispatch logic as another? I also think it's worth discussing splitting the crate into ads-client-core (plain Rust, no uniffi) and ads-client (FFI wrapper), since a few comments below stem from the worker/command code depending directly on FFI types.
| RequestAds(#[from] RequestAdsError), | ||
|
|
||
| #[error("Error requesting ads from worker: {0}")] | ||
| BackgroundWorker(#[from] BackgroundWorkerError), |
There was a problem hiding this comment.
Nit: could we keep the variants alphabetically ordered here? BackgroundWorker would go first, ahead of the Record*/Report*/RequestAds variants. Same applies to BackgroundWorkerError below (PongFailure, WorkerClosed, WorkerFull, WorkerTimedOut).
| pub struct MozAdsClient { | ||
| inner: Mutex<AdsClient<MozAdsTelemetryWrapper>>, | ||
| inner: MozAdsClientInner, | ||
| worker: AdsClientWorkerWrapper<MozAdsTelemetryWrapper>, | ||
| } |
There was a problem hiding this comment.
I think worker shouldn't be owned by the FFI object here. My understanding is FFI's job should stay limited to uniffi/Arc/Mutex plumbing and breaking-change protection, not orchestrating a background thread. Would it make sense to move worker into AdsClient and use &mut self directly there, instead of going through Arc<Mutex<...>> at this level?
Also noticed worker/command.rs imports MozAdsPlacementRequest and AdsClientApiResult straight from the FFI layer, and does the FFI → domain conversion inside run_command (line 83-84). That conversion feels like it belongs in ffi.rs, not in the worker module.
There was a problem hiding this comment.
My intention with this is I really didn't want the fire and forget methods to need to have to wait on the mutex lock. So currently, any mutating call to the AdsClient (including the ones that get ads in the background) gets that lock, and if we keep AdsClientWorkerWrapper inside the Mutex<AdsClient<...>> we need to get a lock to fire-and-forget.
That means that if the background process wants to call request_spoc_ads, and gets a lock on it, any fire and forget commands in the meantime would have to wait.
If we don't want this struct to hold this, we could add a layer of indiscretion for it to work? Something like MozAdsClient -> AdsClientWithWorker (obviously with a different name than that) -> Mutex<AdsClient>
|
|
||
| #[handle_error(ComponentError)] | ||
| #[uniffi::method(default(image_ad_requests = [], spoc_ad_requests = [], tile_ad_requests = [], options = None))] | ||
| pub fn prefetch_ads( |
There was a problem hiding this comment.
Could we simplify this to a single requests: Vec<...> instead of three parallel vectors (image_ad_requests, spoc_ad_requests, tile_ad_requests)? Right now the caller has to split requests by ad type manually, and we do three separate empty-checks + dispatches, which undercuts the point of having a unified request type. If we need a per-item count for SPOCs, maybe that's an optional field (or enum) on the request item rather than a separate vector.
There was a problem hiding this comment.
Sure. I think most reasonable setup would be an enum here
AdRequest {
Image{...},
Spoc{...},
Tile{...}
}
Though it might be a little more verbose on the surface implementation end
|
|
||
| #[handle_error(ComponentError)] | ||
| #[uniffi::method()] | ||
| pub fn query_image_ads(&self, placement_id: String) -> AdsClientApiResult<Option<MozAdsImage>> { |
There was a problem hiding this comment.
Question: can this actually fail? It's reading from the in-memory cache and already returns Option, so I don't see a path to Err. If this is intentionally forward-looking (e.g. a future fallible cache backend), a short comment saying so would help, otherwise I'd lean towards dropping AdsClientApiResult here since it implies a failure mode that doesn't exist.
There was a problem hiding this comment.
Yeah, I was intending to be a bit futureproof here. I'll add a comment
|
|
||
| #[handle_error(ComponentError)] | ||
| #[uniffi::method(default(options = None))] | ||
| pub fn dispatch_record_click( |
There was a problem hiding this comment.
I'm not fully convinced by having both record_impression and dispatch_record_impression -> they parse the URL and map errors identically, the only difference is sync vs. queued. From a consumer's perspective these look interchangeable, which I think creates more confusion than it saves. If we want both a sync and a fire-and-forget flavor, could that be an explicit v1/v2 FFI decision rather than two similarly-named methods sitting next to each other?
Separate, bigger topic (feel free to punt): do we still want to expose raw impression URLs to the consumer at all, or should we be moving towards ids instead? 🤔
There was a problem hiding this comment.
Makes sense- this actually adds a reasonable usecase for the originally pitched ads-client.async-enabled nimbus flag (for the first slice), for whether the dispatches are immediate or async.
| WorkerFull, | ||
|
|
||
| #[error("Error requesting new ads from the background worker: worker closed")] | ||
| WorkerClosed, |
There was a problem hiding this comment.
If this happens, it looks like the client stays broken for the rest of its lifetime, nothing here attempts to restart the worker thread. Since this is an internal implementation detail, I'd prefer we recover from it (respawn) rather than propagate the error to the caller.
| 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, | ||
| } | ||
|
|
There was a problem hiding this comment.
These four enums all share the same four variants. Could we collapse them into a single ClientEvent enum plus a separate Phase (Dispatched/Processed/Failed)? That would also address the naming, CommandDispatchedOperationEvent is quite long for something scoped to this module.
| fn fetch_cached_ads<'a>(ads_cache: &'a AdsCache, id: &str) -> Option<&'a AdTile> { | ||
| ads_cache.tile_ads.get(id).map(|(_, ads)| ads) | ||
| } | ||
| } |
There was a problem hiding this comment.
A few thoughts here:
- Do we need the
StorageTypeassociated type / three separate maps? It looks like this could just beplacement_id -> Vec<Ad>uniformly. image/tile would simply be aVecof length 0 or 1, and the unwrap-to-single-ad already happens at the call site (take_first()in client.rs). That removes the trait indirection and the three near-identical impls. - Naming: is "cache" the right word? We already have
http_cacheas the actual cache (lookup-before-fetch, sqlite-backed, TTL/ETag). This structure is only ever written by the background worker after a prefetch resolves, and read via thequery_*methods -> that reads more like a materialized view (CQRS-style) than a cache to me, and having two different "cache" concepts in the same crate risks confusion down the line. WouldAdsStorebe clearer? - Could we use a
PlacementIdnewtype instead of a rawStringwhile we're touching this to make it clearer what the key is?
There was a problem hiding this comment.
-
My initial version of it looked something like this but I ended up veering away from it because it felt a bit odd to be storing them as
Vec<...>and then running them immediately. Happy to do it however though. -
I'm happy to rename it to AdsStore. I think the term cache is still appropriate here but I agree with the naming confusion- it is intended to be bundled into the same sqlite database eventually as well.
|
|
||
| pub mod command; | ||
|
|
||
| pub const ADS_CLIENT_WORKER_CHANNEL_BUFFER_SIZE: usize = 1000; |
There was a problem hiding this comment.
Is there a reason behind 1000? Seems like a reasonable starting point, but a short comment on how we landed on it would help future readers. Separately: since dispatch uses try_send, hitting this limit returns WorkerFull synchronously to callers of the fire-and-forget methods (dispatch_record_click, etc.), which seems at odds with these being fire-and-forget in the first place.
| .extend(ads.into_iter().map(|(key, ad)| (key, (timestamp, ad)))); | ||
| ads_cache | ||
| .image_ads | ||
| .retain(|_, (x, _)| *x - timestamp < DEFAULT_TTL.as_secs()); |
There was a problem hiding this comment.
I think this is backwards:
t=1000: we cache an image ad for placement"a"→ stored as("a", (1000, ad)).t=1100: we prefetch an image ad for placement"b"→cache_adsis called withtimestamp=1100, sox=1000for"a"'s existing entry andx=1100for"b"'s new one.retainevaluates"b":1100 - 1100 = 0 < 300→ kept, correct.retainevaluates"a":1000 - 1100→ this is a panic https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=aaa058d8f0bc8f002ce41e92ddb06bba
Beyond fixing the operand order, I'd suggest we stop doing the age check as raw arithmetic on u64 at three call sites and give it a name instead, something like:
struct CacheEntry<T>{
inserted_at: Instant,
value: T,
}
impl<T> CacheEntry<T>{
fn is_expired(&self, ttl: Duration) -> bool {
self.inserted_at.elapsed() >= ttl
}
}
The first initial slice for the plan to let ads-client work quasi-asynchronously: instead of having every single command be synchronous (waiting on a call to fully resolve) this adds a background thread worker to allow surface commands to be fire and forget.
prefetchfunctions that perform the ads fetching in the background (queuing up the task).Discussion points:
I've excluded the following from this PR:
ErrorOnlyCallbackthat would allow the surfaces to receive any errors from the process running in the worker thread. However, in light of the uniffi callbacks issue, it requires a bit of refactoring to ensure these can be passed to the thread and shutdown successfully- possible, but adds a lot to this PR, so I've excluded it for now.Pull Request checklist
[ci full]to the PR title.