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}