1use std::collections::HashMap;
8use std::time::Duration;
9
10use reqwest::Method;
11use reqwest::header::HeaderMap;
12use serde_json::{Map, Value};
13
14use crate::client::{Client, meta::parse_json};
15use crate::error::{Error, Result};
16use crate::http::headers;
17use crate::retry::{RetryPolicy, backoff};
18use crate::types::PoolInfo;
19
20#[derive(Debug, Clone)]
22pub struct Pools {
23 client: Client,
24}
25
26impl Client {
27 pub fn pools(&self) -> Pools {
29 Pools {
30 client: self.clone(),
31 }
32 }
33}
34
35impl Pools {
36 pub fn create(&self, name: impl Into<String>) -> PoolCreate {
40 PoolCreate {
41 client: self.client.clone(),
42 name: name.into(),
43 gpus: None,
44 gpu_caps: None,
45 queue_pool: None,
46 bundle: None,
47 minimum_worker_count: None,
48 pinned_models: None,
49 }
50 }
51
52 pub async fn get(&self, name: &str) -> Result<Option<PoolInfo>> {
54 let request = self
55 .client
56 .request(Method::GET, &format!("/v1/pools/{name}"))?
57 .header("accept", headers::JSON_CONTENT_TYPE);
58 match self.client.send_once(request, RetryPolicy::NONE).await {
59 Ok(response) => Ok(Some(parse_json(&response, "pool")?)),
60 Err(error) if error.status() == Some(404) => Ok(None),
61 Err(error) => Err(pool_error(error, name)),
62 }
63 }
64
65 pub async fn delete(&self, name: &str) -> Result<bool> {
67 self.client.stop_lease(name);
68 let request = self
69 .client
70 .request(Method::DELETE, &format!("/v1/pools/{name}"))?
71 .header("accept", headers::JSON_CONTENT_TYPE);
72 match self.client.send_once(request, RetryPolicy::NONE).await {
73 Ok(_) => Ok(true),
74 Err(error) if error.status() == Some(404) => Ok(false),
75 Err(error) => Err(pool_error(error, name)),
76 }
77 }
78}
79
80fn pool_error(error: Error, name: &str) -> Error {
82 match error {
83 Error::Request { message, .. }
84 | Error::Server { message, .. }
85 | Error::Connection { message, .. } => Error::Pool {
86 message: format!("Pool '{name}': {message}"),
87 pool_name: Some(name.to_string()),
88 state: None,
89 },
90 other => other,
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct PoolCreate {
97 client: Client,
98 name: String,
99 gpus: Option<HashMap<String, u32>>,
100 gpu_caps: Option<HashMap<String, u32>>,
101 queue_pool: Option<String>,
102 bundle: Option<String>,
103 minimum_worker_count: Option<u32>,
104 pinned_models: Option<Vec<String>>,
105}
106
107impl PoolCreate {
108 pub fn gpus(mut self, gpus: impl IntoIterator<Item = (String, u32)>) -> Self {
110 self.gpus = Some(gpus.into_iter().collect());
111 self
112 }
113
114 pub fn gpu_caps(mut self, caps: impl IntoIterator<Item = (String, u32)>) -> Self {
116 self.gpu_caps = Some(caps.into_iter().collect());
117 self
118 }
119
120 pub fn queue_pool(mut self, queue_pool: impl Into<String>) -> Self {
122 self.queue_pool = Some(queue_pool.into());
123 self
124 }
125
126 pub fn bundle(mut self, bundle: impl Into<String>) -> Self {
128 self.bundle = Some(bundle.into());
129 self
130 }
131
132 pub fn minimum_worker_count(mut self, count: u32) -> Self {
134 self.minimum_worker_count = Some(count);
135 self
136 }
137
138 pub fn pinned_models(mut self, models: impl IntoIterator<Item = impl Into<String>>) -> Self {
140 self.pinned_models = Some(models.into_iter().map(Into::into).collect());
141 self
142 }
143
144 pub async fn send(self) -> Result<PoolInfo> {
146 let mut body = Map::new();
147 body.insert("name".to_string(), Value::String(self.name.clone()));
148 if let Some(gpus) = &self.gpus {
149 body.insert("gpus".to_string(), json_counts(gpus));
150 }
151 if let Some(caps) = &self.gpu_caps {
152 body.insert("gpu_caps".to_string(), json_counts(caps));
153 }
154 if let Some(queue_pool) = self.queue_pool.filter(|value| !value.is_empty()) {
155 body.insert("queue_pool".to_string(), Value::String(queue_pool));
156 }
157 if let Some(bundle) = self.bundle.filter(|value| !value.is_empty()) {
158 body.insert("bundle".to_string(), Value::String(bundle));
159 }
160 if let Some(count) = self.minimum_worker_count {
161 body.insert("minimum_worker_count".to_string(), Value::from(count));
162 }
163 if let Some(models) = &self.pinned_models {
164 body.insert(
165 "pinned_models".to_string(),
166 Value::Array(
167 models
168 .iter()
169 .map(|model| Value::String(model.clone()))
170 .collect(),
171 ),
172 );
173 }
174
175 let request = self
176 .client
177 .request(Method::POST, "/v1/pools")?
178 .json_headers()
179 .body(serde_json::to_vec(&Value::Object(body)).unwrap_or_default());
180
181 let response = self
182 .client
183 .send_once(request, RetryPolicy::NONE)
184 .await
185 .map_err(|error| pool_error(error, &self.name))?;
186
187 let pool: PoolInfo = parse_json(&response, "pool")?;
188 self.client.start_lease(&self.name)?;
189 Ok(pool)
190 }
191}
192
193fn json_counts(counts: &HashMap<String, u32>) -> Value {
194 Value::Object(
195 counts
196 .iter()
197 .map(|(key, value)| (key.clone(), Value::from(*value)))
198 .collect(),
199 )
200}
201
202pub(crate) struct LeaseRenewer {
207 http: reqwest::Client,
208 url: reqwest::Url,
209 edge_headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
210 name: String,
211}
212
213impl LeaseRenewer {
214 pub(crate) fn new(
215 http: reqwest::Client,
216 url: reqwest::Url,
217 edge_headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
218 name: String,
219 ) -> Self {
220 Self {
221 http,
222 url,
223 edge_headers,
224 name,
225 }
226 }
227
228 pub(crate) async fn run(self) {
230 loop {
231 tokio::time::sleep(backoff::LEASE_RENEWAL_INTERVAL).await;
232 if !self.renew_round().await {
233 tracing::error!(
234 "All {} lease renewal attempts failed for pool '{}'",
235 backoff::LEASE_RENEWAL_MAX_RETRIES,
236 self.name
237 );
238 }
239 }
240 }
241
242 async fn renew_round(&self) -> bool {
243 for attempt in 0..backoff::LEASE_RENEWAL_MAX_RETRIES {
244 let mut headers = HeaderMap::new();
245 headers.insert(
246 reqwest::header::ACCEPT,
247 headers::JSON_CONTENT_TYPE.parse().unwrap(),
248 );
249 for (name, value) in &self.edge_headers {
250 headers.insert(name.clone(), value.clone());
251 }
252
253 let result = self
254 .http
255 .post(self.url.clone())
256 .headers(headers)
257 .send()
258 .await
259 .map(|response| response.status().is_success());
260
261 match result {
262 Ok(true) => return true,
263 Ok(false) => tracing::warn!("Lease renewal for pool '{}' was rejected", self.name),
264 Err(error) => {
265 tracing::warn!("Lease renewal for pool '{}' failed: {error}", self.name);
266 }
267 }
268 let backoff = Duration::from_secs(1 << attempt.min(3)).min(Duration::from_secs(10));
270 tokio::time::sleep(backoff).await;
271 }
272 false
273 }
274}
275
276impl Client {
277 fn start_lease(&self, name: &str) -> Result<()> {
279 let mut leases = self
280 .inner
281 .leases
282 .lock()
283 .expect("lease registry is not poisoned");
284 if leases.contains_key(name) {
285 return Ok(());
286 }
287 let url = self.url(&format!("/v1/pools/{name}/renew"))?;
288 let edge_headers = if self.edge_headers_apply_to(&url) {
289 self.inner.edge_headers.clone()
290 } else {
291 Vec::new()
292 };
293 let renewer =
294 LeaseRenewer::new(self.inner.http.clone(), url, edge_headers, name.to_string());
295 leases.insert(name.to_string(), tokio::spawn(renewer.run()));
296 Ok(())
297 }
298
299 fn stop_lease(&self, name: &str) {
300 if let Some(handle) = self
301 .inner
302 .leases
303 .lock()
304 .expect("lease registry is not poisoned")
305 .remove(name)
306 {
307 handle.abort();
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn client() -> Client {
317 Client::new("https://sie.invalid").unwrap()
318 }
319
320 #[test]
321 fn the_renewal_url_is_derived_from_the_pool_name() {
322 let url = client().url("/v1/pools/prod/renew").unwrap();
323 assert_eq!(url.as_str(), "https://sie.invalid/v1/pools/prod/renew");
324 }
325
326 #[test]
327 fn errors_are_restated_as_pool_errors() {
328 let error = pool_error(
329 Error::Request {
330 message: "gpu type not configured".to_string(),
331 code: None,
332 status: 400,
333 request: None,
334 },
335 "prod",
336 );
337 match error {
338 Error::Pool {
339 message, pool_name, ..
340 } => {
341 assert_eq!(pool_name.as_deref(), Some("prod"));
342 assert!(message.contains("gpu type not configured"), "{message}");
343 }
344 other => panic!("unexpected: {other:?}"),
345 }
346 }
347
348 #[tokio::test]
349 async fn starting_the_same_lease_twice_spawns_one_task() {
350 let client = client();
351 client.start_lease("prod").unwrap();
352 client.start_lease("prod").unwrap();
353 assert_eq!(client.inner.leases.lock().unwrap().len(), 1);
354 client.stop_lease("prod");
355 assert!(client.inner.leases.lock().unwrap().is_empty());
356 }
357}