Skip to main content

sie_sdk/client/
pools.rs

1//! `/v1/pools`: reserved worker capacity, and the lease that keeps it alive.
2//!
3//! A pool lease expires unless it is renewed, so creating one starts a background task that
4//! renews it every minute. The task holds no reference to the [`Client`], so a dropped
5//! client is collected normally and the task shuts down with it.
6
7use 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/// The pools namespace. Obtain one with [`Client::pools`].
21#[derive(Debug, Clone)]
22pub struct Pools {
23    client: Client,
24}
25
26impl Client {
27    /// Operations on worker pools.
28    pub fn pools(&self) -> Pools {
29        Pools {
30            client: self.clone(),
31        }
32    }
33}
34
35impl Pools {
36    /// Create a pool, or update the shape of one that already exists.
37    ///
38    /// The returned pool's lease is renewed in the background until the client is dropped.
39    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    /// Fetch a pool, or `None` when it does not exist.
53    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    /// Delete a pool. Returns `false` when there was nothing to delete.
66    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
80/// Restate a failure as a pool failure, keeping the server's message.
81fn 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/// Creates a pool. Build with [`Pools::create`].
95#[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    /// How many workers of each GPU type the pool reserves.
109    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    /// Ceiling on assigned workers per GPU type.
115    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    /// Which queue pool the workers draw from. Defaults to `default` server-side.
121    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    /// Which model bundle the workers run.
127    pub fn bundle(mut self, bundle: impl Into<String>) -> Self {
128        self.bundle = Some(bundle.into());
129        self
130    }
131
132    /// Workers to keep warm. Zero, the default, allows scaling to zero.
133    pub fn minimum_worker_count(mut self, count: u32) -> Self {
134        self.minimum_worker_count = Some(count);
135        self
136    }
137
138    /// Models to keep resident, as `model` or `model:profile`.
139    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    /// Send the request and start renewing the pool's lease.
145    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
202/// Renews one pool's lease until it is cancelled.
203///
204/// It carries the transport and the built request rather than a [`Client`], so it cannot
205/// keep the client alive by holding a reference back to it.
206pub(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    /// Renew every minute, retrying a failed round with a bounded backoff.
229    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            // 1, 2, 4, 8, then 10 seconds.
269            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    /// Start renewing a pool's lease, unless this client already renews it.
278    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}