use crate::error::{ConsumeResult, StorageError};
use crate::storage::{QuotaInfo, QuotaStorage};
use async_trait::async_trait;
use chrono::Utc;
use oxcache::backend::AtomicCacheWriter;
use oxcache::backend::CacheBackend;
use oxcache::error::OxCacheError;
use std::sync::Arc;
use std::time::Duration;
fn map_error(e: OxCacheError) -> StorageError {
match e {
OxCacheError::Connection(_) | OxCacheError::Timeout(_) => {
StorageError::ConnectionError(e.to_string())
}
_ => StorageError::QueryError(e.to_string()),
}
}
fn counter_key(user_id: &str, resource: &str, bucket: u64) -> String {
format!("quota:{user_id}:{resource}:{bucket}")
}
fn meta_key(user_id: &str, resource: &str) -> String {
format!("quota:{user_id}:{resource}:meta")
}
fn window_secs(window: Duration) -> u64 {
window.as_secs().max(1)
}
pub struct CacheQuotaStorage {
backend: Arc<dyn CacheBackend>,
rw_lock: tokio::sync::Mutex<()>,
}
impl CacheQuotaStorage {
pub fn new(backend: Arc<dyn CacheBackend>) -> Self {
Self {
backend,
rw_lock: tokio::sync::Mutex::new(()),
}
}
async fn consume_atomic(
&self,
atomic: &dyn AtomicCacheWriter,
key: &str,
cost: u64,
limit: u64,
window: Duration,
) -> Result<ConsumeResult, StorageError> {
let new_total = atomic
.incr(key, cost as i64, Some(window))
.await
.map_err(map_error)?;
if new_total > limit as i64 {
let rolled_back = atomic
.incr(key, -(cost as i64), Some(window))
.await
.map_err(map_error)?;
let used = rolled_back.max(0) as u64;
return Ok(ConsumeResult::rejected(used, limit));
}
Ok(ConsumeResult::allowed(new_total.max(0) as u64, limit))
}
async fn consume_fallback(
&self,
key: &str,
cost: u64,
limit: u64,
window: Duration,
) -> Result<ConsumeResult, StorageError> {
let _guard = self.rw_lock.lock().await;
let current = self.read_counter(key).await?;
let new_total = current.saturating_add(cost);
if new_total > limit {
return Ok(ConsumeResult::rejected(current, limit));
}
self.write_counter(key, new_total, window).await?;
Ok(ConsumeResult::allowed(new_total, limit))
}
async fn read_counter(&self, key: &str) -> Result<u64, StorageError> {
match self.backend.get(key).await.map_err(map_error)? {
Some(data) => std::str::from_utf8(&data)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.ok_or_else(|| StorageError::QueryError("quota counter 解析失败".to_string())),
None => Ok(0),
}
}
async fn write_counter(
&self,
key: &str,
value: u64,
window: Duration,
) -> Result<(), StorageError> {
self.backend
.set(
Arc::from(key),
Arc::new(value.to_string().into_bytes()),
Some(window),
)
.await
.map_err(map_error)
}
}
#[async_trait]
impl QuotaStorage for CacheQuotaStorage {
async fn get_quota(
&self,
user_id: &str,
resource: &str,
) -> Result<Option<QuotaInfo>, StorageError> {
let meta_key = meta_key(user_id, resource);
let raw = self.backend.get(&meta_key).await.map_err(map_error)?;
let Some(data) = raw else {
return Ok(None);
};
let v: serde_json::Value =
serde_json::from_slice(&data).map_err(|e| StorageError::QueryError(format!("{e}")))?;
let limit = v
.get("limit")
.and_then(|n| n.as_u64())
.ok_or_else(|| StorageError::QueryError("meta missing limit".to_string()))?;
let window_start = v
.get("window_start")
.and_then(|n| n.as_i64())
.ok_or_else(|| StorageError::QueryError("meta missing window_start".to_string()))?;
let window_end = v
.get("window_end")
.and_then(|n| n.as_i64())
.ok_or_else(|| StorageError::QueryError("meta missing window_end".to_string()))?;
let bucket = (window_start as u64)
/ window_secs(Duration::from_secs(
(window_end - window_start).max(1) as u64
));
let consumed = self
.read_counter(&counter_key(user_id, resource, bucket))
.await?;
let window_start = chrono::DateTime::from_timestamp(window_start, 0)
.ok_or_else(|| StorageError::QueryError("invalid window_start".to_string()))?;
let window_end = chrono::DateTime::from_timestamp(window_end, 0)
.ok_or_else(|| StorageError::QueryError("invalid window_end".to_string()))?;
Ok(Some(QuotaInfo {
consumed,
limit,
window_start,
window_end,
}))
}
async fn consume(
&self,
user_id: &str,
resource: &str,
cost: u64,
limit: u64,
window: Duration,
) -> Result<ConsumeResult, StorageError> {
if cost == 0 {
let info = self.get_quota(user_id, resource).await?;
let consumed = info.as_ref().map(|i| i.consumed).unwrap_or(0);
return Ok(ConsumeResult::allowed(consumed, limit));
}
let now = Utc::now();
let bucket_secs = window_secs(window);
let bucket = now.timestamp() as u64 / bucket_secs;
let key = counter_key(user_id, resource, bucket);
let mkey = meta_key(user_id, resource);
let meta = serde_json::json!({
"limit": limit,
"window_start": bucket * bucket_secs,
"window_end": (bucket + 1) * bucket_secs,
});
let meta_bytes =
serde_json::to_vec(&meta).map_err(|e| StorageError::QueryError(format!("{e}")))?;
self.backend
.set(
Arc::from(mkey.as_str()),
Arc::new(meta_bytes),
Some(window + window),
)
.await
.map_err(map_error)?;
if cost > limit {
return Ok(ConsumeResult::rejected(0, limit));
}
if let Some(atomic) = self.backend.as_atomic_writer() {
return self.consume_atomic(atomic, &key, cost, limit, window).await;
}
self.consume_fallback(&key, cost, limit, window).await
}
async fn reset(
&self,
user_id: &str,
resource: &str,
limit: u64,
window: Duration,
) -> Result<(), StorageError> {
let _guard = self.rw_lock.lock().await;
let now = Utc::now();
let bucket_secs = window_secs(window);
let bucket = now.timestamp() as u64 / bucket_secs;
let meta = serde_json::json!({
"limit": limit,
"window_start": bucket * bucket_secs,
"window_end": (bucket + 1) * bucket_secs,
});
let meta_bytes =
serde_json::to_vec(&meta).map_err(|e| StorageError::QueryError(format!("{e}")))?;
self.backend
.set(
Arc::from(meta_key(user_id, resource).as_str()),
Arc::new(meta_bytes),
Some(window + window),
)
.await
.map_err(map_error)?;
self.write_counter(&counter_key(user_id, resource, bucket), 0, window)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use oxcache::backend::memory::DashMapMemoryBackend;
use std::sync::Arc;
fn make_backend() -> Arc<dyn CacheBackend> {
Arc::new(DashMapMemoryBackend::new())
}
#[tokio::test]
async fn test_consume_allowed() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u1", "api", 10, 100, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
assert_eq!(r.remaining, 90);
}
#[tokio::test]
async fn test_consume_denied() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u1", "api", 100, 50, Duration::from_secs(60))
.await
.unwrap();
assert!(!r.allowed);
}
#[tokio::test]
async fn test_consume_limit_exceeded() {
let qs = CacheQuotaStorage::new(make_backend());
qs.consume("u1", "api", 40, 50, Duration::from_secs(60))
.await
.unwrap();
let r = qs
.consume("u1", "api", 20, 50, Duration::from_secs(60))
.await
.unwrap();
assert!(!r.allowed);
}
#[tokio::test]
async fn test_consume_window_reset() {
let qs = CacheQuotaStorage::new(make_backend());
qs.consume("u1", "api", 40, 50, Duration::from_secs(1))
.await
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let r = qs
.consume("u1", "api", 30, 50, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
}
#[tokio::test]
async fn test_get_quota_none() {
let qs = CacheQuotaStorage::new(make_backend());
let q = qs.get_quota("u1", "nonexistent").await.unwrap();
assert!(q.is_none());
}
#[tokio::test]
async fn test_get_quota_after_consume() {
let qs = CacheQuotaStorage::new(make_backend());
qs.consume("u1", "api", 25, 100, Duration::from_secs(60))
.await
.unwrap();
let q = qs.get_quota("u1", "api").await.unwrap().unwrap();
assert_eq!(q.consumed, 25);
assert_eq!(q.limit, 100);
}
#[tokio::test]
async fn test_reset() {
let qs = CacheQuotaStorage::new(make_backend());
qs.consume("u1", "api", 80, 100, Duration::from_secs(60))
.await
.unwrap();
qs.reset("u1", "api", 200, Duration::from_secs(120))
.await
.unwrap();
let q = qs.get_quota("u1", "api").await.unwrap().unwrap();
assert_eq!(q.consumed, 0);
assert_eq!(q.limit, 200);
}
#[tokio::test]
async fn test_consume_zero_cost() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u1", "api", 0, 100, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
assert_eq!(r.remaining, 100);
}
#[tokio::test]
async fn test_consume_exact_limit() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u1", "api", 100, 100, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
assert_eq!(r.remaining, 0);
}
#[tokio::test]
async fn test_multiple_users() {
let qs = CacheQuotaStorage::new(make_backend());
qs.consume("u1", "api", 30, 100, Duration::from_secs(60))
.await
.unwrap();
qs.consume("u2", "api", 80, 100, Duration::from_secs(60))
.await
.unwrap();
let q1 = qs.get_quota("u1", "api").await.unwrap().unwrap();
let q2 = qs.get_quota("u2", "api").await.unwrap().unwrap();
assert_eq!(q1.consumed, 30);
assert_eq!(q2.consumed, 80);
}
#[tokio::test]
async fn test_arc_trait_object() {
let qs: Arc<dyn QuotaStorage> = Arc::new(CacheQuotaStorage::new(make_backend()));
let r = qs
.consume("u1", "api", 10, 100, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
}
#[tokio::test]
async fn test_consume_zero_limit_allowed_zero_cost() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u_zero", "api", 0, 0, Duration::from_secs(60))
.await
.unwrap();
assert!(r.allowed);
assert_eq!(r.remaining, 0);
assert_eq!(r.usage_percent, 0.0);
}
#[tokio::test]
async fn test_consume_zero_limit_denied_nonzero_cost() {
let qs = CacheQuotaStorage::new(make_backend());
let r = qs
.consume("u_zero2", "api", 5, 0, Duration::from_secs(60))
.await
.unwrap();
assert!(!r.allowed);
assert_eq!(r.remaining, 0);
assert_eq!(r.usage_percent, 0.0);
}
#[tokio::test]
async fn test_consume_zero_limit_after_existing_consumption() {
let qs = CacheQuotaStorage::new(make_backend());
let r1 = qs
.consume("u_zero3", "api", 0, 0, Duration::from_secs(60))
.await
.unwrap();
assert!(r1.allowed);
let r2 = qs
.consume("u_zero3", "api", 1, 0, Duration::from_secs(60))
.await
.unwrap();
assert!(!r2.allowed);
assert_eq!(r2.usage_percent, 0.0);
}
#[test]
fn test_map_error_connection() {
let err = map_error(OxCacheError::Connection("conn fail".to_string()));
assert!(matches!(err, StorageError::ConnectionError(_)));
}
#[test]
fn test_map_error_timeout() {
let err = map_error(OxCacheError::Timeout("timed out".to_string()));
assert!(matches!(err, StorageError::ConnectionError(_)));
}
#[test]
fn test_map_error_other() {
let err = map_error(OxCacheError::NotFound("not found".to_string()));
assert!(matches!(err, StorageError::QueryError(_)));
}
#[tokio::test]
async fn test_get_quota_with_existing_data() {
let backend = make_backend();
let qs = CacheQuotaStorage::new(backend.clone());
qs.consume("u_get", "api", 30, 200, Duration::from_secs(60))
.await
.unwrap();
let q = qs.get_quota("u_get", "api").await.unwrap().unwrap();
assert_eq!(q.consumed, 30);
assert_eq!(q.limit, 200);
}
#[tokio::test]
async fn test_get_quota_invalid_meta() {
let backend = make_backend();
let qs = CacheQuotaStorage::new(backend.clone());
let key = meta_key("u_bad", "api");
backend
.set(
Arc::from(key.as_str()),
Arc::new(b"not json".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.unwrap();
let result = qs.get_quota("u_bad", "api").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_consume_with_invalid_counter() {
let backend = make_backend();
let qs = CacheQuotaStorage::new(backend.clone());
let bucket = Utc::now().timestamp() as u64 / 3600;
let key = counter_key("u_invjson", "api", bucket);
backend
.set(
Arc::from(key.as_str()),
Arc::new(b"not-a-number".to_vec()),
Some(Duration::from_secs(7200)),
)
.await
.unwrap();
let result = qs
.consume("u_invjson", "api", 10, 100, Duration::from_secs(3600))
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_consume_fallback_no_overshoot_under_concurrency() {
let qs = Arc::new(CacheQuotaStorage::new(make_backend()));
let limit = 50u64;
let tasks = 20usize;
let per_task = 10u64;
let mut handles = Vec::with_capacity(tasks);
for _ in 0..tasks {
let qs = Arc::clone(&qs);
handles.push(tokio::spawn(async move {
let mut allowed = 0u64;
for _ in 0..per_task {
if qs
.consume("u_conc", "api", 1, limit, Duration::from_secs(60))
.await
.unwrap()
.allowed
{
allowed += 1;
}
}
allowed
}));
}
let total: u64 = futures::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.sum();
assert!(total <= limit, "并发放行数 {total} 超过上限 {limit}");
}
}