use crate::error::{ConsumeResult, StorageError};
use crate::i18n::t;
#[cfg(feature = "lua-script")]
use crate::oxcache_lua::QUOTA_CONSUME_SCRIPT;
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;
#[cfg(feature = "lua-script")]
mod lua_mode {
pub const F_CONSUMED: &str = "consumed";
pub const F_LIMIT: &str = "limit";
pub const F_WINDOW_START: &str = "window_start";
pub const F_WINDOW_END: &str = "window_end";
pub const REFUND_SCRIPT: &str = r#"
local consumed = tonumber(redis.call('HGET', KEYS[1], 'consumed')) or 0
local nv = math.max(consumed - tonumber(ARGV[1]), 0)
redis.call('HSET', KEYS[1], 'consumed', nv)
return nv
"#;
pub const RESET_SCRIPT: &str = r#"
redis.call('DEL', KEYS[1])
return 1
"#;
pub const GET_SCRIPT: &str = r#"
local consumed = tonumber(redis.call('HGET', KEYS[1], 'consumed'))
if not consumed then
return nil
end
return {
consumed,
tonumber(redis.call('HGET', KEYS[1], 'limit')) or 0,
tonumber(redis.call('HGET', KEYS[1], 'window_start')) or 0,
tonumber(redis.call('HGET', KEYS[1], 'window_end')) or 0,
}
"#;
}
#[cfg(feature = "lua-script")]
fn lua_int(v: &redis::Value) -> Result<i64, StorageError> {
match v {
redis::Value::Int(n) => Ok(*n),
redis::Value::BulkString(bytes) => String::from_utf8_lossy(bytes)
.trim()
.parse::<i64>()
.map_err(|e| StorageError::QueryError(format!("Lua int parse: {e}"))),
other => Err(StorageError::QueryError(format!(
"unexpected Lua response: {other:?}"
))),
}
}
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<()>,
#[cfg(feature = "lua-script")]
lua: Option<Arc<dyn oxcache::backend::LuaExecutor>>,
}
impl CacheQuotaStorage {
pub fn new(backend: Arc<dyn CacheBackend>) -> Self {
Self {
backend,
rw_lock: tokio::sync::Mutex::new(()),
#[cfg(feature = "lua-script")]
lua: None,
}
}
#[cfg(feature = "lua-script")]
pub fn with_lua_executor(
backend: Arc<dyn CacheBackend>,
lua: Arc<dyn oxcache::backend::LuaExecutor>,
) -> Self {
Self {
backend,
rw_lock: tokio::sync::Mutex::new(()),
lua: Some(lua),
}
}
#[cfg(feature = "lua-script")]
async fn consume_lua(
&self,
lua: &Arc<dyn oxcache::backend::LuaExecutor>,
user_id: &str,
resource: &str,
cost: u64,
limit: u64,
window: Duration,
) -> Result<ConsumeResult, StorageError> {
let key = format!("quota:{user_id}:{resource}:ledger");
let now = Utc::now();
let bucket_secs = window_secs(window);
let bucket = now.timestamp() as u64 / bucket_secs;
let overdraft: u64 = 0;
let value = lua
.eval_lua(
QUOTA_CONSUME_SCRIPT,
&[&key],
&[
&cost.to_string(),
&limit.to_string(),
&overdraft.to_string(),
&(bucket * bucket_secs).to_string(),
&((bucket + 1) * bucket_secs).to_string(),
lua_mode::F_CONSUMED,
lua_mode::F_LIMIT,
lua_mode::F_WINDOW_START,
lua_mode::F_WINDOW_END,
],
)
.await
.map_err(|e| StorageError::QueryError(format!("Lua eval failed: {e}")))?;
let redis::Value::Array(items) = value else {
return Err(StorageError::QueryError(format!(
"unexpected quota lua response: {value:?}"
)));
};
if items.len() < 2 {
return Err(StorageError::QueryError(
"quota lua response too short".to_string(),
));
}
let allowed = lua_int(&items[0])? == 1;
let consumed = lua_int(&items[1])?.max(0) as u64;
if allowed {
Ok(ConsumeResult::allowed(consumed, limit))
} else {
Ok(ConsumeResult::rejected(consumed, limit))
}
}
#[cfg(feature = "lua-script")]
fn ledger_key(user_id: &str, resource: &str) -> String {
format!("quota:{user_id}:{resource}:ledger")
}
#[cfg(feature = "lua-script")]
async fn get_quota_lua(
&self,
lua: &Arc<dyn oxcache::backend::LuaExecutor>,
user_id: &str,
resource: &str,
) -> Result<Option<QuotaInfo>, StorageError> {
let key = Self::ledger_key(user_id, resource);
let value = lua
.eval_lua(lua_mode::GET_SCRIPT, &[&key], &[])
.await
.map_err(|e| StorageError::QueryError(format!("Lua eval failed: {e}")))?;
let redis::Value::Nil = value else {
let redis::Value::Array(items) = value else {
return Err(StorageError::QueryError(format!(
"unexpected quota lua response: {value:?}"
)));
};
if items.len() < 4 {
return Err(StorageError::QueryError(
"quota lua response too short".to_string(),
));
}
let consumed = lua_int(&items[0])?.max(0) as u64;
let limit = lua_int(&items[1])?.max(0) as u64;
let ws = lua_int(&items[2])?;
let we = lua_int(&items[3])?;
let from_ts = |ts: i64| {
chrono::DateTime::from_timestamp(ts, 0)
.ok_or_else(|| StorageError::QueryError("invalid window ts".to_string()))
};
return Ok(Some(QuotaInfo {
consumed,
limit,
window_start: from_ts(ws)?,
window_end: from_ts(we)?,
}));
};
Ok(None)
}
#[cfg(feature = "lua-script")]
async fn reset_lua(
&self,
lua: &Arc<dyn oxcache::backend::LuaExecutor>,
user_id: &str,
resource: &str,
) -> Result<(), StorageError> {
let key = Self::ledger_key(user_id, resource);
lua.eval_lua(lua_mode::RESET_SCRIPT, &[&key], &[])
.await
.map_err(|e| StorageError::QueryError(format!("Lua eval failed: {e}")))?;
Ok(())
}
#[cfg(feature = "lua-script")]
async fn refund_lua(
&self,
lua: &Arc<dyn oxcache::backend::LuaExecutor>,
user_id: &str,
resource: &str,
amount: u64,
) -> Result<u64, StorageError> {
let key = Self::ledger_key(user_id, resource);
let value = lua
.eval_lua(lua_mode::REFUND_SCRIPT, &[&key], &[&amount.to_string()])
.await
.map_err(|e| StorageError::QueryError(format!("Lua eval failed: {e}")))?;
Ok(lua_int(&value)?.max(0) as u64)
}
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(t("cache-quota-counter-parse-failed", &[]))
}),
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> {
#[cfg(feature = "lua-script")]
if let Some(lua) = &self.lua {
return self.get_quota_lua(lua, user_id, resource).await;
}
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));
}
#[cfg(feature = "lua-script")]
if let Some(lua) = &self.lua {
return self
.consume_lua(lua, user_id, resource, cost, limit, window)
.await;
}
if cost > i64::MAX as u64 {
let info = self.get_quota(user_id, resource).await?;
let consumed = info.as_ref().map(|i| i.consumed).unwrap_or(0);
return Ok(ConsumeResult::rejected(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> {
#[cfg(feature = "lua-script")]
if let Some(lua) = &self.lua {
return self.reset_lua(lua, user_id, resource).await;
}
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
}
async fn refund(
&self,
user_id: &str,
resource: &str,
amount: u64,
_limit: u64,
window: Duration,
) -> Result<u64, StorageError> {
#[cfg(feature = "lua-script")]
if let Some(lua) = &self.lua {
return self.refund_lua(lua, user_id, resource, amount).await;
}
let amount = amount.min(i64::MAX as u64);
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);
if let Some(atomic) = self.backend.as_atomic_writer() {
let new_total = atomic
.incr(&key, -(amount as i64), Some(window))
.await
.map_err(map_error)?;
if new_total < 0 {
let restored = atomic
.incr(&key, -new_total, Some(window))
.await
.map_err(map_error)?;
return Ok(restored.max(0) as u64);
}
return Ok(new_total.max(0) as u64);
}
let _guard = self.rw_lock.lock().await;
let current = self.read_counter(&key).await?;
let new_total = current.saturating_sub(amount);
self.write_counter(&key, new_total, window).await?;
Ok(new_total)
}
}
#[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,
"concurrent grants {total} exceed the limit {limit}"
);
}
}