Skip to content

Commit 4ceea3e

Browse files
committed
fix: block analytics thread between flushes instead of busy-waiting
1 parent a60bb7d commit 4ceea3e

1 file changed

Lines changed: 77 additions & 7 deletions

File tree

src/flagsmith/analytics.rs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,27 +38,46 @@ impl AnalyticsProcessor {
3838
.spawn(move || {
3939
let mut last_flushed = chrono::Utc::now();
4040
loop {
41-
let data = rx.try_recv();
42-
let mut analytics_data = analytics_data_locked.write().unwrap();
43-
match data {
41+
// Block until either a feature is tracked or the next flush is due
42+
let elapsed = (chrono::Utc::now() - last_flushed)
43+
.num_milliseconds()
44+
.max(0) as u64;
45+
// `saturating_sub` covers a flush that overran its window
46+
let data = rx.recv_timeout(std::time::Duration::from_millis(
47+
timer.saturating_sub(elapsed),
48+
));
49+
50+
let disconnected = match data {
4451
// Update the analytics data with feature_id received
4552
Ok(feature_name) => {
53+
let mut analytics_data = analytics_data_locked.write().unwrap();
4654
analytics_data
4755
.entry(feature_name)
4856
.and_modify(|e| *e += 1)
4957
.or_insert(1);
58+
false
5059
}
51-
Err(flume::TryRecvError::Empty) => {}
52-
Err(flume::TryRecvError::Disconnected) => {
60+
Err(flume::RecvTimeoutError::Timeout) => false,
61+
Err(flume::RecvTimeoutError::Disconnected) => {
5362
debug!("Shutting down analytics thread ");
54-
break;
63+
true
5564
}
5665
};
57-
if (chrono::Utc::now() - last_flushed).num_milliseconds() > timer as i64 {
66+
67+
// Flush when due, or on shutdown
68+
let flush_due = (chrono::Utc::now() - last_flushed).num_milliseconds()
69+
> timer as i64
70+
|| disconnected;
71+
if flush_due {
72+
let mut analytics_data = analytics_data_locked.write().unwrap();
5873
flush(&client, &analytics_data, &analytics_endpoint);
5974
analytics_data.clear();
6075
last_flushed = chrono::Utc::now();
6176
}
77+
78+
if disconnected {
79+
break;
80+
}
6281
}
6382
})
6483
.expect("Failed to start analytics thread");
@@ -94,6 +113,57 @@ mod tests {
94113
use httpmock::prelude::*;
95114
use reqwest::header;
96115

116+
#[test]
117+
fn dropping_processor_flushes_pending_data_and_shuts_down() {
118+
// Given
119+
let feature_1 = "feature_1";
120+
let server = MockServer::start();
121+
let shutdown_flush_mock = server.mock(|when, then| {
122+
when.method(POST)
123+
.path("/api/v1/analytics/flags/")
124+
.json_body(serde_json::json!({feature_1: 2}));
125+
then.status(200);
126+
});
127+
128+
let processor = AnalyticsProcessor::new(
129+
server.url("/api/v1/"),
130+
header::HeaderMap::new(),
131+
std::time::Duration::from_secs(10),
132+
Some(60_000), // deliberately never due during this test
133+
);
134+
processor.track_feature(feature_1);
135+
processor.track_feature(feature_1);
136+
thread::sleep(std::time::Duration::from_millis(50));
137+
138+
// Nothing should have been sent yet: the timer is 60s away.
139+
shutdown_flush_mock.assert_hits(0);
140+
141+
// When
142+
drop(processor);
143+
thread::sleep(std::time::Duration::from_millis(50));
144+
145+
// Then
146+
shutdown_flush_mock.assert_hits(1);
147+
}
148+
149+
#[test]
150+
fn flush_survives_unreachable_endpoint() {
151+
// Given
152+
let client = reqwest::blocking::Client::builder()
153+
.timeout(std::time::Duration::from_millis(100))
154+
.build()
155+
.unwrap();
156+
let mut analytics_data = HashMap::new();
157+
analytics_data.insert("feature_1".to_string(), 1);
158+
159+
// When / Then
160+
flush(
161+
&client,
162+
&analytics_data,
163+
"http://127.0.0.1:1/analytics/flags/",
164+
);
165+
}
166+
97167
#[test]
98168
fn track_feature_updates_analytics_data() {
99169
// Given

0 commit comments

Comments
 (0)