use std::collections::HashMap;
use std::time::Duration;
use reqwest::Method;
use reqwest::header::HeaderMap;
use serde_json::{Map, Value};
use crate::client::{Client, meta::parse_json};
use crate::error::{Error, Result};
use crate::http::headers;
use crate::retry::{RetryPolicy, backoff};
use crate::types::PoolInfo;
#[derive(Debug, Clone)]
pub struct Pools {
client: Client,
}
impl Client {
pub fn pools(&self) -> Pools {
Pools {
client: self.clone(),
}
}
}
impl Pools {
pub fn create(&self, name: impl Into<String>) -> PoolCreate {
PoolCreate {
client: self.client.clone(),
name: name.into(),
gpus: None,
gpu_caps: None,
queue_pool: None,
bundle: None,
minimum_worker_count: None,
pinned_models: None,
}
}
pub async fn get(&self, name: &str) -> Result<Option<PoolInfo>> {
let request = self
.client
.request(Method::GET, &format!("/v1/pools/{name}"))?
.header("accept", headers::JSON_CONTENT_TYPE);
match self.client.send_once(request, RetryPolicy::NONE).await {
Ok(response) => Ok(Some(parse_json(&response, "pool")?)),
Err(error) if error.status() == Some(404) => Ok(None),
Err(error) => Err(pool_error(error, name)),
}
}
pub async fn delete(&self, name: &str) -> Result<bool> {
self.client.stop_lease(name);
let request = self
.client
.request(Method::DELETE, &format!("/v1/pools/{name}"))?
.header("accept", headers::JSON_CONTENT_TYPE);
match self.client.send_once(request, RetryPolicy::NONE).await {
Ok(_) => Ok(true),
Err(error) if error.status() == Some(404) => Ok(false),
Err(error) => Err(pool_error(error, name)),
}
}
}
fn pool_error(error: Error, name: &str) -> Error {
match error {
Error::Request { message, .. }
| Error::Server { message, .. }
| Error::Connection { message, .. } => Error::Pool {
message: format!("Pool '{name}': {message}"),
pool_name: Some(name.to_string()),
state: None,
},
other => other,
}
}
#[derive(Debug, Clone)]
pub struct PoolCreate {
client: Client,
name: String,
gpus: Option<HashMap<String, u32>>,
gpu_caps: Option<HashMap<String, u32>>,
queue_pool: Option<String>,
bundle: Option<String>,
minimum_worker_count: Option<u32>,
pinned_models: Option<Vec<String>>,
}
impl PoolCreate {
pub fn gpus(mut self, gpus: impl IntoIterator<Item = (String, u32)>) -> Self {
self.gpus = Some(gpus.into_iter().collect());
self
}
pub fn gpu_caps(mut self, caps: impl IntoIterator<Item = (String, u32)>) -> Self {
self.gpu_caps = Some(caps.into_iter().collect());
self
}
pub fn queue_pool(mut self, queue_pool: impl Into<String>) -> Self {
self.queue_pool = Some(queue_pool.into());
self
}
pub fn bundle(mut self, bundle: impl Into<String>) -> Self {
self.bundle = Some(bundle.into());
self
}
pub fn minimum_worker_count(mut self, count: u32) -> Self {
self.minimum_worker_count = Some(count);
self
}
pub fn pinned_models(mut self, models: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.pinned_models = Some(models.into_iter().map(Into::into).collect());
self
}
pub async fn send(self) -> Result<PoolInfo> {
let mut body = Map::new();
body.insert("name".to_string(), Value::String(self.name.clone()));
if let Some(gpus) = &self.gpus {
body.insert("gpus".to_string(), json_counts(gpus));
}
if let Some(caps) = &self.gpu_caps {
body.insert("gpu_caps".to_string(), json_counts(caps));
}
if let Some(queue_pool) = self.queue_pool.filter(|value| !value.is_empty()) {
body.insert("queue_pool".to_string(), Value::String(queue_pool));
}
if let Some(bundle) = self.bundle.filter(|value| !value.is_empty()) {
body.insert("bundle".to_string(), Value::String(bundle));
}
if let Some(count) = self.minimum_worker_count {
body.insert("minimum_worker_count".to_string(), Value::from(count));
}
if let Some(models) = &self.pinned_models {
body.insert(
"pinned_models".to_string(),
Value::Array(
models
.iter()
.map(|model| Value::String(model.clone()))
.collect(),
),
);
}
let request = self
.client
.request(Method::POST, "/v1/pools")?
.json_headers()
.body(serde_json::to_vec(&Value::Object(body)).unwrap_or_default());
let response = self
.client
.send_once(request, RetryPolicy::NONE)
.await
.map_err(|error| pool_error(error, &self.name))?;
let pool: PoolInfo = parse_json(&response, "pool")?;
self.client.start_lease(&self.name)?;
Ok(pool)
}
}
fn json_counts(counts: &HashMap<String, u32>) -> Value {
Value::Object(
counts
.iter()
.map(|(key, value)| (key.clone(), Value::from(*value)))
.collect(),
)
}
pub(crate) struct LeaseRenewer {
http: reqwest::Client,
url: reqwest::Url,
edge_headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
name: String,
}
impl LeaseRenewer {
pub(crate) fn new(
http: reqwest::Client,
url: reqwest::Url,
edge_headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
name: String,
) -> Self {
Self {
http,
url,
edge_headers,
name,
}
}
pub(crate) async fn run(self) {
loop {
tokio::time::sleep(backoff::LEASE_RENEWAL_INTERVAL).await;
if !self.renew_round().await {
tracing::error!(
"All {} lease renewal attempts failed for pool '{}'",
backoff::LEASE_RENEWAL_MAX_RETRIES,
self.name
);
}
}
}
async fn renew_round(&self) -> bool {
for attempt in 0..backoff::LEASE_RENEWAL_MAX_RETRIES {
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
headers::JSON_CONTENT_TYPE.parse().unwrap(),
);
for (name, value) in &self.edge_headers {
headers.insert(name.clone(), value.clone());
}
let result = self
.http
.post(self.url.clone())
.headers(headers)
.send()
.await
.map(|response| response.status().is_success());
match result {
Ok(true) => return true,
Ok(false) => tracing::warn!("Lease renewal for pool '{}' was rejected", self.name),
Err(error) => {
tracing::warn!("Lease renewal for pool '{}' failed: {error}", self.name);
}
}
let backoff = Duration::from_secs(1 << attempt.min(3)).min(Duration::from_secs(10));
tokio::time::sleep(backoff).await;
}
false
}
}
impl Client {
fn start_lease(&self, name: &str) -> Result<()> {
let mut leases = self
.inner
.leases
.lock()
.expect("lease registry is not poisoned");
if leases.contains_key(name) {
return Ok(());
}
let url = self.url(&format!("/v1/pools/{name}/renew"))?;
let edge_headers = if self.edge_headers_apply_to(&url) {
self.inner.edge_headers.clone()
} else {
Vec::new()
};
let renewer =
LeaseRenewer::new(self.inner.http.clone(), url, edge_headers, name.to_string());
leases.insert(name.to_string(), tokio::spawn(renewer.run()));
Ok(())
}
fn stop_lease(&self, name: &str) {
if let Some(handle) = self
.inner
.leases
.lock()
.expect("lease registry is not poisoned")
.remove(name)
{
handle.abort();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> Client {
Client::new("https://sie.invalid").unwrap()
}
#[test]
fn the_renewal_url_is_derived_from_the_pool_name() {
let url = client().url("/v1/pools/prod/renew").unwrap();
assert_eq!(url.as_str(), "https://sie.invalid/v1/pools/prod/renew");
}
#[test]
fn errors_are_restated_as_pool_errors() {
let error = pool_error(
Error::Request {
message: "gpu type not configured".to_string(),
code: None,
status: 400,
request: None,
},
"prod",
);
match error {
Error::Pool {
message, pool_name, ..
} => {
assert_eq!(pool_name.as_deref(), Some("prod"));
assert!(message.contains("gpu type not configured"), "{message}");
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn starting_the_same_lease_twice_spawns_one_task() {
let client = client();
client.start_lease("prod").unwrap();
client.start_lease("prod").unwrap();
assert_eq!(client.inner.leases.lock().unwrap().len(), 1);
client.stop_lease("prod");
assert!(client.inner.leases.lock().unwrap().is_empty());
}
}