Skip to main content

monty_pool/
pool.rs

1//! The elastic worker pool: prewarming, checkout, replacement, teardown.
2
3use std::{
4    mem,
5    pin::pin,
6    sync::{Arc, Mutex, MutexGuard, PoisonError},
7    time::Duration,
8};
9
10use futures_util::future::join_all;
11use monty_proto::pb;
12use tokio::{
13    sync::Notify,
14    time::{Instant, timeout_at},
15};
16
17use crate::{
18    PoolConfig, PoolError,
19    checkout::{Checkout, CheckoutOptions, Redial, ReplConfig, request},
20    worker::Worker,
21};
22
23/// An elastic pool of `monty subprocess` workers.
24///
25/// `min_processes` workers spawn eagerly so the first checkout is fast;
26/// further workers spawn on demand up to `max_processes`, and dead workers
27/// are detected and replaced transparently. See the crate docs for the full
28/// lifecycle.
29///
30/// `Pool` is safe to share across tasks and threads. [`Pool::close`] asks idle
31/// workers to exit cleanly; merely dropping the pool kills them instead (via
32/// their kill-on-drop handles). Workers held by live [`Checkout`]s die when
33/// those are finished or dropped.
34pub struct Pool {
35    pub(crate) inner: Arc<PoolInner>,
36}
37
38pub(crate) struct PoolInner {
39    pub(crate) config: PoolConfig,
40    /// Guarded by a *synchronous* mutex: every critical section is short and
41    /// await-free, and worker/checkout `Drop` impls must be able to release
42    /// capacity without an async context.
43    state: Mutex<PoolState>,
44    /// Signalled whenever a worker returns to the idle queue or capacity is
45    /// released, waking blocked `checkout` calls.
46    available: Notify,
47}
48
49struct PoolState {
50    idle: Vec<Worker>,
51    /// Live workers: idle + checked out + currently being spawned.
52    total: usize,
53    /// Never reused, including when a spawn fails or is cancelled.
54    next_id: u64,
55}
56
57impl Pool {
58    /// Creates the pool and eagerly spawns `min_processes` workers, failing
59    /// fast if the binary cannot be spawned. Must be called within a tokio
60    /// runtime (worker process and pipe I/O is driven by the runtime).
61    pub async fn new(config: PoolConfig) -> Result<Self, PoolError> {
62        if config.min_processes > config.max_processes || config.max_processes == 0 {
63            return Err(PoolError::Spawn(format!(
64                "invalid pool size: min_processes={} max_processes={}",
65                config.min_processes, config.max_processes
66            )));
67        }
68        // Only the subprocess transport pre-warms workers; WebSocket connections
69        // are made per-checkout (its `min_processes` is 0).
70        let mut idle = Vec::with_capacity(config.min_processes);
71        if !config.transport.is_websocket() {
72            for _ in 0..config.min_processes {
73                let mut worker = Worker::new(&config, &[]).await?;
74                worker.id = idle.len() as u64 + 1;
75                idle.push(worker);
76            }
77        }
78        let total = idle.len();
79        let pool = Self {
80            inner: Arc::new(PoolInner {
81                config,
82                state: Mutex::new(PoolState {
83                    idle,
84                    total,
85                    next_id: total as u64 + 1,
86                }),
87                available: Notify::new(),
88            }),
89        };
90        let workers = worker_count(total);
91        pool.inner.record_worker_delta(workers, workers);
92        Ok(pool)
93    }
94
95    /// Dedicates a worker to one REPL session created from `repl`, with
96    /// default [`CheckoutOptions`] — see [`Self::checkout_with`].
97    pub async fn checkout(&self, repl: &ReplConfig) -> Result<Checkout, PoolError> {
98        self.checkout_with(repl, CheckoutOptions::default()).await
99    }
100
101    /// Dedicates a worker to one REPL session created from `repl`, with the
102    /// host context `options` carries.
103    ///
104    /// Takes an idle worker when one exists, spawns or dials a new one (with
105    /// `options.connect_headers`, preceded by the W3C trace headers of
106    /// `options.telemetry`) while below `max_processes`, and otherwise waits
107    /// up to `checkout_timeout` (forever when `None`) before failing with
108    /// [`PoolError::Exhausted`].
109    pub async fn checkout_with(&self, repl: &ReplConfig, mut options: CheckoutOptions) -> Result<Checkout, PoolError> {
110        // ahead of the caller's headers, which stay last-wins
111        #[cfg(feature = "telemetry")]
112        if let Some(telemetry) = &options.telemetry {
113            options.connect_headers.splice(0..0, telemetry.propagation_headers());
114        }
115        let worker = self.inner.acquire_worker(&options.connect_headers).await?;
116        let config = &self.inner.config;
117        let redial = (config.auto_resume && config.transport.is_websocket()).then(|| Redial {
118            repl: repl.clone(),
119            connect_headers: options.connect_headers.clone(),
120            #[cfg(feature = "telemetry")]
121            telemetry: options.telemetry.clone(),
122        });
123        #[cfg(feature = "telemetry")]
124        let worker = worker.with_adapter_context(options.telemetry);
125        Checkout::create(worker, Arc::clone(&self.inner), repl, redial).await
126    }
127
128    /// Asks idle workers to exit cleanly and reaps them, capping the wait per
129    /// worker. Sessions still checked out keep their workers until they finish.
130    ///
131    /// Optional: dropping the pool kills idle workers instead, which is just
132    /// as safe — this only trades a SIGKILL for a clean protocol goodbye.
133    ///
134    /// Telemetry exporter shutdown remains the configuring application's
135    /// responsibility and should happen after checked-out sessions finish.
136    pub async fn close(&self) {
137        // Pair each removed worker with a capacity guard immediately: if this
138        // future is dropped mid-close, every unreaped worker is killed by its
139        // kill-on-drop handle and its slot released by the guard, instead of
140        // leaking capacity the pool can never recover.
141        let mut idle: Vec<_> = {
142            let mut state = lock_ignore_poison(&self.inner.state);
143            mem::take(&mut state.idle)
144        }
145        .into_iter()
146        .map(|worker| (worker, CapacityGuard::new(&self.inner)))
147        .collect();
148        self.inner.record_worker_delta(0, -worker_count(idle.len()));
149        for _ in &idle {
150            self.inner.count_termination("closed");
151        }
152        for (worker, _) in &mut idle {
153            let _ = worker
154                .send(&request(pb::parent_request::Kind::Shutdown(pb::Shutdown {})))
155                .await;
156        }
157        // Reap concurrently: a *nonresponsive* worker costs the full grace, so
158        // reaping serially would multiply it by the number of stuck workers.
159        join_all(idle.into_iter().map(|(mut worker, capacity)| async move {
160            let _capacity = capacity;
161            worker.reap_or_kill(SHUTDOWN_EXIT_GRACE).await;
162        }))
163        .await;
164    }
165
166    /// Number of idle workers right now (diagnostics/tests only — the value
167    /// is stale the moment it is returned).
168    #[must_use]
169    pub fn idle_workers(&self) -> usize {
170        lock_ignore_poison(&self.inner.state).idle.len()
171    }
172
173    /// PIDs of the idle workers (diagnostics/tests only).
174    #[must_use]
175    pub fn idle_worker_pids(&self) -> Vec<u32> {
176        lock_ignore_poison(&self.inner.state)
177            .idle
178            .iter()
179            .filter_map(Worker::pid)
180            .collect()
181    }
182}
183
184/// How long [`Pool::close`] waits for a worker to exit on its own after the
185/// `Shutdown` request before killing it.
186const SHUTDOWN_EXIT_GRACE: Duration = Duration::from_millis(500);
187
188impl PoolInner {
189    /// Takes a worker, reusing/spawning a local one or connecting a fresh
190    /// remote one (with `connect_headers` on its upgrade request), waiting as
191    /// capacity allows — and times that for `monty.pool.checkout.wait`.
192    pub(crate) async fn acquire_worker(&self, connect_headers: &[(String, String)]) -> Result<Worker, PoolError> {
193        #[cfg(feature = "telemetry")]
194        let started = Instant::now();
195        // set by the acquisition itself: only it can tell an idle reuse from a
196        // spawn, or either from a wait for capacity
197        let mut outcome = "idle";
198        let worker = self.acquire_worker_inner(connect_headers, &mut outcome).await;
199        #[cfg(feature = "telemetry")]
200        if let Some(metrics) = &self.config.metrics {
201            let outcome = match &worker {
202                Ok(_) => outcome,
203                Err(PoolError::Exhausted) => "exhausted",
204                Err(_) => "error",
205            };
206            metrics.checkout_wait(started.elapsed(), outcome);
207        }
208        worker
209    }
210
211    /// Reuses/spawns a local worker or connects a fresh remote one (with
212    /// `connect_headers` on its upgrade request), waiting as capacity allows,
213    /// and reports through `outcome` how it got one.
214    async fn acquire_worker_inner(
215        &self,
216        connect_headers: &[(String, String)],
217        outcome: &mut &'static str,
218    ) -> Result<Worker, PoolError> {
219        // WebSocket connections are single-use and never pooled idle, so the
220        // idle-reuse step is skipped and each acquisition dials a fresh worker.
221        let websocket = self.config.transport.is_websocket();
222        let mut waited = false;
223        let deadline = self.config.checkout_timeout.map(|t| Instant::now() + t);
224        loop {
225            // Register for wakeups BEFORE checking state: a release landing
226            // between the check and the await below is then still observed
227            // (`enable` is what arms an un-polled `Notified`).
228            let mut notified = pin!(self.available.notified());
229            notified.as_mut().enable();
230            // The guard's scope must close before any await below, so the
231            // spawn/connect and the notified wait never hold the lock.
232            let spawn = {
233                let mut state = lock_ignore_poison(&self.state);
234                let mut reused = None;
235                let mut died_idle = 0;
236                let mut removed_idle = 0;
237                if !websocket {
238                    // discard workers that died while idle — their replacement
239                    // is the spawn below or a later checkout's spawn
240                    while let Some(mut worker) = state.idle.pop() {
241                        removed_idle += 1;
242                        if worker.is_dead() {
243                            state.total -= 1;
244                            died_idle += 1;
245                            drop(worker); // kill-on-drop backstop; already dead
246                        } else {
247                            reused = Some(worker);
248                            break;
249                        }
250                    }
251                }
252                // reserve capacity before releasing the lock to spawn/connect
253                let below_cap = reused.is_none() && state.total < self.config.max_processes;
254                let id = if below_cap {
255                    state.total += 1;
256                    let id = state.next_id;
257                    state.next_id = id.checked_add(1).expect("worker identity exhausted");
258                    Some(id)
259                } else {
260                    None
261                };
262                drop(state); // never call into the host adapter under the lock
263                for _ in 0..died_idle {
264                    self.count_termination("died_idle");
265                }
266                let spawned = i64::from(below_cap);
267                self.record_worker_delta(spawned - worker_count(died_idle), -worker_count(removed_idle));
268                if let Some(worker) = reused {
269                    *outcome = if waited { "waited" } else { "idle" };
270                    return Ok(worker);
271                }
272                id
273            };
274            if let Some(id) = spawn {
275                *outcome = if waited { "waited" } else { "spawned" };
276                // guard the reserved slot: a failed — or cancelled, for the
277                // WebSocket dial — spawn must release it or the pool shrinks
278                let capacity = CapacityGuard::new(self);
279                let mut worker = Worker::new(&self.config, connect_headers).await?;
280                worker.id = id;
281                capacity.disarm();
282                return Ok(worker);
283            }
284            waited = true;
285            match deadline {
286                Some(deadline) => {
287                    if timeout_at(deadline, notified).await.is_err() {
288                        return Err(PoolError::Exhausted);
289                    }
290                }
291                None => notified.await,
292            }
293        }
294    }
295
296    /// Counts one worker leaving the pool. Every path that drops a worker
297    /// records here or in [`crate::checkout`], so the reasons add up to the
298    /// pool's whole turnover.
299    pub(crate) fn count_termination(&self, reason: &'static str) {
300        #[cfg(feature = "telemetry")]
301        if let Some(metrics) = &self.config.metrics {
302            metrics.worker_terminated(reason);
303        }
304        #[cfg(not(feature = "telemetry"))]
305        let _ = reason;
306    }
307
308    /// Records changes to live and immediately available workers.
309    ///
310    /// Call with the pool lock released to keep telemetry outside pool
311    /// synchronization. Recording only updates Rust SDK aggregates.
312    pub(crate) fn record_worker_delta(&self, live: i64, idle: i64) {
313        #[cfg(feature = "telemetry")]
314        if let Some(metrics) = &self.config.metrics {
315            if live != 0 {
316                metrics.live_workers(live);
317            }
318            if idle != 0 {
319                metrics.idle_workers(idle);
320            }
321        }
322        #[cfg(not(feature = "telemetry"))]
323        let _ = (live, idle);
324    }
325
326    /// Returns a healthy worker to the idle queue (or retires it when it hit
327    /// the recycle limit, or it is a single-use WebSocket connection).
328    pub(crate) fn release_worker(&self, worker: Worker) {
329        let websocket = self.config.transport.is_websocket();
330        let recycle = websocket
331            || self
332                .config
333                .max_checkouts_per_worker
334                .is_some_and(|max| worker.checkouts_served >= max);
335        if recycle {
336            drop(worker); // kill (on drop) — reaped by tokio in the background
337            self.count_termination(if websocket { "single_use" } else { "recycled" });
338            self.release_capacity();
339        } else {
340            lock_ignore_poison(&self.state).idle.push(worker);
341            self.available.notify_one();
342            self.record_worker_delta(0, 1);
343        }
344    }
345
346    /// Records the death/retirement of a worker, freeing capacity for a
347    /// future spawn.
348    pub(crate) fn release_capacity(&self) {
349        lock_ignore_poison(&self.state).total -= 1;
350        self.available.notify_one();
351        self.record_worker_delta(-1, 0);
352    }
353}
354
355#[cfg(feature = "telemetry")]
356impl Drop for PoolInner {
357    /// A pool dropped without [`Pool::close`] — a supported shutdown — kills
358    /// its idle workers via their kill-on-drop handles; count them here so
359    /// that path does not under-report worker turnover. (`close` drains the
360    /// idle queue and counts, so nothing is counted twice.)
361    fn drop(&mut self) {
362        let (live, idle) = {
363            let state = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
364            (state.total, state.idle.len())
365        };
366        self.record_worker_delta(-worker_count(live), -worker_count(idle));
367        for _ in 0..idle {
368            self.count_termination("closed");
369        }
370    }
371}
372
373/// RAII hold on one reserved capacity slot: releases it on drop unless
374/// [`CapacityGuard::disarm`]ed.
375///
376/// Teardown and spawn paths hold one across their `await`s so a future
377/// dropped mid-flight (e.g. asyncio cancellation) still releases the slot —
378/// a leaked slot would shrink the pool permanently, down to a pool that can
379/// never check out again. The worker itself needs no such guard: its
380/// kill-on-drop handle kills the child whenever it is dropped.
381pub(crate) struct CapacityGuard<'a> {
382    pool: Option<&'a PoolInner>,
383    /// Why the worker holding this slot left the pool, counted when the slot
384    /// is released. `None` for a slot merely reserved for a spawn, where no
385    /// worker has left.
386    reason: Option<&'static str>,
387}
388
389impl<'a> CapacityGuard<'a> {
390    /// Guards a slot reserved for a spawn that has not produced a worker yet.
391    pub(crate) fn new(pool: &'a PoolInner) -> Self {
392        Self {
393            pool: Some(pool),
394            reason: None,
395        }
396    }
397
398    /// Guards the slot of a worker being torn down, counting its termination
399    /// under `reason` when the slot is released.
400    ///
401    /// The count belongs to the guard rather than to the teardown code because
402    /// every one of those sites awaits a reap: a caller dropping the turn
403    /// future there still kills the worker and frees its slot, so the
404    /// termination has to be counted on that path too.
405    pub(crate) fn terminating(pool: &'a PoolInner, reason: &'static str) -> Self {
406        Self {
407            pool: Some(pool),
408            reason: Some(reason),
409        }
410    }
411
412    /// Refines the reason once the worker's exit status has classified it.
413    /// The reason it replaces is what a cancelled teardown records instead.
414    pub(crate) const fn set_reason(&mut self, reason: &'static str) {
415        self.reason = Some(reason);
416    }
417
418    /// Keeps the slot reserved: the capacity was consumed by a live worker.
419    pub(crate) fn disarm(mut self) {
420        self.pool = None;
421    }
422}
423
424impl Drop for CapacityGuard<'_> {
425    fn drop(&mut self) {
426        if let Some(pool) = self.pool {
427            if let Some(reason) = self.reason {
428                pool.count_termination(reason);
429            }
430            pool.release_capacity();
431        }
432    }
433}
434
435/// Saturates a worker count at the largest signed metric adjustment.
436fn worker_count(count: usize) -> i64 {
437    i64::try_from(count).unwrap_or(i64::MAX)
438}
439
440/// Locks a possibly poisoned mutex; a panic elsewhere must not stop us from
441/// killing/reaping children.
442pub(crate) fn lock_ignore_poison<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
443    mutex.lock().unwrap_or_else(PoisonError::into_inner)
444}