use dashmap::DashMap;
use std::sync::Arc;
use crate::error::LimiteronError;
use crate::limiters::{Limiter, RateLimitSnapshot, TokenBucketLimiter};
#[async_trait::async_trait]
pub trait QueryThrottle: Send + Sync {
async fn acquire(&self, shard_id: &str, cost: u64) -> Result<bool, LimiteronError>;
async fn remaining(&self, shard_id: &str) -> Result<RateLimitSnapshot, LimiteronError>;
}
pub struct LimiteronQueryThrottle {
budget: u64,
buckets: DashMap<String, Arc<TokenBucketLimiter>>,
}
impl LimiteronQueryThrottle {
pub fn new(budget: u64) -> Self {
Self {
budget,
buckets: DashMap::new(),
}
}
pub fn budget(&self) -> u64 {
self.budget
}
pub fn tracked_shards(&self) -> usize {
self.buckets.len()
}
fn bucket_for(&self, shard_id: &str) -> Arc<TokenBucketLimiter> {
if let Some(b) = self.buckets.get(shard_id)
&& b.capacity() == self.budget
{
return b.value().clone();
}
let bucket = Arc::new(TokenBucketLimiter::new(self.budget, self.budget));
self.buckets.insert(shard_id.to_string(), bucket.clone());
bucket
}
}
#[async_trait::async_trait]
impl QueryThrottle for LimiteronQueryThrottle {
async fn acquire(&self, shard_id: &str, cost: u64) -> Result<bool, LimiteronError> {
if cost == 0 {
return Err(LimiteronError::ConfigError(
"QueryThrottle cost cannot be zero".to_string(),
));
}
self.bucket_for(shard_id).allow(cost).await
}
async fn remaining(&self, shard_id: &str) -> Result<RateLimitSnapshot, LimiteronError> {
self.bucket_for(shard_id).remaining().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_t617_query_throttle_acquire_within_budget() {
let throttle = LimiteronQueryThrottle::new(10);
assert!(throttle.acquire("s0", 5).await.unwrap());
assert_eq!(throttle.remaining("s0").await.unwrap().remaining, 5);
assert_eq!(throttle.tracked_shards(), 1);
}
#[tokio::test]
async fn test_t617_query_throttle_all_or_nothing() {
let throttle = LimiteronQueryThrottle::new(10);
assert!(throttle.acquire("s0", 6).await.unwrap());
assert!(
!throttle.acquire("s0", 6).await.unwrap(),
"gap 6 > remaining 4 -> reject"
);
assert_eq!(
throttle.remaining("s0").await.unwrap().remaining,
4,
"a rejection must not deduct budget"
);
assert!(throttle.acquire("s0", 4).await.unwrap());
assert_eq!(throttle.remaining("s0").await.unwrap().remaining, 0);
}
#[tokio::test]
async fn test_t617_query_throttle_shards_isolated() {
let throttle = LimiteronQueryThrottle::new(5);
assert!(throttle.acquire("db-a", 5).await.unwrap());
assert!(!throttle.acquire("db-a", 1).await.unwrap());
assert!(
throttle.acquire("db-b", 5).await.unwrap(),
"sibling shard budgets are independent"
);
assert_eq!(throttle.tracked_shards(), 2);
}
#[tokio::test]
async fn test_t617_query_throttle_remaining_is_non_consuming() {
let throttle = LimiteronQueryThrottle::new(20);
assert!(throttle.acquire("s1", 8).await.unwrap());
let snap1 = throttle.remaining("s1").await.unwrap();
assert_eq!(snap1.remaining, 12);
assert_eq!(snap1.limit, 20);
let snap2 = throttle.remaining("s1").await.unwrap();
assert_eq!(snap1, snap2, "consecutive peeks must not deduct budget");
}
#[tokio::test]
async fn test_t617_query_throttle_zero_cost_rejected() {
let throttle = LimiteronQueryThrottle::new(10);
assert!(throttle.acquire("s0", 0).await.is_err());
}
}