rovenue/placements/
client.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use crate::cache::placements::PlacementsCacheRepo;
5use crate::cache::CacheStore;
6use crate::error::{ErrorKind, RovenueError, RovenueResult};
7use crate::offerings::client::map_offering;
8use crate::time::{Clock, SystemClock};
9use crate::transport::api::ApiEnvelope;
10use crate::transport::http_client::HttpClient;
11use crate::transport::types::HttpRequest;
12
13use super::bucketing::{assign_bucket, select_variant_index};
14use super::types::{CoreFunnel, CorePresentedContext, PlacementsResponse};
15
16pub struct PlacementsClient {
17    http: Arc<HttpClient>,
18    store: Arc<CacheStore>,
19    clock: Arc<dyn Clock>,
20    /// In-memory bundled-fallback-file map: `identifier -> raw
21    /// PlacementsResponse JSON string`, loaded (once, wholesale-replaced)
22    /// by `set_fallback` (see `RovenueCore::set_fallback_placements` /
23    /// `placements::fallback::parse_fallback_file`). Decoded lazily per
24    /// `get_funnel` call — same raw-string-storage rationale as the disk
25    /// cache (see `fallback.rs` doc comment): `PlacementsResponse` isn't
26    /// `Clone`.
27    fallback: Mutex<HashMap<String, String>>,
28}
29
30impl PlacementsClient {
31    pub fn new(http: Arc<HttpClient>, store: Arc<CacheStore>) -> Self {
32        Self {
33            http,
34            store,
35            clock: Arc::new(SystemClock),
36            fallback: Mutex::new(HashMap::new()),
37        }
38    }
39
40    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
41        self.clock = clock;
42        self
43    }
44
45    /// Replace the in-memory bundled-fallback-file map wholesale (called
46    /// once by `RovenueCore::set_fallback_placements`, after
47    /// `fallback::parse_fallback_file` has already validated the file and
48    /// decoded each entry once to confirm it parses as `PlacementsResponse`
49    /// — the raw strings stored here are re-decoded lazily per
50    /// `get_funnel` call, same as the disk cache).
51    pub fn set_fallback(&self, entries: HashMap<String, String>) {
52        if let Ok(mut guard) = self.fallback.lock() {
53            *guard = entries;
54        }
55    }
56
57    /// Fetch + resolve `GET /v1/placements/{identifier}?locale=` into the
58    /// funnel the placement serves.
59    ///
60    /// `Ok(None)` means the placement resolved to nothing — unknown/inactive
61    /// placement, a `target: none` row, a retired reference, or no row
62    /// matched. This is NOT an error: a shipped app must never crash
63    /// because a placement was retired server-side.
64    ///
65    /// On a successful live fetch the raw response JSON is cached under
66    /// `placement:{identifier}`. On `NetworkUnavailable`/`Timeout` the last
67    /// cached response (if any) is re-resolved instead (a fresh bucket draw
68    /// runs against the cached experiment payload). If there's no cached
69    /// response either, the bundled fallback map (see `set_fallback`) is
70    /// consulted as a last resort — cache always beats fallback, and the
71    /// fallback is only ever consulted on this same connectivity-class
72    /// branch (never for auth/server errors, which propagate exactly as
73    /// before). Any other error propagates unchanged.
74    ///
75    /// `server_assignment(experiment_key)` returns the variant the server
76    /// already assigned this subscriber (the Remote Config cache), if any.
77    /// It wins over the local draw, which otherwise disagrees with the
78    /// server after two devices merge into one subscriber (see
79    /// `resolve_funnel`).
80    pub fn get_funnel(
81        &self,
82        identifier: &str,
83        locale: Option<&str>,
84        subscriber_id: &str,
85        server_assignment: &dyn Fn(&str) -> Option<String>,
86    ) -> RovenueResult<Option<CoreFunnel>> {
87        let (resp, served_from_fallback) = self.fetch(identifier, locale, subscriber_id)?;
88        Ok(resolve_funnel(
89            resp,
90            subscriber_id,
91            served_from_fallback,
92            server_assignment,
93        ))
94    }
95
96    /// The shared fetch ladder: live, then disk cache, then the bundled
97    /// fallback file. The bool is `served_from_fallback`.
98    fn fetch(
99        &self,
100        identifier: &str,
101        locale: Option<&str>,
102        subscriber_id: &str,
103    ) -> RovenueResult<(PlacementsResponse, bool)> {
104        let cache_key = format!("placement:{identifier}");
105        let path = match locale {
106            Some(l) if !l.is_empty() => format!("/v1/placements/{identifier}?locale={l}"),
107            _ => format!("/v1/placements/{identifier}"),
108        };
109        let req = HttpRequest::new(&path).subscriber_id(subscriber_id);
110
111        match self.http.get_json::<ApiEnvelope<PlacementsResponse>>(req) {
112            Ok(resp) => {
113                let body = resp.body.ok_or(RovenueError::Internal())?;
114                // Persist the raw response (best-effort: a cache write
115                // failure must not fail an otherwise-successful fetch).
116                if let Ok(raw) = serde_json::to_string(&body.data) {
117                    let _ = PlacementsCacheRepo::new(&self.store).put(
118                        &cache_key,
119                        &raw,
120                        self.clock.now_unix_ms(),
121                    );
122                }
123                Ok((body.data, false))
124            }
125            Err(e) if matches!(e.kind, ErrorKind::NetworkUnavailable | ErrorKind::Timeout) => {
126                match PlacementsCacheRepo::new(&self.store).get(&cache_key)? {
127                    Some(raw) => {
128                        let parsed: PlacementsResponse =
129                            serde_json::from_str(&raw).map_err(|_| RovenueError::Internal())?;
130                        Ok((parsed, false))
131                    }
132                    None => match self.fallback_raw(identifier) {
133                        Some(raw) => match serde_json::from_str::<PlacementsResponse>(&raw) {
134                            Ok(parsed) => Ok((parsed, true)),
135                            // A stored fallback entry that fails to
136                            // re-decode is unreachable in practice (it was
137                            // already validated by parse_fallback_file
138                            // before being stored), but must not panic —
139                            // fall through to the original network error.
140                            Err(_) => Err(e),
141                        },
142                        None => Err(e),
143                    },
144                }
145            }
146            Err(e) => Err(e),
147        }
148    }
149
150    fn fallback_raw(&self, identifier: &str) -> Option<String> {
151        self.fallback
152            .lock()
153            .ok()
154            .and_then(|guard| guard.get(identifier).cloned())
155    }
156}
157
158/// Pull a funnel out of a resolved placement response.
159///
160/// A free function rather than a method: it touches no client state, and
161/// keeping it that way makes it testable without an HTTP stack.
162fn resolve_funnel(
163    resp: PlacementsResponse,
164    subscriber_id: &str,
165    served_from_fallback: bool,
166    server_assignment: &dyn Fn(&str) -> Option<String>,
167) -> Option<CoreFunnel> {
168    let placement = resp.placement?;
169
170    // A direct target wins; otherwise draw a variant.
171    let (funnel, variant_id, experiment_key) = match resp.funnel {
172        Some(funnel) => (funnel, None, None),
173        None => {
174            let experiment = resp.experiment?;
175            if experiment.variants.is_empty() {
176                return None;
177            }
178            // The server's sticky assignment first: after two devices merge
179            // into one subscriber, this device's rovenueId can draw a
180            // different variant than the one the server recorded (drawn
181            // from the other device's rovenueId). /v1/config is per
182            // subscriber and follows the merge, so its cached assignment is
183            // the canonical one. A variant the envelope no longer carries
184            // (the experiment was edited) falls through to the draw.
185            let assigned = server_assignment(&experiment.key)
186                .and_then(|id| experiment.variants.iter().position(|v| v.variant_id == id));
187            // Otherwise the local draw: one bucketing function, one set of
188            // vectors, one weight interpretation — shared with every other
189            // experiment draw in this crate, so all of them agree with
190            // bucketing-vectors.json. For a single device it lands where the
191            // server's own draw did.
192            let idx = assigned.unwrap_or_else(|| {
193                let weights: Vec<f64> = experiment.variants.iter().map(|v| v.weight).collect();
194                select_variant_index(assign_bucket(subscriber_id, &experiment.key), &weights)
195            });
196            let variant = experiment.variants.into_iter().nth(idx)?;
197            (
198                variant.funnel,
199                Some(variant.variant_id),
200                Some(experiment.key),
201            )
202        }
203    };
204    // Serialized here rather than on the way in, so the string the façade
205    // receives is exactly what `FunnelSession` parses.
206    let config_json = serde_json::to_string(&funnel.config).ok()?;
207
208    Some(CoreFunnel {
209        placement_identifier: placement.identifier.clone(),
210        placement_revision: placement.revision,
211        funnel_identifier: funnel.identifier.clone(),
212        funnel_id: funnel.id.clone(),
213        revision: funnel.revision,
214        config_format_version: funnel.config_format_version,
215        config_json,
216        offering: funnel.offering.map(map_offering),
217        links: funnel.links.into(),
218        // `funnel_id` holds the funnels ROW id — what web checkout writes
219        // too (funnel-payment.ts) — not the document identifier.
220        presented_context: Some(CorePresentedContext {
221            placement_id: placement.identifier,
222            funnel_id: funnel.id.clone(),
223            variant_id,
224            experiment_key,
225            revision: placement.revision,
226        }),
227        served_from_fallback,
228    })
229}