leviath_runtime/inference_pool.rs
1//! Per-model inference concurrency pools.
2//!
3//! A single [`InferencePools`] belongs to the world and bounds how many
4//! inference requests are in flight to each model at once - e.g. "at most 3
5//! concurrent requests to `anthropic:claude-opus-4-8`", "at most 1 to a local
6//! `ollama:gemma`". This is the world-level control the ECS inference-dispatch
7//! system consults before issuing a request: an agent only leaves `ReadyToInfer`
8//! once a permit for its model is available; otherwise it stays ready and is
9//! retried on a later tick (so "waiting for a slot" costs nothing but data).
10//!
11//! Why this matters: a single inference can take up to an hour for very large
12//! requests, so a permit may legitimately be held for a very long time - the
13//! pool is what keeps us from opening an unbounded number of simultaneous
14//! long-lived requests to a provider.
15//!
16//! Distinct from a blueprint's fan-out `max_workers` (which bounds a stage's
17//! sub-agent fan *width*); these pools bound total in-flight inferences *per
18//! model* across every agent in the world.
19
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, PoisonError};
22
23use tokio::sync::{AcquireError, Notify, OwnedSemaphorePermit, Semaphore};
24
25/// How `tokio::sync::Semaphore` represents "effectively unbounded" - its own
26/// maximum permit count. A model with no configured limit gets this many
27/// permits, so `acquire` never actually waits for it.
28const UNBOUNDED_PERMITS: usize = Semaphore::MAX_PERMITS;
29
30/// Configuration for the world's per-model inference concurrency limits.
31///
32/// A model listed in `per_model` uses that limit; any other model uses
33/// `default_limit` (or is unbounded when that is `None`). With a single world
34/// today this is just a global config table.
35#[derive(Debug, Clone, Default)]
36pub struct InferencePoolConfig {
37 per_model: HashMap<String, usize>,
38 default_limit: Option<usize>,
39}
40
41impl InferencePoolConfig {
42 /// An empty config: every model unbounded.
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 /// Set the fallback limit applied to models with no explicit entry.
48 /// `None` leaves unlisted models unbounded.
49 pub fn with_default(mut self, limit: Option<usize>) -> Self {
50 self.default_limit = limit;
51 self
52 }
53
54 /// Set the concurrency limit for a specific model key.
55 pub fn set_limit(&mut self, model: impl Into<String>, limit: usize) {
56 self.per_model.insert(model.into(), limit);
57 }
58
59 /// The configured limit for `model`: its explicit entry if present, else the
60 /// default. `None` means unbounded.
61 pub fn limit_for(&self, model: &str) -> Option<usize> {
62 self.per_model.get(model).copied().or(self.default_limit)
63 }
64}
65
66/// The world's live per-model inference pools. Cheap to clone-share behind an
67/// `Arc`; semaphores are created lazily the first time a model is seen.
68#[derive(Debug)]
69pub struct InferencePools {
70 config: InferencePoolConfig,
71 semaphores: Mutex<HashMap<String, Arc<Semaphore>>>,
72 /// The tick-loop wake handle, handed to every permit so that releasing one
73 /// re-drives dispatch. See [`InferencePools::with_wake`].
74 wake: Option<Arc<Notify>>,
75}
76
77impl InferencePools {
78 /// Build the pools from a configuration.
79 pub fn new(config: InferencePoolConfig) -> Self {
80 Self {
81 config,
82 semaphores: Mutex::new(HashMap::new()),
83 wake: None,
84 }
85 }
86
87 /// Attach the tick-loop wake handle, so that **releasing** a permit wakes
88 /// the driver.
89 ///
90 /// This is load-bearing, not a nicety. `dispatch_inference` leaves a
91 /// slot-starved agent `ReadyToInfer` to be retried "on a later tick", and
92 /// the loop is event-driven: a later tick only happens when something wakes
93 /// it. So every path that frees a permit owes the loop a wake, or the freed
94 /// slot is invisible and everything queued behind it stays parked (issue
95 /// #189).
96 ///
97 /// Hanging the wake off the permit's `Drop` rather than off each release
98 /// site is the point: the obligation can't be forgotten by a new call site,
99 /// and it covers the paths that don't report an outcome at all - notably a
100 /// cancelled job, which frees its permit and returns with nothing to send.
101 pub fn with_wake(mut self, wake: Arc<Notify>) -> Self {
102 self.wake = Some(wake);
103 self
104 }
105
106 /// Acquire a permit for `model`, waiting for a free slot if the pool is
107 /// full. The returned [`InferencePermit`] releases the slot when dropped -
108 /// so the caller holds it for exactly the duration of the inference request.
109 pub async fn acquire(&self, model: &str) -> InferencePermit {
110 let semaphore = self.semaphore_for(model);
111 // The semaphore is never closed (we never call `.close()`), so
112 // `acquire_owned` only ever returns `Ok`; `expect_permit` documents and
113 // enforces that invariant.
114 let permit = expect_permit(semaphore.acquire_owned().await);
115 self.issue(model, permit)
116 }
117
118 /// Try to take a permit for `model` **without waiting**. Returns `None` if
119 /// the pool is currently full.
120 ///
121 /// This is what the synchronous ECS inference-dispatch system calls: a
122 /// system can't `.await`, so instead of blocking on a full pool it leaves
123 /// the agent `ReadyToInfer` and retries on a later tick.
124 pub fn try_acquire(&self, model: &str) -> Option<InferencePermit> {
125 let semaphore = self.semaphore_for(model);
126 // `try_acquire_owned` errors only on "no permits" (pool full) or
127 // "closed" (never, since we never close) - both mean "no slot now".
128 match semaphore.try_acquire_owned() {
129 Ok(permit) => Some(self.issue(model, permit)),
130 Err(_) => None,
131 }
132 }
133
134 /// Wrap a raw semaphore permit as an [`InferencePermit`] carrying this
135 /// pool's wake handle, and trace the acquisition. Acquire and release are
136 /// traced as a pair so a leaked or long-held slot can be read straight off
137 /// the log.
138 fn issue(&self, model: &str, permit: OwnedSemaphorePermit) -> InferencePermit {
139 tracing::trace!(model = %model, "inference slot acquired");
140 InferencePermit {
141 permit: Some(permit),
142 model: model.to_string(),
143 wake: self.wake.clone(),
144 }
145 }
146
147 /// How many slots are in use per model, against each model's cap. `None` as
148 /// the cap means the model is unbounded. Only models that have actually been
149 /// used appear - the semaphores are created lazily.
150 ///
151 /// This is what makes "the pool is full and has been for hours" observable
152 /// rather than inferred.
153 pub fn occupancy(&self) -> Vec<PoolOccupancy> {
154 let map = self
155 .semaphores
156 .lock()
157 .unwrap_or_else(PoisonError::into_inner);
158 let mut out: Vec<PoolOccupancy> = map
159 .iter()
160 .map(|(model, semaphore)| {
161 let cap = self.config.limit_for(model);
162 let free = semaphore.available_permits();
163 PoolOccupancy {
164 model: model.clone(),
165 // An unbounded pool starts at `UNBOUNDED_PERMITS`, so its
166 // in-use count is the shortfall from that, not from a cap.
167 in_use: cap.unwrap_or(UNBOUNDED_PERMITS).saturating_sub(free),
168 cap,
169 }
170 })
171 .collect();
172 out.sort_by(|a, b| a.model.cmp(&b.model)); // stable output for logs and tests
173 out
174 }
175
176 /// Fetch (or lazily create) the semaphore for `model`.
177 fn semaphore_for(&self, model: &str) -> Arc<Semaphore> {
178 let mut map = self
179 .semaphores
180 .lock()
181 .unwrap_or_else(PoisonError::into_inner);
182 if let Some(existing) = map.get(model) {
183 return existing.clone();
184 }
185 let permits = self.config.limit_for(model).unwrap_or(UNBOUNDED_PERMITS);
186 let semaphore = Arc::new(Semaphore::new(permits));
187 map.insert(model.to_string(), semaphore.clone());
188 semaphore
189 }
190}
191
192/// How busy one model's pool is. `cap: None` means the model is unbounded.
193#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
194pub struct PoolOccupancy {
195 /// The model key the pool is scoped to.
196 pub model: String,
197 /// Slots currently held.
198 pub in_use: usize,
199 /// The configured limit, or `None` when unbounded.
200 pub cap: Option<usize>,
201}
202
203impl PoolOccupancy {
204 /// Whether every slot in this pool is taken. An unbounded pool never is.
205 #[must_use]
206 pub fn is_full(&self) -> bool {
207 self.cap.is_some_and(|cap| self.in_use >= cap)
208 }
209}
210
211impl std::fmt::Display for PoolOccupancy {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 match self.cap {
214 Some(cap) => write!(f, "{}={}/{}", self.model, self.in_use, cap),
215 None => write!(f, "{}={}/unbounded", self.model, self.in_use),
216 }
217 }
218}
219
220/// Unwrap an `acquire_owned` result, panicking with a clear message if the
221/// semaphore was closed. Extracted as a free function (rather than an inline
222/// `.expect(...)`) so both arms - the ordinary `Ok` and the never-in-practice
223/// `Err` - are exercised directly by unit tests, keeping the region covered.
224///
225/// Shared with the tool lane, whose semaphore is never closed either.
226pub(crate) fn expect_permit(
227 result: Result<OwnedSemaphorePermit, AcquireError>,
228) -> OwnedSemaphorePermit {
229 result.expect("a lane semaphore is never closed")
230}
231
232/// An RAII permit occupying one slot of a model's inference pool. Dropping it
233/// frees the slot for the next waiting agent **and wakes the tick loop**, so the
234/// agents parked on a full pool are re-driven and can take it.
235#[derive(Debug)]
236pub struct InferencePermit {
237 /// `Option` purely so `Drop` can hand the slot back *before* it wakes the
238 /// loop; a field would otherwise be dropped after the `Drop` body, and the
239 /// woken tick could re-check the pool while this slot was still held.
240 permit: Option<OwnedSemaphorePermit>,
241 /// The model whose pool this slot belongs to; carried for the release trace.
242 model: String,
243 /// Present when the pools were built with [`InferencePools::with_wake`].
244 wake: Option<Arc<Notify>>,
245}
246
247impl Drop for InferencePermit {
248 fn drop(&mut self) {
249 // Release first, wake second. The other order is a race: the woken tick
250 // could run `dispatch_inference` before this slot was actually handed
251 // back, find the pool still full, and park again - with nothing left to
252 // wake it. That is the exact failure this whole mechanism exists to stop.
253 drop(self.permit.take());
254 tracing::trace!(model = %self.model, "inference slot released");
255 // `notify_one` stores a permit when nobody is parked yet, so a wake that
256 // lands mid-tick is remembered rather than lost.
257 if let Some(wake) = &self.wake {
258 wake.notify_one();
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn limit_for_prefers_explicit_over_default() {
269 let mut cfg = InferencePoolConfig::new().with_default(Some(5));
270 cfg.set_limit("anthropic:x", 3);
271 assert_eq!(cfg.limit_for("anthropic:x"), Some(3)); // explicit entry
272 assert_eq!(cfg.limit_for("ollama:gemma"), Some(5)); // falls back to default
273 }
274
275 #[test]
276 fn limit_for_unbounded_when_no_entry_and_no_default() {
277 let cfg = InferencePoolConfig::new();
278 assert_eq!(cfg.limit_for("anything"), None);
279 }
280
281 #[test]
282 fn semaphore_for_is_cached_per_model() {
283 let pools = InferencePools::new(InferencePoolConfig::new());
284 let first = pools.semaphore_for("m");
285 let second = pools.semaphore_for("m"); // cache hit - same Arc
286 assert!(Arc::ptr_eq(&first, &second));
287 let other = pools.semaphore_for("n"); // cache miss - distinct Arc
288 assert!(!Arc::ptr_eq(&first, &other));
289 }
290
291 #[tokio::test]
292 async fn acquire_bounds_concurrency_and_releases_on_drop() {
293 let mut cfg = InferencePoolConfig::new();
294 cfg.set_limit("m", 1);
295 let pools = Arc::new(InferencePools::new(cfg));
296
297 let permit = pools.acquire("m").await; // takes the only slot
298
299 // A second acquire cannot complete while the permit is held.
300 let pools2 = pools.clone();
301 let waiting = tokio::spawn(async move { pools2.acquire("m").await });
302 // Give the task a chance to run and block on the full pool.
303 tokio::task::yield_now().await;
304 assert!(
305 !waiting.is_finished(),
306 "second acquire must wait for a slot"
307 );
308
309 drop(permit); // free the slot
310 // Now the waiter can obtain the permit.
311 let _second = waiting.await.expect("waiter task should not panic");
312 }
313
314 #[test]
315 fn try_acquire_returns_none_when_full() {
316 let mut cfg = InferencePoolConfig::new();
317 cfg.set_limit("m", 1);
318 let pools = InferencePools::new(cfg);
319
320 let permit = pools.try_acquire("m").expect("first slot is free"); // Ok arm
321 assert!(pools.try_acquire("m").is_none()); // Err arm: pool full
322 drop(permit);
323 assert!(pools.try_acquire("m").is_some()); // slot freed
324 }
325
326 #[tokio::test]
327 async fn acquire_unbounded_model_never_blocks() {
328 let pools = InferencePools::new(InferencePoolConfig::new()); // no limits
329 // Hold many permits for an unlisted (unbounded) model at once; each
330 // acquire returns immediately without ever waiting for a slot.
331 let mut permits = Vec::new();
332 for _ in 0..64 {
333 permits.push(pools.acquire("free").await);
334 }
335 assert_eq!(permits.len(), 64);
336 }
337
338 #[test]
339 fn expect_permit_returns_ok_permit() {
340 let sem = Arc::new(Semaphore::new(1));
341 let ok = sem.clone().try_acquire_owned().unwrap();
342 // Wrap and unwrap through the same boundary the async path uses.
343 let permit = expect_permit(Ok(ok));
344 drop(permit);
345 assert_eq!(sem.available_permits(), 1);
346 }
347
348 #[tokio::test]
349 #[should_panic(expected = "never closed")]
350 async fn expect_permit_panics_on_closed_semaphore() {
351 let sem = Arc::new(Semaphore::new(0));
352 sem.close();
353 // Acquiring on a closed semaphore yields the `Err` arm.
354 let _ = expect_permit(sem.acquire_owned().await);
355 }
356
357 /// The issue #189 contract, at its narrowest: handing a slot back has to wake
358 /// the driver. `dispatch_inference` parks a slot-starved agent to be retried
359 /// "on a later tick", and the loop is event-driven - so a silent release
360 /// leaves the freed capacity invisible.
361 #[tokio::test]
362 async fn dropping_a_permit_frees_the_slot_and_wakes_the_driver() {
363 leviath_testkit::with_tracing(|| async {
364 let mut cfg = InferencePoolConfig::new();
365 cfg.set_limit("m", 1);
366 let wake = Arc::new(Notify::new());
367 let pools = InferencePools::new(cfg).with_wake(wake.clone());
368
369 let permit = pools.try_acquire("m").expect("the only slot");
370 assert!(pools.try_acquire("m").is_none(), "pool full");
371 // Nothing has released yet, so there is no wake to collect.
372 assert!(
373 tokio::time::timeout(std::time::Duration::from_millis(20), wake.notified())
374 .await
375 .is_err(),
376 "holding a permit must not wake the driver"
377 );
378
379 drop(permit);
380 // The slot is back...
381 assert!(pools.try_acquire("m").is_some(), "slot freed");
382 // ...and the driver was told, so a parked loop re-drives dispatch.
383 tokio::time::timeout(std::time::Duration::from_millis(20), wake.notified())
384 .await
385 .expect("releasing a permit must wake the driver");
386 })
387 .await;
388 }
389
390 /// Pools built without a wake (the embedding case, and every pre-existing
391 /// caller) still release cleanly - the handle is optional, not required.
392 #[tokio::test]
393 async fn a_permit_without_a_wake_handle_still_releases() {
394 let mut cfg = InferencePoolConfig::new();
395 cfg.set_limit("m", 1);
396 let pools = InferencePools::new(cfg); // no `with_wake`
397 let permit = pools.try_acquire("m").expect("the only slot");
398 assert!(pools.try_acquire("m").is_none());
399 drop(permit);
400 assert!(pools.try_acquire("m").is_some());
401 }
402
403 /// The async `acquire` path carries the wake too, not just `try_acquire`.
404 #[tokio::test]
405 async fn the_awaiting_acquire_path_also_wakes_on_release() {
406 let wake = Arc::new(Notify::new());
407 let pools = InferencePools::new(InferencePoolConfig::new()).with_wake(wake.clone());
408 drop(pools.acquire("m").await);
409 tokio::time::timeout(std::time::Duration::from_millis(20), wake.notified())
410 .await
411 .expect("an awaited permit wakes on release as well");
412 }
413
414 #[tokio::test]
415 async fn occupancy_reports_in_use_against_each_models_cap() {
416 let mut cfg = InferencePoolConfig::new().with_default(None); // unlisted = unbounded
417 cfg.set_limit("capped", 2);
418 let pools = InferencePools::new(cfg);
419
420 // Untouched models don't appear: the semaphores are lazy.
421 assert!(pools.occupancy().is_empty());
422
423 let held = pools.try_acquire("capped").expect("free");
424 let _unbounded = pools.try_acquire("free").expect("unbounded is always free");
425 let occ = pools.occupancy();
426 assert_eq!(
427 occ,
428 vec![
429 PoolOccupancy {
430 model: "capped".to_string(),
431 in_use: 1,
432 cap: Some(2)
433 },
434 PoolOccupancy {
435 model: "free".to_string(),
436 in_use: 1,
437 cap: None
438 },
439 ],
440 "sorted by model, in-use counted against the cap where there is one"
441 );
442 assert!(!occ[0].is_full(), "1 of 2 is not full");
443 assert!(!occ[1].is_full(), "an unbounded pool is never full");
444 assert_eq!(occ[0].to_string(), "capped=1/2");
445 assert_eq!(occ[1].to_string(), "free=1/unbounded");
446
447 // Fill the capped pool and it reads as full.
448 let _second = pools.try_acquire("capped").expect("second of two");
449 assert!(pools.occupancy()[0].is_full(), "2 of 2 is full");
450 drop(held);
451 assert!(!pools.occupancy()[0].is_full(), "and not full once freed");
452 }
453}