Skip to main content

statsig_rust/
statsig_runtime.rs

1use crate::statsig_global::StatsigGlobal;
2use crate::statsig_options::{
3    RuntimeThreadStartCallback, DEFAULT_SDK_RUNTIME_THREAD_COUNT, SDK_RUNTIME_THREAD_COUNT_ENV_VAR,
4};
5use crate::StatsigErr;
6use crate::{log_d, log_e};
7use futures::future::join_all;
8use parking_lot::Mutex;
9use std::collections::HashMap;
10use std::future::Future;
11use std::sync::atomic::AtomicBool;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::runtime::{Builder, Handle, Runtime};
15use tokio::sync::Notify;
16use tokio::task::JoinHandle;
17
18const TAG: &str = stringify!(StatsigRuntime);
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21struct TaskId {
22    tag: String,
23    tokio_id: tokio::task::Id,
24}
25
26pub struct StatsigRuntime {
27    spawned_tasks: Arc<Mutex<HashMap<TaskId, JoinHandle<()>>>>,
28    shutdown_notify: Arc<Notify>,
29    is_shutdown: Arc<AtomicBool>,
30    sdk_runtime_thread_count: Option<usize>,
31    runtime_thread_start_callback: Option<RuntimeThreadStartCallback>,
32    prefer_owned_runtime: bool,
33}
34
35impl StatsigRuntime {
36    #[must_use]
37    pub fn get_runtime() -> Arc<StatsigRuntime> {
38        Self::get_runtime_with_thread_count(None)
39    }
40
41    #[must_use]
42    pub fn get_runtime_with_thread_count(
43        sdk_runtime_thread_count: Option<usize>,
44    ) -> Arc<StatsigRuntime> {
45        Self::get_runtime_with_options(sdk_runtime_thread_count, None)
46    }
47
48    #[must_use]
49    pub fn get_runtime_with_options(
50        sdk_runtime_thread_count: Option<usize>,
51        runtime_thread_start_callback: Option<RuntimeThreadStartCallback>,
52    ) -> Arc<StatsigRuntime> {
53        create_runtime_if_required(
54            sdk_runtime_thread_count,
55            runtime_thread_start_callback.clone(),
56        );
57
58        // Tokio only exposes worker-start hooks on Runtime::Builder. A caller's current runtime
59        // cannot be retrofitted, so configuring the callback necessarily opts into the shared
60        // Statsig-owned runtime.
61        let prefer_owned_runtime =
62            sdk_runtime_thread_count.is_some() || runtime_thread_start_callback.is_some();
63
64        Arc::new(StatsigRuntime {
65            spawned_tasks: Arc::new(Mutex::new(HashMap::new())),
66            shutdown_notify: Arc::new(Notify::new()),
67            is_shutdown: Arc::new(AtomicBool::new(false)),
68            sdk_runtime_thread_count,
69            runtime_thread_start_callback,
70            prefer_owned_runtime,
71        })
72    }
73
74    pub fn get_handle(&self) -> Result<Handle, StatsigErr> {
75        if !self.prefer_owned_runtime {
76            if let Ok(handle) = Handle::try_current() {
77                return Ok(handle);
78            }
79        }
80
81        if let Some(handle) = self.get_owned_runtime_handle()? {
82            return Ok(handle);
83        }
84
85        Err(StatsigErr::ThreadFailure(
86            "No tokio runtime found".to_string(),
87        ))
88    }
89
90    fn get_owned_runtime_handle(&self) -> Result<Option<Handle>, StatsigErr> {
91        let global = StatsigGlobal::get();
92        let mut rt = global
93            .tokio_runtime
94            .try_lock_for(Duration::from_secs(5))
95            .ok_or_else(|| StatsigErr::LockFailure("Failed to lock tokio runtime".to_string()))?;
96        if rt.is_none() {
97            *rt = Some(Arc::new(create_new_runtime_with_options(
98                self.sdk_runtime_thread_count,
99                self.runtime_thread_start_callback.clone(),
100            )));
101        }
102        if let Some(rt) = rt.as_ref() {
103            return Ok(Some(rt.handle().clone()));
104        }
105
106        Ok(None)
107    }
108
109    pub fn get_num_active_tasks(&self) -> usize {
110        match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
111            Some(lock) => lock.len(),
112            None => {
113                log_e!(TAG, "Failed to lock spawned tasks for get_num_active_tasks");
114                0
115            }
116        }
117    }
118
119    pub fn shutdown(&self) {
120        self.shutdown_notify.notify_waiters();
121
122        match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
123            Some(mut lock) => {
124                for (_, task) in lock.drain() {
125                    task.abort();
126                }
127            }
128            None => {
129                log_e!(TAG, "Failed to lock spawned tasks for shutdown");
130            }
131        }
132    }
133
134    pub fn spawn<F, Fut>(&self, tag: &str, task: F) -> Result<tokio::task::Id, StatsigErr>
135    where
136        F: FnOnce(Arc<Notify>) -> Fut + Send + 'static,
137        Fut: Future<Output = ()> + Send + 'static,
138    {
139        let tag_string = tag.to_string();
140        let shutdown_notify = self.shutdown_notify.clone();
141        let spawned_tasks = self.spawned_tasks.clone();
142        let is_shutdown = self.is_shutdown.clone();
143
144        log_d!(TAG, "Spawning task {}", tag);
145
146        let handle = self.get_handle()?.spawn(async move {
147            if is_shutdown.load(std::sync::atomic::Ordering::Relaxed) {
148                return;
149            }
150
151            let task_id = tokio::task::id();
152            log_d!(TAG, "Executing task {}.{}", tag_string, task_id);
153            task(shutdown_notify).await;
154            remove_join_handle_with_id(spawned_tasks, tag_string, &task_id);
155        });
156
157        Ok(self.insert_join_handle(tag, handle))
158    }
159
160    pub async fn await_tasks_with_tag(&self, tag: &str) {
161        let mut handles = Vec::new();
162
163        match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
164            Some(mut lock) => {
165                let keys: Vec<TaskId> = lock.keys().cloned().collect();
166                for key in &keys {
167                    if key.tag == tag {
168                        let removed = if let Some(handle) = lock.remove(key) {
169                            handle
170                        } else {
171                            log_e!(TAG, "No running task found for tag {}", tag);
172                            continue;
173                        };
174
175                        handles.push(removed);
176                    }
177                }
178            }
179            None => {
180                log_e!(TAG, "Failed to lock spawned tasks for await_tasks_with_tag");
181                return;
182            }
183        };
184
185        join_all(handles).await;
186    }
187
188    pub async fn await_join_handle(
189        &self,
190        tag: &str,
191        handle_id: &tokio::task::Id,
192    ) -> Result<(), StatsigErr> {
193        let task_id = TaskId {
194            tag: tag.to_string(),
195            tokio_id: *handle_id,
196        };
197
198        let handle = match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
199            Some(mut lock) => match lock.remove(&task_id) {
200                Some(handle) => handle,
201                None => {
202                    return Err(StatsigErr::ThreadFailure(
203                        "No running task found".to_string(),
204                    ));
205                }
206            },
207            None => {
208                log_e!(TAG, "Failed to lock spawned tasks for await_join_handle");
209                return Err(StatsigErr::ThreadFailure(
210                    "Failed to lock spawned tasks".to_string(),
211                ));
212            }
213        };
214
215        handle
216            .await
217            .map_err(|e| StatsigErr::ThreadFailure(e.to_string()))?;
218
219        Ok(())
220    }
221
222    pub fn get_running_task_ids(&self) -> Vec<(String, String)> {
223        let tasks = match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
224            Some(lock) => lock,
225            None => {
226                log_e!(TAG, "Failed to lock spawned tasks for get_running_task_ids");
227                return Vec::new();
228            }
229        };
230
231        tasks
232            .keys()
233            .map(|key| (key.tag.clone(), key.tokio_id.to_string()))
234            .collect()
235    }
236
237    fn insert_join_handle(&self, tag: &str, handle: JoinHandle<()>) -> tokio::task::Id {
238        let handle_id = handle.id();
239        let task_id = TaskId {
240            tag: tag.to_string(),
241            tokio_id: handle_id,
242        };
243
244        match self.spawned_tasks.try_lock_for(Duration::from_secs(5)) {
245            Some(mut lock) => {
246                lock.insert(task_id, handle);
247            }
248            None => {
249                log_e!(TAG, "Failed to lock spawned tasks for insert_join_handle");
250            }
251        }
252
253        handle_id
254    }
255}
256
257pub fn create_new_runtime() -> Runtime {
258    create_new_runtime_with_thread_count(None)
259}
260
261pub fn create_new_runtime_with_thread_count(sdk_runtime_thread_count: Option<usize>) -> Runtime {
262    create_new_runtime_with_options(sdk_runtime_thread_count, None)
263}
264
265fn create_new_runtime_with_options(
266    sdk_runtime_thread_count: Option<usize>,
267    runtime_thread_start_callback: Option<RuntimeThreadStartCallback>,
268) -> Runtime {
269    let worker_threads = get_runtime_thread_count(sdk_runtime_thread_count);
270
271    #[cfg(not(target_family = "wasm"))]
272    {
273        let mut builder = Builder::new_multi_thread();
274        builder
275            .worker_threads(worker_threads)
276            .thread_name("statsig")
277            .enable_all();
278        if let Some(callback) = runtime_thread_start_callback {
279            builder.on_thread_start(move || callback());
280        }
281        builder.build().expect("Failed to create a tokio Runtime")
282    }
283
284    #[cfg(target_family = "wasm")]
285    return Builder::new_current_thread()
286        .thread_name("statsig")
287        .enable_all()
288        .build()
289        .expect("Failed to create a tokio Runtime (single-threaded for wasm");
290}
291
292fn get_runtime_thread_count(sdk_runtime_thread_count: Option<usize>) -> usize {
293    match sdk_runtime_thread_count {
294        Some(count) if count > 0 => count,
295        _ => std::env::var(SDK_RUNTIME_THREAD_COUNT_ENV_VAR)
296            .ok()
297            .and_then(|value| value.parse::<usize>().ok())
298            .filter(|count| *count > 0)
299            .unwrap_or(DEFAULT_SDK_RUNTIME_THREAD_COUNT),
300    }
301}
302
303fn remove_join_handle_with_id(
304    spawned_tasks: Arc<Mutex<HashMap<TaskId, JoinHandle<()>>>>,
305    tag: String,
306    handle_id: &tokio::task::Id,
307) {
308    let task_id = TaskId {
309        tag,
310        tokio_id: *handle_id,
311    };
312
313    match spawned_tasks.try_lock_for(Duration::from_secs(5)) {
314        Some(mut lock) => {
315            lock.remove(&task_id);
316        }
317        None => {
318            log_e!(
319                TAG,
320                "Failed to lock spawned tasks for remove_join_handle_with_id"
321            );
322        }
323    }
324}
325
326fn create_runtime_if_required(
327    sdk_runtime_thread_count: Option<usize>,
328    runtime_thread_start_callback: Option<RuntimeThreadStartCallback>,
329) {
330    if sdk_runtime_thread_count.is_none()
331        && runtime_thread_start_callback.is_none()
332        && Handle::try_current().is_ok()
333    {
334        log_d!(TAG, "External tokio runtime found");
335        return;
336    }
337
338    let global = StatsigGlobal::get();
339    let mut lock = global
340        .tokio_runtime
341        .try_lock_for(Duration::from_secs(5))
342        .expect("Failed to lock owned tokio runtime");
343
344    match lock.as_ref() {
345        Some(_) => {
346            log_d!(TAG, "Existing StatsigGlobal tokio runtime found");
347        }
348        None => {
349            log_d!(TAG, "Creating new tokio runtime for StatsigGlobal");
350            let rt = Arc::new(create_new_runtime_with_options(
351                sdk_runtime_thread_count,
352                runtime_thread_start_callback,
353            ));
354
355            lock.replace(rt);
356        }
357    };
358}
359
360impl Drop for StatsigRuntime {
361    fn drop(&mut self) {
362        self.shutdown();
363
364        // let opt_inner = match self.inner_runtime.lock() {
365        //     Ok(mut inner_runtime) => inner_runtime.take(),
366        //     Err(e) => {
367        //         log_e!(TAG, "Failed to lock inner runtime {}", e);
368        //         None
369        //     }
370        // };
371
372        // let inner = match opt_inner {
373        //     Some(inner) => inner,
374        //     None => {
375        //         log_d!(TAG, "Runtime owned by tokio");
376        //         return;
377        //     }
378        // };
379
380        // if Arc::strong_count(&inner) > 1 {
381        //     // Another instance is still using the Runtime, so we can't drop it
382        //     return;
383        // }
384
385        // if tokio::runtime::Handle::try_current().is_err() {
386        //     println!("Not inside the Tokio runtime. Will automatically drop(inner).");
387        //     // Not inside the Tokio runtime. Will automatically drop(inner).
388        //     return;
389        // }
390
391        // log_w!(TAG, "Attempt to shutdown runtime from inside runtime");
392        // std::thread::spawn(move || {
393        //     println!("Dropping inner runtime from outside the Tokio runtime");
394        //     // We should not drop from inside the runtime, but in the odd case we do,
395        //     // moving inner to a new thread will prevent a panic
396        //     drop(inner);
397        // });
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    #[cfg(not(target_family = "wasm"))]
407    fn creates_runtime_with_configured_worker_count() {
408        let runtime = create_new_runtime_with_thread_count(Some(2));
409
410        assert_eq!(runtime.metrics().num_workers(), 2);
411    }
412
413    #[test]
414    #[cfg(not(target_family = "wasm"))]
415    fn calls_thread_start_callback_for_each_worker() {
416        use std::sync::mpsc;
417
418        let (sender, receiver) = mpsc::channel();
419        let callback = Arc::new(move || {
420            sender
421                .send(std::thread::current().name().map(str::to_owned))
422                .expect("thread start callback receiver should still exist");
423        });
424
425        let runtime = create_new_runtime_with_options(Some(2), Some(callback));
426        let thread_names = (0..2)
427            .map(|_| {
428                receiver
429                    .recv_timeout(Duration::from_secs(5))
430                    .expect("thread start callback should run for every worker")
431            })
432            .collect::<Vec<_>>();
433
434        assert_eq!(thread_names, vec![Some("statsig".to_string()); 2]);
435        drop(runtime);
436    }
437}