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}