use std::sync::Arc;
use dashmap::DashMap;
use super::token_bucket::TokenBucketLimiter;
use super::traits::Limiter;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct PrefetchResult {
pub key: String,
pub requested: u64,
pub granted: bool,
}
#[derive(Default)]
pub struct BatchTokenPrefetcher {
buckets: DashMap<String, Arc<TokenBucketLimiter>>,
}
impl BatchTokenPrefetcher {
pub fn new() -> Self {
Self::default()
}
pub async fn prefetch(&self, key: &str, tokens: u64) -> PrefetchResult {
let granted = if tokens == 0 {
false
} else {
let limiter = self.bucket_for(key, tokens);
limiter.allow(tokens).await.unwrap_or_else(|e| {
log::warn!(target: "limiteron", "prefetch allow failed for key '{key}': {e}");
false
})
};
PrefetchResult {
key: key.to_string(),
requested: tokens,
granted,
}
}
pub async fn prefetch_batch(&self, items: &[(String, u64)]) -> Vec<PrefetchResult> {
let mut results = Vec::with_capacity(items.len());
for (key, tokens) in items {
results.push(self.prefetch(key, *tokens).await);
}
results
}
pub fn tracked_keys(&self) -> usize {
self.buckets.len()
}
fn bucket_for(&self, key: &str, tokens: u64) -> Arc<TokenBucketLimiter> {
if let Some(existing) = self.buckets.get(key)
&& existing.capacity() == tokens
{
return existing.value().clone();
}
let limiter = Arc::new(TokenBucketLimiter::new(tokens, tokens));
self.buckets.insert(key.to_string(), limiter.clone());
limiter
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_t613_prefetch_grants_when_budget_available() {
let prefetcher = BatchTokenPrefetcher::new();
let r = prefetcher.prefetch("client-a", 10).await;
assert!(r.granted);
assert_eq!(r.requested, 10);
assert_eq!(prefetcher.tracked_keys(), 1);
}
#[tokio::test]
async fn test_t613_prefetch_rejects_when_budget_exhausted() {
let prefetcher = BatchTokenPrefetcher::new();
assert!(prefetcher.prefetch("client-a", 5).await.granted);
let r = prefetcher.prefetch("client-a", 5).await;
assert!(!r.granted, "exhausted budget must reject");
}
#[tokio::test]
async fn test_t613_prefetch_capacity_change_rebuilds_bucket() {
let prefetcher = BatchTokenPrefetcher::new();
assert!(prefetcher.prefetch("client-b", 3).await.granted);
let r = prefetcher.prefetch("client-b", 20).await;
assert!(r.granted, "rebuilt bucket has fresh budget");
}
#[tokio::test]
async fn test_t613_prefetch_batch_orders_and_isolates() {
let prefetcher = BatchTokenPrefetcher::new();
let items = vec![
("k1".to_string(), 5),
("k2".to_string(), 5),
("k3".to_string(), 5),
];
let results = prefetcher.prefetch_batch(&items).await;
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.granted));
assert_eq!(results[1].key, "k2");
}
#[tokio::test]
async fn test_t613_prefetch_zero_tokens_reported_not_fatal() {
let prefetcher = BatchTokenPrefetcher::new();
let items = vec![("bad".to_string(), 0), ("good".to_string(), 2)];
let results = prefetcher.prefetch_batch(&items).await;
assert!(!results[0].granted);
assert!(results[1].granted);
}
#[tokio::test]
async fn test_t613_prefetch_keys_are_isolated() {
let prefetcher = BatchTokenPrefetcher::new();
assert!(prefetcher.prefetch("iso-1", 2).await.granted);
assert!(!prefetcher.prefetch("iso-1", 2).await.granted);
let r = prefetcher.prefetch("iso-2", 2).await;
assert!(r.granted, "sibling key must be isolated");
}
}