Skip to main content

rlx_runtime/
worker_pool.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Worker pool with isolation primitives (plan #36).
6//!
7//! Borrowed from MAX's `serve/worker_interface/`. The serving
8//! pattern: engines run in workers (eventually subprocesses); a
9//! main router forwards requests via IPC. One worker crashing
10//! doesn't take the server down.
11//!
12//! This module ships the in-process layer (testable, deterministic)
13//! plus the trait surface that a future `SubprocessWorker` will
14//! implement. The IPC plumbing (stdin/stdout JSON-lines, recovery
15//! on crash) is intentionally out of scope until a serving binary
16//! consumes it; we'd rather build it once against a real consumer
17//! than build it twice.
18
19use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
20use std::sync::{Arc, Mutex};
21
22/// Stable worker identifier.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct WorkerId(pub u32);
25
26#[derive(Debug, Clone, Copy)]
27pub struct WorkerHealth {
28    /// Outstanding requests this worker is processing.
29    pub in_flight: u32,
30    /// Lifetime requests handled (successful + errored).
31    pub completed: u64,
32    /// Lifetime requests that errored.
33    pub errored: u64,
34}
35
36/// Trait every worker implements. `Req` and `Resp` are
37/// caller-defined; the future subprocess flavour will use a
38/// serde-friendly wire type as both parameters.
39pub trait Worker<Req, Resp>: Send + Sync {
40    fn id(&self) -> WorkerId;
41    fn health(&self) -> WorkerHealth;
42    /// Block until this request finishes. Errors propagate the
43    /// engine's failure mode without crashing the worker.
44    fn dispatch(&self, req: Req) -> Result<Resp, WorkerError>;
45}
46
47#[derive(Debug, Clone)]
48pub enum WorkerError {
49    /// The handler returned a domain error (request was bad,
50    /// model rejected it, etc.). Worker stays healthy.
51    Domain { reason: String },
52    /// The worker itself failed (panic, OOM, lost subprocess).
53    /// Pool will route around it on the next request.
54    WorkerCrash { reason: String },
55}
56
57impl std::fmt::Display for WorkerError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Domain { reason } => write!(f, "domain error: {reason}"),
61            Self::WorkerCrash { reason } => write!(f, "worker crash: {reason}"),
62        }
63    }
64}
65
66impl std::error::Error for WorkerError {}
67
68/// In-process worker — runs the handler closure inline. Useful
69/// for tests and for single-process serving. Tracks in-flight /
70/// completed / errored counts atomically.
71pub struct InProcessWorker<Req, Resp, F>
72where
73    F: Fn(Req) -> Result<Resp, WorkerError> + Send + Sync,
74{
75    id: WorkerId,
76    handler: F,
77    in_flight: AtomicU32,
78    completed: AtomicU64,
79    errored: AtomicU64,
80    // PhantomData<fn() -> _> is always Send + Sync regardless
81    // of T's bounds — we don't actually own a Req or Resp.
82    _p: std::marker::PhantomData<fn() -> (Req, Resp)>,
83}
84
85impl<Req, Resp, F> InProcessWorker<Req, Resp, F>
86where
87    F: Fn(Req) -> Result<Resp, WorkerError> + Send + Sync,
88{
89    pub fn new(id: WorkerId, handler: F) -> Self {
90        Self {
91            id,
92            handler,
93            in_flight: AtomicU32::new(0),
94            completed: AtomicU64::new(0),
95            errored: AtomicU64::new(0),
96            _p: std::marker::PhantomData,
97        }
98    }
99}
100
101impl<Req, Resp, F> Worker<Req, Resp> for InProcessWorker<Req, Resp, F>
102where
103    Req: Send,
104    Resp: Send,
105    F: Fn(Req) -> Result<Resp, WorkerError> + Send + Sync,
106{
107    fn id(&self) -> WorkerId {
108        self.id
109    }
110
111    fn health(&self) -> WorkerHealth {
112        WorkerHealth {
113            in_flight: self.in_flight.load(Ordering::Relaxed),
114            completed: self.completed.load(Ordering::Relaxed),
115            errored: self.errored.load(Ordering::Relaxed),
116        }
117    }
118
119    fn dispatch(&self, req: Req) -> Result<Resp, WorkerError> {
120        self.in_flight.fetch_add(1, Ordering::Relaxed);
121        let result = (self.handler)(req);
122        self.in_flight.fetch_sub(1, Ordering::Relaxed);
123        self.completed.fetch_add(1, Ordering::Relaxed);
124        if result.is_err() {
125            self.errored.fetch_add(1, Ordering::Relaxed);
126        }
127        result
128    }
129}
130
131/// Pool dispatch policy.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum DispatchPolicy {
134    /// Round-robin: deterministic, ignores load.
135    RoundRobin,
136    /// Least-loaded: pick the worker with the fewest in-flight
137    /// requests; ties broken by `id`.
138    LeastLoaded,
139}
140
141/// Pool of workers. Generic over `(Req, Resp)`; works with the
142/// `Worker` trait directly.
143pub struct WorkerPool<Req, Resp> {
144    workers: Vec<Arc<dyn Worker<Req, Resp>>>,
145    next_rr: Mutex<usize>,
146    pub policy: DispatchPolicy,
147}
148
149impl<Req, Resp> WorkerPool<Req, Resp> {
150    pub fn new(policy: DispatchPolicy) -> Self {
151        Self {
152            workers: Vec::new(),
153            next_rr: Mutex::new(0),
154            policy,
155        }
156    }
157
158    pub fn add(&mut self, worker: Arc<dyn Worker<Req, Resp>>) {
159        self.workers.push(worker);
160    }
161
162    pub fn len(&self) -> usize {
163        self.workers.len()
164    }
165    pub fn is_empty(&self) -> bool {
166        self.workers.is_empty()
167    }
168
169    /// Pick a worker per `policy`.
170    pub fn select(&self) -> Option<&Arc<dyn Worker<Req, Resp>>> {
171        if self.workers.is_empty() {
172            return None;
173        }
174        match self.policy {
175            DispatchPolicy::RoundRobin => {
176                let mut rr = self.next_rr.lock().unwrap();
177                let pick = *rr % self.workers.len();
178                *rr = (*rr + 1) % self.workers.len();
179                Some(&self.workers[pick])
180            }
181            DispatchPolicy::LeastLoaded => self
182                .workers
183                .iter()
184                .min_by_key(|w| (w.health().in_flight, w.id().0)),
185        }
186    }
187
188    /// Dispatch a request through the chosen worker.
189    pub fn dispatch(&self, req: Req) -> Result<Resp, WorkerError> {
190        match self.select() {
191            Some(w) => w.dispatch(req),
192            None => Err(WorkerError::WorkerCrash {
193                reason: "no workers available".into(),
194            }),
195        }
196    }
197
198    /// Snapshot of every worker's health.
199    pub fn health(&self) -> Vec<(WorkerId, WorkerHealth)> {
200        let mut h: Vec<_> = self.workers.iter().map(|w| (w.id(), w.health())).collect();
201        h.sort_by_key(|(id, _)| id.0);
202        h
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn make_worker(
211        id: u32,
212    ) -> Arc<InProcessWorker<u32, u32, impl Fn(u32) -> Result<u32, WorkerError> + Send + Sync>>
213    {
214        Arc::new(InProcessWorker::new(WorkerId(id), |x: u32| Ok(x * 2)))
215    }
216
217    #[test]
218    fn in_process_worker_handles_dispatch() {
219        let w = make_worker(7);
220        assert_eq!(w.dispatch(5).unwrap(), 10);
221        let h = w.health();
222        assert_eq!(h.completed, 1);
223        assert_eq!(h.errored, 0);
224        assert_eq!(h.in_flight, 0);
225    }
226
227    #[test]
228    fn errors_increment_errored_count() {
229        let w: Arc<InProcessWorker<u32, u32, _>> =
230            Arc::new(InProcessWorker::new(WorkerId(1), |_x: u32| {
231                Err(WorkerError::Domain {
232                    reason: "bad".into(),
233                })
234            }));
235        let _ = w.dispatch(1);
236        let h = w.health();
237        assert_eq!(h.errored, 1);
238        assert_eq!(h.completed, 1);
239    }
240
241    #[test]
242    fn round_robin_visits_each_worker() {
243        let mut pool: WorkerPool<u32, u32> = WorkerPool::new(DispatchPolicy::RoundRobin);
244        for i in 0..3 {
245            pool.add(make_worker(i));
246        }
247
248        let mut ids = Vec::new();
249        for _ in 0..6 {
250            let w = pool.select().unwrap();
251            ids.push(w.id().0);
252        }
253        // 6 picks across 3 workers RR → each worker hit twice in
254        // a deterministic 0,1,2,0,1,2 sequence.
255        assert_eq!(ids, vec![0, 1, 2, 0, 1, 2]);
256    }
257
258    #[test]
259    fn least_loaded_picks_quietest() {
260        // Build three workers; bump in_flight on two of them so
261        // the third is the obvious least-loaded pick.
262        let w0 = make_worker(0);
263        let w1 = make_worker(1);
264        let w2 = make_worker(2);
265        // Manually bump w0 + w1 in-flight via fetch_add.
266        w0.in_flight.fetch_add(5, Ordering::Relaxed);
267        w1.in_flight.fetch_add(3, Ordering::Relaxed);
268
269        let mut pool: WorkerPool<u32, u32> = WorkerPool::new(DispatchPolicy::LeastLoaded);
270        pool.add(w0);
271        pool.add(w1);
272        pool.add(w2);
273
274        let pick = pool.select().unwrap();
275        assert_eq!(
276            pick.id().0,
277            2,
278            "least-loaded should pick the worker with 0 in-flight"
279        );
280    }
281
282    #[test]
283    fn empty_pool_dispatch_errors() {
284        let pool: WorkerPool<u32, u32> = WorkerPool::new(DispatchPolicy::RoundRobin);
285        let err = pool.dispatch(1).unwrap_err();
286        assert!(matches!(err, WorkerError::WorkerCrash { .. }));
287    }
288
289    #[test]
290    fn health_snapshot_includes_every_worker() {
291        let mut pool: WorkerPool<u32, u32> = WorkerPool::new(DispatchPolicy::RoundRobin);
292        for i in 0..3 {
293            pool.add(make_worker(i));
294        }
295        let _ = pool.dispatch(1);
296        let _ = pool.dispatch(2);
297        let h = pool.health();
298        assert_eq!(h.len(), 3);
299        let total_completed: u64 = h.iter().map(|(_, hh)| hh.completed).sum();
300        assert_eq!(total_completed, 2);
301    }
302}