Skip to main content

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}