use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::auth::KeyPlan;
use crate::error::ApiError;
use crate::ingest::IngestCaps;
use crate::keys::SELF_HOSTED_PLAN;
pub const HOSTED_MAX_ACTIVE_INDEXES: usize = 25;
pub const HOSTED_MAX_QUERIES_PER_MONTH: u64 = 10_000;
pub const HOSTED_MAX_FILES_PER_INDEX: usize = 50_000;
pub const HOSTED_QUERY_TIMEOUT_SECS: u64 = 30;
pub const HOSTED_MAX_CONCURRENT_INDEX_OPS: usize = 1;
pub const GLOBAL_MAX_CONCURRENT_QUERIES: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlanLimits {
pub max_active_indexes: Option<usize>,
pub max_queries_per_month: Option<u64>,
pub max_files_per_index: Option<usize>,
pub query_timeout: Option<Duration>,
pub max_concurrent_index_ops: Option<usize>,
pub ingest: IngestCaps,
}
const HOSTED_LIMITS: PlanLimits = PlanLimits {
max_active_indexes: Some(HOSTED_MAX_ACTIVE_INDEXES),
max_queries_per_month: Some(HOSTED_MAX_QUERIES_PER_MONTH),
max_files_per_index: Some(HOSTED_MAX_FILES_PER_INDEX),
query_timeout: Some(Duration::from_secs(HOSTED_QUERY_TIMEOUT_SECS)),
max_concurrent_index_ops: Some(HOSTED_MAX_CONCURRENT_INDEX_OPS),
ingest: IngestCaps::HOSTED,
};
const SELF_HOSTED_LIMITS: PlanLimits = PlanLimits {
max_active_indexes: None,
max_queries_per_month: None,
max_files_per_index: None,
query_timeout: None,
max_concurrent_index_ops: None,
ingest: IngestCaps::UNLIMITED,
};
pub fn plan_limits(plan: &str) -> PlanLimits {
match plan {
SELF_HOSTED_PLAN => SELF_HOSTED_LIMITS,
_ => HOSTED_LIMITS,
}
}
#[derive(Debug)]
pub struct Envelope {
query_permits: Arc<Semaphore>,
index_permits: Mutex<HashMap<String, Arc<Semaphore>>>,
}
impl Envelope {
pub fn new() -> Self {
Envelope::with_query_permits(GLOBAL_MAX_CONCURRENT_QUERIES)
}
pub fn with_query_permits(permits: usize) -> Self {
Envelope {
query_permits: Arc::new(Semaphore::new(permits)),
index_permits: Mutex::new(HashMap::new()),
}
}
pub async fn acquire_query(&self) -> OwnedSemaphorePermit {
self.query_permits
.clone()
.acquire_owned()
.await
.expect("the query semaphore is never closed")
}
pub fn try_acquire_query(&self) -> Option<OwnedSemaphorePermit> {
self.query_permits.clone().try_acquire_owned().ok()
}
pub fn try_acquire_index_op(
&self,
key_id: &str,
max_concurrent: usize,
) -> Option<OwnedSemaphorePermit> {
let semaphore = {
let mut map = self.index_permits.lock().ok()?;
map.entry(key_id.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(max_concurrent)))
.clone()
};
semaphore.try_acquire_owned().ok()
}
}
impl Default for Envelope {
fn default() -> Self {
Envelope::new()
}
}
pub async fn query_envelope(
State(envelope): State<Arc<Envelope>>,
request: Request,
next: Next,
) -> Response {
let plan = request
.extensions()
.get::<KeyPlan>()
.map(|plan| plan.as_str().to_string());
let timeout = plan
.as_deref()
.map(|name| plan_limits(name).query_timeout)
.unwrap_or(None);
let _permit = envelope.acquire_query().await;
match within(timeout, next.run(request)).await {
Some(response) => response,
None => ApiError::request_timeout(
plan.as_deref().unwrap_or(""),
timeout.map(|limit| limit.as_secs()).unwrap_or(0),
)
.into_response(),
}
}
async fn within<T>(limit: Option<Duration>, future: impl Future<Output = T>) -> Option<T> {
match limit {
None => Some(future.await),
Some(limit) => tokio::time::timeout(limit, future).await.ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::DEFAULT_PLAN;
#[test]
fn hosted_plan_carries_the_offer_numbers() {
let limits = plan_limits(DEFAULT_PLAN);
assert_eq!(limits.max_active_indexes, Some(25));
assert_eq!(limits.max_queries_per_month, Some(10_000));
assert_eq!(limits.max_files_per_index, Some(50_000));
assert_eq!(
limits.query_timeout,
Some(Duration::from_secs(30)),
"a query timeout exists and is the documented value"
);
assert_eq!(limits.max_concurrent_index_ops, Some(1));
assert_eq!(limits.ingest, IngestCaps::HOSTED);
}
#[test]
fn self_hosted_plan_is_unlimited() {
let limits = plan_limits(SELF_HOSTED_PLAN);
assert_eq!(limits.max_active_indexes, None);
assert_eq!(limits.max_queries_per_month, None);
assert_eq!(limits.max_files_per_index, None);
assert_eq!(limits.query_timeout, None);
assert_eq!(limits.max_concurrent_index_ops, None);
assert_eq!(limits.ingest, IngestCaps::UNLIMITED);
}
#[test]
fn an_unknown_plan_gets_the_tenant_envelope_not_the_operators() {
assert_eq!(plan_limits("hosed"), plan_limits(DEFAULT_PLAN));
assert_eq!(plan_limits(""), plan_limits(DEFAULT_PLAN));
}
#[test]
fn global_query_permits_run_out_at_the_cap() {
let envelope = Envelope::with_query_permits(2);
let first = envelope.try_acquire_query().expect("first slot");
let second = envelope.try_acquire_query().expect("second slot");
assert!(envelope.try_acquire_query().is_none(), "cap reached");
drop(first);
assert!(
envelope.try_acquire_query().is_some(),
"a freed slot can be taken again"
);
drop(second);
}
#[test]
fn index_op_permits_are_per_key_and_refuse_when_taken() {
let envelope = Envelope::new();
let held = envelope
.try_acquire_index_op("tenant-a", 1)
.expect("first index op takes the slot");
assert!(
envelope.try_acquire_index_op("tenant-a", 1).is_none(),
"the same key's second op is refused at a cap of one"
);
assert!(
envelope.try_acquire_index_op("tenant-b", 1).is_some(),
"another key has its own slot"
);
drop(held);
assert!(
envelope.try_acquire_index_op("tenant-a", 1).is_some(),
"the slot returns when the op ends"
);
}
#[tokio::test]
async fn within_reports_a_miss_past_the_limit() {
let slow = within(
Some(Duration::from_millis(10)),
tokio::time::sleep(Duration::from_secs(60)),
)
.await;
assert!(slow.is_none(), "a future past its limit is a miss");
let fast = within(Some(Duration::from_secs(60)), async { 7 }).await;
assert_eq!(fast, Some(7));
let unbounded = within(None, async { 9 }).await;
assert_eq!(unbounded, Some(9), "no limit always waits");
}
}