rovenue/attributes/
dispatcher.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::attributes::buffer::AttributeBuffer;
5use crate::error::RovenueResult;
6use crate::polling::scheduler::PollingScheduler;
7use crate::transport::http_client::HttpClient;
8
9/// Most mutations one POST carries.
10const BATCH_LIMIT: usize = 200;
11
12pub struct AttributeDispatcher {
13    buffer: Arc<AttributeBuffer>,
14    http: Arc<HttpClient>,
15}
16
17impl AttributeDispatcher {
18    pub fn new(buffer: Arc<AttributeBuffer>, http: Arc<HttpClient>) -> Self {
19        Self { buffer, http }
20    }
21
22    /// List → POST → delete-on-success, oldest first, each mutation under the
23    /// identity it was made under (a log out in between changes nothing).
24    /// One POST carries the leading run of up to 200 rows that share a
25    /// `rovenue_id`, coalesced within that run only; runs are sent in order
26    /// until the queue is empty or a POST fails. Returns the number of
27    /// distinct keys synced. On a retryable error the run stays queued.
28    pub fn flush_once(&self) -> RovenueResult<usize> {
29        let mut synced = 0;
30        loop {
31            let rows = self.buffer.list(BATCH_LIMIT)?;
32            let Some(first) = rows.first() else {
33                return Ok(synced);
34            };
35            let sub_id = first.rovenue_id.clone();
36            let run: Vec<_> = rows
37                .into_iter()
38                .take_while(|r| r.rovenue_id == sub_id)
39                .collect();
40            let ids: Vec<i64> = run.iter().map(|r| r.id).collect();
41            // Unattributable (never expected: the identity is minted at
42            // configure). Kept, it would block every later mutation.
43            if sub_id.is_empty() {
44                self.buffer.delete(&ids)?;
45                continue;
46            }
47            // Coalesce in id ASC order: later set of the same key wins.
48            let mut map = serde_json::Map::new();
49            for r in &run {
50                let v = match &r.value {
51                    Some(s) => serde_json::Value::String(s.clone()),
52                    None => serde_json::Value::Null,
53                };
54                map.insert(r.key.clone(), v);
55            }
56            // Post first; only delete if it succeeded (durable). A permanent
57            // rejection (4xx other than 429) is the one exception: retaining a
58            // run the server will never accept re-sends it every tick and
59            // blocks newer mutations forever, so drop it instead. An
60            // unresolvable subscriber is not a rejection of the payload: the
61            // project owner repairing the merge chain makes the same run
62            // deliverable, so it is kept.
63            if let Err(err) = self.http.post_attributes(&sub_id, &map) {
64                if !err.retryable
65                    && err.kind != crate::error::ErrorKind::SubscriberUnresolvable
66                    && matches!(err.http_status, Some(s) if (400..500).contains(&s))
67                {
68                    self.buffer.delete(&ids)?;
69                }
70                return Err(err);
71            }
72            self.buffer.delete(&ids)?;
73            // Distinct keys synced (coalesced), not raw queue rows drained.
74            synced += map.len();
75        }
76    }
77
78    /// Register the periodic flush on the scheduler (30s, same cadence
79    /// as sessions; only fires while foregrounded).
80    pub fn start(self: Arc<Self>, scheduler: &PollingScheduler) {
81        let me = Arc::clone(&self);
82        scheduler.register("attributes", Duration::from_secs(30), move || {
83            let _ = me.flush_once();
84        });
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::attributes::buffer::AttributeBuffer;
92    use crate::cache::store::CacheStore;
93    use std::sync::Arc;
94    use std::time::Duration;
95
96    // An HttpClient pointed at an unroutable address with a single, short
97    // attempt — any actual request fails fast with a network error. Used by
98    // the no-op and network-error cases (the no-op case never sends).
99    fn http_unreachable() -> Arc<HttpClient> {
100        Arc::new(
101            HttpClient::new("http://127.0.0.1:1".to_string(), "pk_test_abc".into())
102                .with_max_attempts(1)
103                .with_min_backoff(Duration::from_millis(1))
104                .with_request_timeout(Duration::from_millis(200)),
105        )
106    }
107
108    #[test]
109    fn flush_drops_unattributable_rows_without_sending() {
110        let store = Arc::new(CacheStore::open_in_memory().unwrap());
111        let buf = Arc::new(AttributeBuffer::new(Arc::clone(&store)));
112        buf.set("", "$email", Some("a@b.com")).unwrap();
113        // http would fail if called; an empty stamp is never sent.
114        let dispatcher = AttributeDispatcher::new(Arc::clone(&buf), http_unreachable());
115        assert_eq!(dispatcher.flush_once().unwrap(), 0);
116        assert_eq!(buf.list(100).unwrap().len(), 0);
117    }
118
119    #[test]
120    fn flush_deletes_only_on_success() {
121        let mut server = mockito::Server::new();
122        let m = server
123            .mock("POST", "/v1/me/attributes")
124            .match_header("x-rovenue-app-user-id", "rov_x")
125            .match_body(mockito::Matcher::JsonString(
126                r#"{"attributes":{"$email":"a@b.com","country":null}}"#.into(),
127            ))
128            .with_status(200)
129            .with_body(r#"{"data":{"ok":true}}"#)
130            .create();
131
132        let store = Arc::new(CacheStore::open_in_memory().unwrap());
133        let buf = Arc::new(AttributeBuffer::new(Arc::clone(&store)));
134        buf.set("rov_x", "$email", Some("a@b.com")).unwrap();
135        buf.set("rov_x", "country", None).unwrap();
136        let http = Arc::new(
137            HttpClient::new(server.url(), "pk_test_abc".into())
138                .with_max_attempts(1)
139                .with_request_timeout(Duration::from_millis(500)),
140        );
141        let dispatcher = AttributeDispatcher::new(Arc::clone(&buf), http);
142        assert_eq!(dispatcher.flush_once().unwrap(), 2);
143        assert_eq!(buf.list(100).unwrap().len(), 0);
144        m.assert();
145    }
146
147    /// Same poison-pill guard as the session dispatcher: a queue the server
148    /// permanently rejects (4xx other than 429) must be dropped, or every
149    /// future flush re-sends it and newer mutations never sync.
150    #[test]
151    fn flush_discards_queue_on_permanent_4xx() {
152        let mut server = mockito::Server::new();
153        let _m = server
154            .mock("POST", "/v1/me/attributes")
155            .with_status(400)
156            .with_body(r#"{"error":{"code":"VALIDATION_ERROR"}}"#)
157            .create();
158
159        let store = Arc::new(CacheStore::open_in_memory().unwrap());
160        let buf = Arc::new(AttributeBuffer::new(Arc::clone(&store)));
161        buf.set("rov_x", "$email", Some("a@b.com")).unwrap();
162        let http = Arc::new(
163            HttpClient::new(server.url(), "pk_test_abc".into())
164                .with_max_attempts(1)
165                .with_request_timeout(Duration::from_millis(500)),
166        );
167        let dispatcher = AttributeDispatcher::new(Arc::clone(&buf), http);
168        assert!(dispatcher.flush_once().is_err(), "a 400 must still surface");
169        assert_eq!(
170            buf.list(100).unwrap().len(),
171            0,
172            "a 4xx-rejected queue must be dropped, not retried forever"
173        );
174    }
175
176    #[test]
177    fn flush_keeps_queue_on_network_error() {
178        let store = Arc::new(CacheStore::open_in_memory().unwrap());
179        let buf = Arc::new(AttributeBuffer::new(Arc::clone(&store)));
180        buf.set("rov_x", "$email", Some("a@b.com")).unwrap();
181        let dispatcher = AttributeDispatcher::new(Arc::clone(&buf), http_unreachable());
182        assert!(dispatcher.flush_once().is_err());
183        // NOT deleted — durable retry
184        assert_eq!(buf.list(100).unwrap().len(), 1);
185    }
186}