Skip to main content

cloudillo_types/
worker.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Worker pool. Handles synchronous tasks with 3 priority levels, configurable worker threads.
5
6use flume::{Receiver, Sender};
7use futures::channel::oneshot;
8use std::{sync::Arc, thread};
9
10use crate::prelude::*;
11
12#[derive(Clone, Copy, Debug)]
13pub enum Priority {
14	High,
15	Medium,
16	Low,
17}
18
19#[derive(Debug)]
20pub struct WorkerPool {
21	high: Sender<Box<dyn FnOnce() + Send>>,
22	med: Sender<Box<dyn FnOnce() + Send>>,
23	low: Sender<Box<dyn FnOnce() + Send>>,
24}
25
26impl WorkerPool {
27	pub fn new(n1: usize, n2: usize, n3: usize) -> Self {
28		Self::build(n1, n2, n3).0
29	}
30
31	/// `new`, plus a witness whose strong count is the number of live worker threads —
32	/// each worker holds a clone until `worker_loop` returns.
33	fn build(n1: usize, n2: usize, n3: usize) -> (Self, std::sync::Weak<()>) {
34		let (high, rx_high) = flume::unbounded();
35		let (med, rx_med) = flume::unbounded();
36		let (low, rx_low) = flume::unbounded();
37
38		let rx_high = Arc::new(rx_high);
39		let rx_med = Arc::new(rx_med);
40		let rx_low = Arc::new(rx_low);
41
42		let alive = Arc::new(());
43		let witness = Arc::downgrade(&alive);
44
45		// Workers dedicated to High only
46		for _ in 0..n1 {
47			let rx_high = Arc::clone(&rx_high);
48			let alive = Arc::clone(&alive);
49			thread::spawn(move || {
50				worker_loop(&[rx_high]);
51				drop(alive);
52			});
53		}
54
55		// Workers for High + Medium
56		for _ in 0..n2 {
57			let rx_high = Arc::clone(&rx_high);
58			let rx_med = Arc::clone(&rx_med);
59			let alive = Arc::clone(&alive);
60			thread::spawn(move || {
61				worker_loop(&[rx_high, rx_med]);
62				drop(alive);
63			});
64		}
65
66		// Workers for High + Medium + Low
67		for _ in 0..n3 {
68			let rx_high = Arc::clone(&rx_high);
69			let rx_med = Arc::clone(&rx_med);
70			let rx_low = Arc::clone(&rx_low);
71			let alive = Arc::clone(&alive);
72			thread::spawn(move || {
73				worker_loop(&[rx_high, rx_med, rx_low]);
74				drop(alive);
75			});
76		}
77
78		drop(alive);
79		(Self { high, med, low }, witness)
80	}
81
82	/// Submit a closure with arguments → returns a Future for the result
83	pub fn spawn<F, T>(
84		&self,
85		priority: Priority,
86		f: F,
87	) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
88	where
89		F: FnOnce() -> T + Send + 'static,
90		T: Send + 'static,
91	{
92		let (res_tx, res_rx) = oneshot::channel();
93
94		let job = Box::new(move || {
95			let result = f();
96			let _ = res_tx.send(result);
97		});
98
99		match priority {
100			Priority::High => {
101				if self.high.send(job).is_err() {
102					error!("Failed to send job to high priority worker queue");
103				}
104			}
105			Priority::Medium => {
106				if self.med.send(job).is_err() {
107					error!("Failed to send job to medium priority worker queue");
108				}
109			}
110			Priority::Low => {
111				if self.low.send(job).is_err() {
112					error!("Failed to send job to low priority worker queue");
113				}
114			}
115		}
116
117		async move {
118			res_rx.await.map_err(|_| {
119				error!("Worker dropped result channel (task may have panicked)");
120				Error::Internal("worker task failed".into())
121			})
122		}
123	}
124
125	pub fn run<F, T>(&self, f: F) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
126	where
127		F: FnOnce() -> T + Send + 'static,
128		T: Send + 'static,
129	{
130		let (res_tx, res_rx) = oneshot::channel();
131
132		let job = Box::new(move || {
133			let result = f();
134			let _ignore = res_tx.send(result);
135		});
136
137		if self.med.send(job).is_err() {
138			error!("Failed to send job to medium priority worker queue");
139		}
140
141		async move {
142			res_rx.await.map_err(|_| {
143				error!("Worker dropped result channel (task may have panicked)");
144				Error::Internal("worker task failed".into())
145			})
146		}
147	}
148
149	pub fn run_immed<F, T>(
150		&self,
151		f: F,
152	) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
153	where
154		F: FnOnce() -> T + Send + 'static,
155		T: Send + 'static,
156	{
157		let (res_tx, res_rx) = oneshot::channel();
158
159		let job = Box::new(move || {
160			let result = f();
161			let _ignore = res_tx.send(result);
162		});
163
164		if self.high.send(job).is_err() {
165			error!("Failed to send job to high priority worker queue");
166		}
167
168		async move {
169			res_rx.await.map_err(|_| {
170				error!("Worker dropped result channel (task may have panicked)");
171				Error::Internal("worker task failed".into())
172			})
173		}
174	}
175
176	pub fn run_slow<F, T>(&self, f: F) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
177	where
178		F: FnOnce() -> T + Send + 'static,
179		T: Send + 'static,
180	{
181		let (res_tx, res_rx) = oneshot::channel();
182
183		let job = Box::new(move || {
184			let result = f();
185			let _ignore = res_tx.send(result);
186		});
187
188		if self.low.send(job).is_err() {
189			error!("Failed to send job to low priority worker queue");
190		}
191
192		async move {
193			res_rx.await.map_err(|_| {
194				error!("Worker dropped result channel (task may have panicked)");
195				Error::Internal("worker task failed".into())
196			})
197		}
198	}
199
200	/// Like `run`, but flattens `ClResult<ClResult<T>>` into `ClResult<T>`.
201	/// Use when the closure itself returns `ClResult<T>`.
202	pub fn try_run<F, T>(&self, f: F) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
203	where
204		F: FnOnce() -> ClResult<T> + Send + 'static,
205		T: Send + 'static,
206	{
207		let fut = self.run(f);
208		async move { fut.await? }
209	}
210
211	/// Like `run_immed`, but flattens `ClResult<ClResult<T>>` into `ClResult<T>`.
212	/// Use when the closure itself returns `ClResult<T>`.
213	pub fn try_run_immed<F, T>(
214		&self,
215		f: F,
216	) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
217	where
218		F: FnOnce() -> ClResult<T> + Send + 'static,
219		T: Send + 'static,
220	{
221		let fut = self.run_immed(f);
222		async move { fut.await? }
223	}
224
225	/// Like `run_slow`, but flattens `ClResult<ClResult<T>>` into `ClResult<T>`.
226	/// Use when the closure itself returns `ClResult<T>`.
227	pub fn try_run_slow<F, T>(
228		&self,
229		f: F,
230	) -> impl std::future::Future<Output = ClResult<T>> + use<F, T>
231	where
232		F: FnOnce() -> ClResult<T> + Send + 'static,
233		T: Send + 'static,
234	{
235		let fut = self.run_slow(f);
236		async move { fut.await? }
237	}
238}
239
240type JobQueue = Arc<Receiver<Box<dyn FnOnce() + Send>>>;
241
242fn run_job(job: Box<dyn FnOnce() + Send>) {
243	if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)) {
244		error!("Worker thread caught panic: {:?}", e);
245	}
246}
247
248fn worker_loop(queues: &[JobQueue]) {
249	// Queues still worth waiting on. flume reports a disconnected channel as *ready*, so a
250	// dead queue left in the wait set turns `Selector::wait` into a busy spin that outlives
251	// the pool. Drop each one once its sender is gone and its buffer drained; stop when the
252	// set empties.
253	let mut live: Vec<&JobQueue> = queues.iter().collect();
254
255	loop {
256		// Try higher-priority queues first (non-blocking)
257		let mut job = None;
258		for rx in &live {
259			if let Ok(j) = rx.try_recv() {
260				job = Some(j);
261				break;
262			}
263		}
264		if let Some(job) = job {
265			run_job(job);
266			continue;
267		}
268
269		live.retain(|rx| !rx.is_disconnected() || !rx.is_empty());
270		if live.is_empty() {
271			break;
272		}
273
274		// Wait for next job
275		let mut selector = flume::Selector::new();
276		for &rx in &live {
277			selector = selector.recv(rx, |res| res);
278		}
279		match selector.wait() {
280			Ok(job) => run_job(job),
281			// A sender dropped while we waited. Loop: the `try_recv` sweep drains what is
282			// left and `retain` prunes the dead queue.
283			Err(flume::RecvError::Disconnected) => (),
284		}
285	}
286}
287
288#[cfg(test)]
289mod tests {
290	#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
291
292	use super::*;
293	use std::sync::{
294		Weak,
295		atomic::{AtomicBool, Ordering},
296	};
297	use std::time::{Duration, Instant};
298
299	/// Poll `alive` until every worker thread has returned, or the deadline expires.
300	fn wait_for_exit(alive: &Weak<()>, timeout: Duration) -> usize {
301		let deadline = Instant::now() + timeout;
302		loop {
303			let count = alive.strong_count();
304			if count == 0 || Instant::now() >= deadline {
305				return count;
306			}
307			thread::sleep(Duration::from_millis(5));
308		}
309	}
310
311	#[test]
312	fn workers_exit_when_pool_is_dropped() {
313		let (pool, alive) = WorkerPool::build(1, 1, 1);
314		assert_eq!(alive.strong_count(), 3, "expected 3 worker threads");
315
316		drop(pool);
317
318		assert_eq!(
319			wait_for_exit(&alive, Duration::from_secs(2)),
320			0,
321			"worker threads did not exit after the pool was dropped"
322		);
323	}
324
325	#[test]
326	fn queued_jobs_run_before_workers_exit() {
327		// A single worker serving [high, med], so the two jobs below are strictly ordered.
328		let (pool, alive) = WorkerPool::build(0, 1, 0);
329
330		// Occupy the worker so the second job is still buffered when the pool drops.
331		let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
332		let blocker = pool.run(move || {
333			let _ignore = release_rx.recv();
334		});
335
336		let ran = Arc::new(AtomicBool::new(false));
337		let flag = Arc::clone(&ran);
338		let queued = pool.run(move || flag.store(true, Ordering::SeqCst));
339
340		drop(pool);
341		drop(blocker);
342		drop(queued);
343
344		// Let the worker go; it must drain the buffered job before exiting.
345		let _ignore = release_tx.send(());
346		drop(release_tx);
347
348		assert_eq!(
349			wait_for_exit(&alive, Duration::from_secs(2)),
350			0,
351			"worker thread did not exit after the pool was dropped"
352		);
353		assert!(ran.load(Ordering::SeqCst), "job queued before the pool dropped was never run");
354	}
355}
356
357// vim: ts=4