rovenue/exposure/
mod.rs

1use std::collections::HashSet;
2use std::sync::{Arc, Mutex};
3
4use serde::Serialize;
5
6use crate::cache::ExposureRepo;
7use crate::identity::IdentityManager;
8use crate::remote_config::types::ExperimentAssignment;
9use crate::time::Clock;
10use crate::transport::api::ApiEnvelope;
11use crate::transport::http_client::HttpClient;
12use crate::transport::types::HttpPostRequest;
13
14/// Backend `error.code` (409) for an exposure on an experiment that is not
15/// RUNNING (`packages/shared/src/error-catalog.ts`).
16pub const EXPERIMENT_NOT_RUNNING_CODE: &str = "EXPERIMENT_NOT_RUNNING";
17
18#[derive(Serialize)]
19struct ExposeBody<'a> {
20    #[serde(rename = "variantId")]
21    variant_id: &'a str,
22    #[serde(rename = "subscriberId")]
23    subscriber_id: &'a str,
24}
25
26/// Best-effort, deduped experiment-exposure reporter. Reads stay non-blocking:
27/// the POST runs on a spawned thread and only marks the persistent dedup ledger
28/// on success, so a failed report is retried on the next read.
29///
30/// `pending` is an in-memory set of `"scope\0experiment_id\0variant_id"` keys
31/// that are currently in-flight or already persisted. It prevents spawning
32/// duplicate threads for rapid back-to-back reads before the first thread's
33/// HTTP round-trip has completed.
34pub struct ExposureTracker {
35    repo: ExposureRepo,
36    http: Option<Arc<HttpClient>>,
37    clock: Option<Arc<dyn Clock>>,
38    identity: Arc<IdentityManager>,
39    /// In-memory coalesce guard: tracks keys with an in-flight POST so rapid
40    /// repeated reads don't fan-out N threads for the same (scope, exp, var).
41    pending: Mutex<HashSet<String>>,
42}
43
44impl ExposureTracker {
45    pub fn new(
46        repo: ExposureRepo,
47        http: Option<Arc<HttpClient>>,
48        clock: Option<Arc<dyn Clock>>,
49        identity: Arc<IdentityManager>,
50    ) -> Arc<Self> {
51        Arc::new(Self {
52            repo,
53            http,
54            clock,
55            identity,
56            pending: Mutex::new(HashSet::new()),
57        })
58    }
59
60    /// Fire-and-forget exposure report for `assignment`. If http or clock are
61    /// absent (unconfigured SDK), does nothing. Deduplicates via the persistent
62    /// `ExposureRepo` ledger (checked synchronously before spawning) and via an
63    /// in-memory `pending` set (blocks duplicate spawns for the same triple
64    /// while the first thread's POST is in flight).
65    pub fn maybe_track(self: &Arc<Self>, assignment: &ExperimentAssignment) {
66        let (http, clock) = match (self.http.as_ref(), self.clock.as_ref()) {
67            (Some(h), Some(c)) => (Arc::clone(h), Arc::clone(c)),
68            _ => return,
69        };
70
71        // `scope` namespaces the local dedup ledger/coalesce key; `wire_id` is
72        // the stable rovenue_id sent to the server (resolved as a rovenueId).
73        let scope = self.identity.current_user_scope();
74        let wire_id = self.identity.rovenue_id();
75        let experiment_id = assignment.experiment_id.clone();
76        let variant_id = assignment.variant_id.clone();
77
78        // Build the coalesce key: NUL-separated to avoid collisions.
79        let coalesce_key = format!("{scope}\0{experiment_id}\0{variant_id}");
80
81        // Fast path: check in-memory pending set first (avoids SQLite read
82        // for the common case of back-to-back reads after the first spawn).
83        {
84            let mut pending = match self.pending.lock() {
85                Ok(g) => g,
86                Err(_) => return,
87            };
88            if pending.contains(&coalesce_key) {
89                return;
90            }
91            // Also check the persistent ledger while we hold the lock so there
92            // is no window between the check and the insert.
93            if self
94                .repo
95                .is_exposed(&scope, &experiment_id, &variant_id)
96                .unwrap_or(false)
97            {
98                // Already persisted — insert into pending so future reads skip
99                // even the SQLite check.
100                pending.insert(coalesce_key);
101                return;
102            }
103            // Reserve the slot: mark in-flight before releasing the lock.
104            pending.insert(coalesce_key.clone());
105        }
106
107        let this = Arc::clone(self);
108        std::thread::spawn(move || {
109            let path = format!("/v1/experiments/{experiment_id}/expose");
110            let body = ExposeBody {
111                variant_id: &variant_id,
112                subscriber_id: &wire_id,
113            };
114            // Use serde_json::Value as the response data type so that a 202 with
115            // an empty or non-JSON body doesn't cause a deserialization failure.
116            let res = http.post_json::<ExposeBody, ApiEnvelope<serde_json::Value>>(
117                HttpPostRequest::new(&path).user_scope(&wire_id),
118                &body,
119            );
120            // A 409 EXPERIMENT_NOT_RUNNING is the server's final word on this
121            // exposure (draft/paused/completed experiment): recorded as sent,
122            // like a success, so every later read does not re-POST it.
123            let settled = match &res {
124                Ok(_) => true,
125                Err(e) => e.server_code.as_deref() == Some(EXPERIMENT_NOT_RUNNING_CODE),
126            };
127            if settled {
128                let _ = this
129                    .repo
130                    .mark(&scope, &experiment_id, &variant_id, clock.now_unix_ms());
131                // Leave the key in `pending` — it acts as a permanent in-memory
132                // sentinel so subsequent reads never even hit SQLite.
133            } else {
134                // On failure, remove the key from pending so the next read
135                // retries the POST (dedup set only marks success).
136                if let Ok(mut guard) = this.pending.lock() {
137                    guard.remove(&coalesce_key);
138                }
139            }
140        });
141    }
142}