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, 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}
73
74impl InferencePools {
75    /// Build the pools from a configuration.
76    pub fn new(config: InferencePoolConfig) -> Self {
77        Self {
78            config,
79            semaphores: Mutex::new(HashMap::new()),
80        }
81    }
82
83    /// Acquire a permit for `model`, waiting for a free slot if the pool is
84    /// full. The returned [`InferencePermit`] releases the slot when dropped -
85    /// so the caller holds it for exactly the duration of the inference request.
86    pub async fn acquire(&self, model: &str) -> InferencePermit {
87        let semaphore = self.semaphore_for(model);
88        // The semaphore is never closed (we never call `.close()`), so
89        // `acquire_owned` only ever returns `Ok`; `expect_permit` documents and
90        // enforces that invariant.
91        let permit = expect_permit(semaphore.acquire_owned().await);
92        InferencePermit { _permit: permit }
93    }
94
95    /// Try to take a permit for `model` **without waiting**. Returns `None` if
96    /// the pool is currently full.
97    ///
98    /// This is what the synchronous ECS inference-dispatch system calls: a
99    /// system can't `.await`, so instead of blocking on a full pool it leaves
100    /// the agent `ReadyToInfer` and retries on a later tick.
101    pub fn try_acquire(&self, model: &str) -> Option<InferencePermit> {
102        let semaphore = self.semaphore_for(model);
103        // `try_acquire_owned` errors only on "no permits" (pool full) or
104        // "closed" (never, since we never close) - both mean "no slot now".
105        match semaphore.try_acquire_owned() {
106            Ok(permit) => Some(InferencePermit { _permit: permit }),
107            Err(_) => None,
108        }
109    }
110
111    /// Fetch (or lazily create) the semaphore for `model`.
112    fn semaphore_for(&self, model: &str) -> Arc<Semaphore> {
113        let mut map = self
114            .semaphores
115            .lock()
116            .unwrap_or_else(PoisonError::into_inner);
117        if let Some(existing) = map.get(model) {
118            return existing.clone();
119        }
120        let permits = self.config.limit_for(model).unwrap_or(UNBOUNDED_PERMITS);
121        let semaphore = Arc::new(Semaphore::new(permits));
122        map.insert(model.to_string(), semaphore.clone());
123        semaphore
124    }
125}
126
127/// Unwrap an `acquire_owned` result, panicking with a clear message if the
128/// semaphore was closed. Extracted as a free function (rather than an inline
129/// `.expect(...)`) so both arms - the ordinary `Ok` and the never-in-practice
130/// `Err` - are exercised directly by unit tests, keeping the region covered.
131fn expect_permit(result: Result<OwnedSemaphorePermit, AcquireError>) -> OwnedSemaphorePermit {
132    result.expect("inference pool semaphore is never closed")
133}
134
135/// An RAII permit occupying one slot of a model's inference pool. Dropping it
136/// frees the slot for the next waiting agent.
137#[derive(Debug)]
138pub struct InferencePermit {
139    _permit: OwnedSemaphorePermit,
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn limit_for_prefers_explicit_over_default() {
148        let mut cfg = InferencePoolConfig::new().with_default(Some(5));
149        cfg.set_limit("anthropic:x", 3);
150        assert_eq!(cfg.limit_for("anthropic:x"), Some(3)); // explicit entry
151        assert_eq!(cfg.limit_for("ollama:gemma"), Some(5)); // falls back to default
152    }
153
154    #[test]
155    fn limit_for_unbounded_when_no_entry_and_no_default() {
156        let cfg = InferencePoolConfig::new();
157        assert_eq!(cfg.limit_for("anything"), None);
158    }
159
160    #[test]
161    fn semaphore_for_is_cached_per_model() {
162        let pools = InferencePools::new(InferencePoolConfig::new());
163        let first = pools.semaphore_for("m");
164        let second = pools.semaphore_for("m"); // cache hit - same Arc
165        assert!(Arc::ptr_eq(&first, &second));
166        let other = pools.semaphore_for("n"); // cache miss - distinct Arc
167        assert!(!Arc::ptr_eq(&first, &other));
168    }
169
170    #[tokio::test]
171    async fn acquire_bounds_concurrency_and_releases_on_drop() {
172        let mut cfg = InferencePoolConfig::new();
173        cfg.set_limit("m", 1);
174        let pools = Arc::new(InferencePools::new(cfg));
175
176        let permit = pools.acquire("m").await; // takes the only slot
177
178        // A second acquire cannot complete while the permit is held.
179        let pools2 = pools.clone();
180        let waiting = tokio::spawn(async move { pools2.acquire("m").await });
181        // Give the task a chance to run and block on the full pool.
182        tokio::task::yield_now().await;
183        assert!(
184            !waiting.is_finished(),
185            "second acquire must wait for a slot"
186        );
187
188        drop(permit); // free the slot
189        // Now the waiter can obtain the permit.
190        let _second = waiting.await.expect("waiter task should not panic");
191    }
192
193    #[test]
194    fn try_acquire_returns_none_when_full() {
195        let mut cfg = InferencePoolConfig::new();
196        cfg.set_limit("m", 1);
197        let pools = InferencePools::new(cfg);
198
199        let permit = pools.try_acquire("m").expect("first slot is free"); // Ok arm
200        assert!(pools.try_acquire("m").is_none()); // Err arm: pool full
201        drop(permit);
202        assert!(pools.try_acquire("m").is_some()); // slot freed
203    }
204
205    #[tokio::test]
206    async fn acquire_unbounded_model_never_blocks() {
207        let pools = InferencePools::new(InferencePoolConfig::new()); // no limits
208        // Hold many permits for an unlisted (unbounded) model at once; each
209        // acquire returns immediately without ever waiting for a slot.
210        let mut permits = Vec::new();
211        for _ in 0..64 {
212            permits.push(pools.acquire("free").await);
213        }
214        assert_eq!(permits.len(), 64);
215    }
216
217    #[test]
218    fn expect_permit_returns_ok_permit() {
219        let sem = Arc::new(Semaphore::new(1));
220        let ok = sem.clone().try_acquire_owned().unwrap();
221        // Wrap and unwrap through the same boundary the async path uses.
222        let permit = expect_permit(Ok(ok));
223        drop(permit);
224        assert_eq!(sem.available_permits(), 1);
225    }
226
227    #[tokio::test]
228    #[should_panic(expected = "never closed")]
229    async fn expect_permit_panics_on_closed_semaphore() {
230        let sem = Arc::new(Semaphore::new(0));
231        sem.close();
232        // Acquiring on a closed semaphore yields the `Err` arm.
233        let _ = expect_permit(sem.acquire_owned().await);
234    }
235}