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}