@@ -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