openzeppelin_relayer/bootstrap/
runtime.rs1use std::env;
14use std::thread::available_parallelism;
15use std::time::Duration;
16
17use tracing::{info, warn};
18
19use crate::queues::WorkerHandle;
20
21const ENV_TOKIO_WORKER_THREADS: &str = "TOKIO_WORKER_THREADS";
22const ENV_ACTIX_WORKERS: &str = "ACTIX_WORKERS";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct RuntimeConfig {
27 pub vcpu: usize,
29 pub actix_workers: usize,
31 pub tokio_worker_threads: usize,
33}
34
35impl RuntimeConfig {
36 pub fn from_env() -> Self {
38 Self::resolve(
39 detect_vcpu(),
40 read_usize_env(ENV_ACTIX_WORKERS),
41 read_usize_env(ENV_TOKIO_WORKER_THREADS),
42 )
43 }
44
45 fn resolve(vcpu: usize, actix_override: Option<usize>, tokio_override: Option<usize>) -> Self {
54 let vcpu = vcpu.max(1);
55 let actix_workers = actix_override
56 .filter(|n| *n > 0)
57 .unwrap_or_else(|| (vcpu / 2).max(1));
58 let tokio_worker_threads = tokio_override
59 .filter(|n| *n > 0)
60 .unwrap_or_else(|| vcpu.saturating_sub(actix_workers).max(1));
61 Self {
62 vcpu,
63 actix_workers,
64 tokio_worker_threads,
65 }
66 }
67
68 pub fn total_threads(&self) -> usize {
70 self.actix_workers + self.tokio_worker_threads
71 }
72
73 pub fn over_budget(&self) -> bool {
75 self.total_threads() > self.vcpu
76 }
77
78 pub fn log_startup(&self) {
80 info!(
81 vcpu = self.vcpu,
82 actix_workers = self.actix_workers,
83 tokio_worker_threads = self.tokio_worker_threads,
84 "resolved runtime worker budget"
85 );
86 if self.over_budget() {
87 warn!(
88 vcpu = self.vcpu,
89 actix_workers = self.actix_workers,
90 tokio_worker_threads = self.tokio_worker_threads,
91 total = self.total_threads(),
92 "worker budget exceeds vCPU quota; threads will contend / CFS-throttle. \
93 Pin TOKIO_WORKER_THREADS and ACTIX_WORKERS to fit the container CPU quota."
94 );
95 }
96 }
97}
98
99pub async fn drain_worker_handles(handles: Vec<WorkerHandle>, timeout: Duration) -> bool {
107 let drain = async {
108 for handle in handles {
109 if let WorkerHandle::Tokio(join_handle) = handle {
110 if let Err(e) = join_handle.await {
111 warn!(error = %e, "pipeline worker did not join cleanly");
112 }
113 }
114 }
115 };
116 tokio::time::timeout(timeout, drain).await.is_ok()
117}
118
119fn detect_vcpu() -> usize {
120 available_parallelism().map(|n| n.get()).unwrap_or(1)
121}
122
123fn read_usize_env(key: &str) -> Option<usize> {
124 env::var(key)
125 .ok()
126 .and_then(|v| v.trim().parse::<usize>().ok())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn defaults_split_vcpu_between_runtimes() {
135 let cfg = RuntimeConfig::resolve(8, None, None);
136 assert_eq!(cfg.vcpu, 8);
137 assert_eq!(cfg.actix_workers, 4); assert_eq!(cfg.tokio_worker_threads, 4); assert!(!cfg.over_budget());
140 }
141
142 #[test]
143 fn single_vcpu_never_yields_zero_threads() {
144 let cfg = RuntimeConfig::resolve(1, None, None);
145 assert_eq!(cfg.actix_workers, 1); assert_eq!(cfg.tokio_worker_threads, 1); assert!(cfg.over_budget());
149 }
150
151 #[test]
152 fn explicit_overrides_are_respected() {
153 let cfg = RuntimeConfig::resolve(8, Some(2), Some(4));
154 assert_eq!(cfg.actix_workers, 2);
155 assert_eq!(cfg.tokio_worker_threads, 4);
156 assert_eq!(cfg.total_threads(), 6);
157 assert!(!cfg.over_budget());
158 }
159
160 #[test]
161 fn tokio_default_accounts_for_actix_override() {
162 let cfg = RuntimeConfig::resolve(8, Some(6), None);
164 assert_eq!(cfg.actix_workers, 6);
165 assert_eq!(cfg.tokio_worker_threads, 2); }
167
168 #[test]
169 fn zero_override_is_ignored() {
170 let cfg = RuntimeConfig::resolve(4, Some(0), Some(0));
171 assert_eq!(cfg.actix_workers, 2); assert_eq!(cfg.tokio_worker_threads, 2);
173 }
174
175 #[test]
176 fn over_budget_detected_when_sum_exceeds_quota() {
177 let cfg = RuntimeConfig::resolve(4, Some(4), Some(4));
178 assert_eq!(cfg.total_threads(), 8);
179 assert!(cfg.over_budget());
180 }
181
182 #[tokio::test]
183 async fn drain_completes_when_workers_finish_within_window() {
184 let handles: Vec<WorkerHandle> = (0..2)
186 .map(|_| {
187 WorkerHandle::Tokio(tokio::spawn(async {
188 tokio::time::sleep(Duration::from_millis(20)).await;
189 }))
190 })
191 .collect();
192
193 let drained = drain_worker_handles(handles, Duration::from_secs(5)).await;
194 assert!(drained, "workers should drain within the window");
195 }
196
197 #[tokio::test]
198 async fn drain_times_out_when_a_worker_hangs() {
199 let handles = vec![WorkerHandle::Tokio(tokio::spawn(async {
201 futures::future::pending::<()>().await;
202 }))];
203
204 let drained = drain_worker_handles(handles, Duration::from_millis(150)).await;
205 assert!(!drained, "drain must time out when a worker hangs");
206 }
207}