use super::traits::{DistributedLimiter, Limiter};
use crate::error::LimiteronError;
#[cfg(all(feature = "distributed", feature = "lua-script"))]
use crate::error::StorageError;
use async_trait::async_trait;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Clone)]
struct TtlEntry {
count: u64,
expires_at: Instant,
}
pub struct InMemoryDistributedLimiter {
counters: Arc<DashMap<String, u64>>,
ttl_counters: Arc<DashMap<String, TtlEntry>>,
capacity: u64,
}
impl InMemoryDistributedLimiter {
pub fn new() -> Self {
Self {
counters: Arc::new(DashMap::new()),
ttl_counters: Arc::new(DashMap::new()),
capacity: u64::MAX,
}
}
pub fn with_capacity(capacity: u64) -> Self {
Self {
counters: Arc::new(DashMap::new()),
ttl_counters: Arc::new(DashMap::new()),
capacity,
}
}
fn cleanup_expired(&self) {
let now = Instant::now();
self.ttl_counters.retain(|_, entry| entry.expires_at > now);
}
}
impl Default for InMemoryDistributedLimiter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Limiter for InMemoryDistributedLimiter {
async fn allow(&self, cost: u64) -> Result<bool, LimiteronError> {
let new_count = self.incr("_global", cost).await?;
Ok(new_count <= self.capacity)
}
}
#[async_trait]
impl DistributedLimiter for InMemoryDistributedLimiter {
async fn incr(&self, key: &str, amount: u64) -> Result<u64, LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
let new_count = self
.counters
.entry(key.to_string())
.and_modify(|c| *c = c.saturating_add(amount))
.or_insert(amount);
Ok(*new_count)
}
async fn incr_with_ttl(
&self,
key: &str,
amount: u64,
ttl: Duration,
) -> Result<u64, LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
let now = Instant::now();
let expires_at = now + ttl;
self.cleanup_expired();
let new_count = self
.ttl_counters
.entry(key.to_string())
.and_modify(|entry| {
if entry.expires_at > now {
entry.count = entry.count.saturating_add(amount);
} else {
entry.count = amount;
entry.expires_at = expires_at;
}
})
.or_insert(TtlEntry {
count: amount,
expires_at,
});
Ok(new_count.count)
}
async fn get_count(&self, key: &str) -> Result<u64, LimiteronError> {
if let Some(entry) = self.ttl_counters.get(key)
&& entry.expires_at > Instant::now()
{
return Ok(entry.count);
}
if let Some(count) = self.counters.get(key) {
return Ok(*count);
}
Ok(0)
}
async fn reset(&self, key: &str) -> Result<(), LimiteronError> {
self.counters.remove(key);
self.ttl_counters.remove(key);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_incr_new_key() {
let limiter = InMemoryDistributedLimiter::new();
let count = limiter.incr("user:1", 1).await.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_incr_existing_key() {
let limiter = InMemoryDistributedLimiter::new();
limiter.incr("user:1", 5).await.unwrap();
let count = limiter.incr("user:1", 3).await.unwrap();
assert_eq!(count, 8);
}
#[tokio::test]
async fn test_incr_empty_key() {
let limiter = InMemoryDistributedLimiter::new();
let result = limiter.incr("", 1).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_incr_saturating() {
let limiter = InMemoryDistributedLimiter::new();
limiter.incr("user:1", u64::MAX).await.unwrap();
let count = limiter.incr("user:1", 1).await.unwrap();
assert_eq!(count, u64::MAX);
}
#[tokio::test]
async fn test_incr_with_ttl_new_key() {
let limiter = InMemoryDistributedLimiter::new();
let count = limiter
.incr_with_ttl("user:1", 1, Duration::from_secs(60))
.await
.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_incr_with_ttl_no_refresh_on_accumulate() {
let limiter = InMemoryDistributedLimiter::new();
limiter
.incr_with_ttl("k", 1, Duration::from_millis(50))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
limiter
.incr_with_ttl("k", 1, Duration::from_millis(50))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await; let c = limiter
.incr_with_ttl("k", 1, Duration::from_millis(50))
.await
.unwrap();
assert_eq!(
c, 1,
"accumulating refreshed the TTL (should be an expiry reset)"
);
}
#[tokio::test]
async fn test_inmemory_allow_enforces_capacity() {
let limiter = InMemoryDistributedLimiter::with_capacity(1);
assert!(limiter.allow(1).await.unwrap());
assert!(
!limiter.allow(1).await.unwrap(),
"over-capacity must be rejected"
);
}
#[tokio::test]
async fn test_incr_with_ttl_expired() {
let limiter = InMemoryDistributedLimiter::new();
limiter
.incr_with_ttl("user:1", 5, Duration::from_millis(1))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let count = limiter
.incr_with_ttl("user:1", 3, Duration::from_secs(60))
.await
.unwrap();
assert_eq!(count, 3);
}
#[tokio::test]
async fn test_get_count_nonexistent() {
let limiter = InMemoryDistributedLimiter::new();
let count = limiter.get_count("nonexistent").await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_get_count_after_incr() {
let limiter = InMemoryDistributedLimiter::new();
limiter.incr("user:1", 10).await.unwrap();
let count = limiter.get_count("user:1").await.unwrap();
assert_eq!(count, 10);
}
#[tokio::test]
async fn test_get_count_after_ttl_expire() {
let limiter = InMemoryDistributedLimiter::new();
limiter
.incr_with_ttl("user:1", 5, Duration::from_millis(1))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let count = limiter.get_count("user:1").await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_reset() {
let limiter = InMemoryDistributedLimiter::new();
limiter.incr("user:1", 10).await.unwrap();
limiter.reset("user:1").await.unwrap();
let count = limiter.get_count("user:1").await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_reset_ttl_counter() {
let limiter = InMemoryDistributedLimiter::new();
limiter
.incr_with_ttl("user:1", 10, Duration::from_secs(60))
.await
.unwrap();
limiter.reset("user:1").await.unwrap();
let count = limiter.get_count("user:1").await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_concurrent_incr() {
let limiter = Arc::new(InMemoryDistributedLimiter::new());
let mut handles = vec![];
for _ in 0..10 {
let limiter_clone = Arc::clone(&limiter);
handles.push(tokio::spawn(async move {
limiter_clone.incr("concurrent", 1).await.unwrap();
}));
}
for handle in handles {
handle.await.unwrap();
}
let count = limiter.get_count("concurrent").await.unwrap();
assert_eq!(count, 10);
}
#[tokio::test]
async fn test_limiter_trait_compatibility() {
let limiter = InMemoryDistributedLimiter::new();
let allowed = limiter.allow(1).await.unwrap();
assert!(allowed);
let count = limiter.incr("test", 1).await.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_incr_with_ttl_accumulate() {
let limiter = InMemoryDistributedLimiter::new();
let c1 = limiter
.incr_with_ttl("user:1", 3, Duration::from_secs(60))
.await
.unwrap();
assert_eq!(c1, 3);
let c2 = limiter
.incr_with_ttl("user:1", 5, Duration::from_secs(60))
.await
.unwrap();
assert_eq!(c2, 8);
}
#[tokio::test]
async fn test_different_keys_isolated() {
let limiter = InMemoryDistributedLimiter::new();
limiter.incr("user:1", 10).await.unwrap();
limiter.incr("user:2", 20).await.unwrap();
assert_eq!(limiter.get_count("user:1").await.unwrap(), 10);
assert_eq!(limiter.get_count("user:2").await.unwrap(), 20);
}
#[tokio::test]
async fn test_reset_nonexistent_key() {
let limiter = InMemoryDistributedLimiter::new();
let result = limiter.reset("nonexistent").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_incr_with_ttl_empty_key() {
let limiter = InMemoryDistributedLimiter::new();
let result = limiter.incr_with_ttl("", 1, Duration::from_secs(60)).await;
assert!(result.is_err());
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
const REDIS_INCR_SCRIPT: &str = r#"
local val = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
return val
"#;
#[cfg(all(feature = "distributed", feature = "lua-script"))]
const REDIS_INCR_TTL_SCRIPT: &str = r#"
-- TTL 语义=「首递增起算」:仅当递增后计数等于本次增量(key 全新)时
-- 设置 EXPIRE;续增刷新 TTL 会让持续流量的 key 永不过期重置(永久封死)
local new_count = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
if new_count == tonumber(ARGV[1]) then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end
return new_count
"#;
#[cfg(all(feature = "distributed", feature = "lua-script"))]
const REDIS_GET_COUNT_SCRIPT: &str = r#"
local val = redis.call('GET', KEYS[1])
if val == false then return 0 end
return tonumber(val)
"#;
#[cfg(all(feature = "distributed", feature = "lua-script"))]
const REDIS_RESET_SCRIPT: &str = r#"
redis.call('DEL', KEYS[1])
return 1
"#;
#[cfg(all(feature = "distributed", feature = "lua-script"))]
pub struct RedisDistributedLimiter {
cache: oxcache::Cache<String, String>,
capacity: u64,
window_ms: u64,
timeout: std::time::Duration,
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
const EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
#[cfg(all(feature = "distributed", feature = "lua-script"))]
impl RedisDistributedLimiter {
pub fn new(cache: oxcache::Cache<String, String>, capacity: u64, window_ms: u64) -> Self {
Self {
cache,
capacity,
window_ms,
timeout: EVAL_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
async fn eval_raw(
&self,
script: &str,
keys: &[&str],
args: &[&str],
) -> Result<redis::Value, LimiteronError> {
let eval = self.cache.eval_lua(script, keys, args);
let value = if self.timeout.is_zero() {
eval.await
} else {
tokio::time::timeout(self.timeout, eval)
.await
.map_err(|_| {
LimiteronError::StorageError(StorageError::TimeoutError(format!(
"Lua eval timed out after {:?}",
self.timeout
)))
})?
}
.map_err(|e| {
LimiteronError::StorageError(StorageError::QueryError(format!(
"Lua eval failed: {}",
e
)))
})?;
Ok(value)
}
async fn eval_lua_int(
&self,
script: &str,
keys: &[&str],
args: &[&str],
) -> Result<u64, LimiteronError> {
let value = self.eval_raw(script, keys, args).await?;
match value {
redis::Value::Int(n) => Ok(n as u64),
redis::Value::BulkString(bytes) => {
let s = String::from_utf8_lossy(&bytes);
s.parse::<u64>().map_err(|e| {
LimiteronError::StorageError(StorageError::QueryError(format!(
"Lua int parse: {}",
e
)))
})
}
other => Err(LimiteronError::StorageError(StorageError::QueryError(
format!("unexpected Lua response: {:?}", other),
))),
}
}
async fn eval_lua_array(
&self,
script: &str,
keys: &[&str],
args: &[&str],
) -> Result<Vec<i64>, LimiteronError> {
let value = self.eval_raw(script, keys, args).await?;
match value {
redis::Value::Array(arr) => arr
.iter()
.map(|v| match v {
redis::Value::Int(n) => Ok(*n),
redis::Value::BulkString(bytes) => {
let s = String::from_utf8_lossy(bytes);
s.parse::<i64>().map_err(|e| {
LimiteronError::StorageError(StorageError::QueryError(format!(
"parse: {}",
e
)))
})
}
other => Err(LimiteronError::StorageError(StorageError::QueryError(
format!("unexpected array element: {:?}", other),
))),
})
.collect(),
other => Err(LimiteronError::StorageError(StorageError::QueryError(
format!("expected array, got: {:?}", other),
))),
}
}
pub async fn allow_token_bucket(
&self,
key: &str,
capacity: u64,
refill_rate_per_sec: u64,
cost: u64,
) -> Result<(bool, i64), LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
let refill_per_ms = (refill_rate_per_sec.min(1_000_000) as f64) / 1000.0;
let refill_per_ms = refill_per_ms.max(1e-6);
let result = self
.eval_lua_array(
crate::oxcache_lua::TOKEN_BUCKET_SCRIPT,
&[key],
&[
&capacity.to_string(),
&format!("{refill_per_ms:.6}"),
&cost.to_string(),
],
)
.await?;
match result.as_slice() {
[allowed, remaining, _refill] => Ok((*allowed != 0, *remaining)),
_ => Err(LimiteronError::StorageError(StorageError::QueryError(
"unexpected TOKEN_BUCKET response length".to_string(),
))),
}
}
pub async fn allow_sliding_window(
&self,
key: &str,
window_ms: u64,
max_requests: u64,
) -> Result<(bool, i64), LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
let seq_key = format!("{key}:seq");
let result = self
.eval_lua_array(
crate::oxcache_lua::SLIDING_WINDOW_SCRIPT,
&[key, &seq_key],
&[&window_ms.to_string(), &max_requests.to_string()],
)
.await?;
match result.as_slice() {
[allowed, count, _reset] => Ok((*allowed != 0, *count)),
_ => Err(LimiteronError::StorageError(StorageError::QueryError(
"unexpected SLIDING_WINDOW response length".to_string(),
))),
}
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
pub struct RedisTokenBucketLimiter {
inner: Arc<RedisDistributedLimiter>,
key: String,
capacity: u64,
refill_rate_per_sec: u64,
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
impl RedisTokenBucketLimiter {
pub fn new(
inner: Arc<RedisDistributedLimiter>,
key: impl Into<String>,
capacity: u64,
refill_rate_per_sec: u64,
) -> Self {
Self {
inner,
key: key.into(),
capacity,
refill_rate_per_sec,
}
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
#[async_trait]
impl Limiter for RedisTokenBucketLimiter {
async fn allow(&self, cost: u64) -> Result<bool, LimiteronError> {
Ok(self
.inner
.allow_token_bucket(&self.key, self.capacity, self.refill_rate_per_sec, cost)
.await?
.0)
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
pub struct RedisSlidingWindowLimiter {
inner: Arc<RedisDistributedLimiter>,
key: String,
window_ms: u64,
max_requests: u64,
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
impl RedisSlidingWindowLimiter {
pub fn new(
inner: Arc<RedisDistributedLimiter>,
key: impl Into<String>,
window_ms: u64,
max_requests: u64,
) -> Self {
Self {
inner,
key: key.into(),
window_ms,
max_requests,
}
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
#[async_trait]
impl Limiter for RedisSlidingWindowLimiter {
async fn allow(&self, _cost: u64) -> Result<bool, LimiteronError> {
Ok(self
.inner
.allow_sliding_window(&self.key, self.window_ms, self.max_requests)
.await?
.0)
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
#[async_trait]
impl Limiter for RedisDistributedLimiter {
async fn allow(&self, _cost: u64) -> Result<bool, LimiteronError> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let current_window = (now_ms / self.window_ms.max(1)) * self.window_ms.max(1);
let window_key = format!("_global:{current_window}");
let result = self
.eval_lua_array(
crate::oxcache_lua::FIXED_WINDOW_SCRIPT,
&["_global", &window_key],
&[&self.window_ms.to_string(), &self.capacity.to_string()],
)
.await?;
match result.as_slice() {
[allowed, _count, _reset] => Ok(*allowed != 0),
_ => Err(LimiteronError::StorageError(StorageError::QueryError(
"unexpected FIXED_WINDOW response length".to_string(),
))),
}
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
#[async_trait]
impl DistributedLimiter for RedisDistributedLimiter {
async fn incr(&self, key: &str, amount: u64) -> Result<u64, LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
self.eval_lua_int(REDIS_INCR_SCRIPT, &[key], &[&amount.to_string()])
.await
}
async fn incr_with_ttl(
&self,
key: &str,
amount: u64,
ttl: Duration,
) -> Result<u64, LimiteronError> {
if key.is_empty() {
return Err(LimiteronError::ConfigError(
"Key cannot be empty".to_string(),
));
}
let ttl_secs = ttl.as_secs().max(1);
self.eval_lua_int(
REDIS_INCR_TTL_SCRIPT,
&[key],
&[&amount.to_string(), &ttl_secs.to_string()],
)
.await
}
async fn get_count(&self, key: &str) -> Result<u64, LimiteronError> {
self.eval_lua_int(REDIS_GET_COUNT_SCRIPT, &[key], &[]).await
}
async fn reset(&self, key: &str) -> Result<(), LimiteronError> {
self.eval_lua_int(REDIS_RESET_SCRIPT, &[key], &[]).await?;
Ok(())
}
}
#[cfg(all(feature = "distributed", feature = "lua-script"))]
#[cfg(test)]
mod redis_distributed_tests {
use super::*;
#[test]
fn test_redis_distributed_limiter_construction() {
assert!(!REDIS_INCR_SCRIPT.is_empty());
assert!(!REDIS_INCR_TTL_SCRIPT.is_empty());
assert!(!REDIS_GET_COUNT_SCRIPT.is_empty());
assert!(!REDIS_RESET_SCRIPT.is_empty());
}
#[test]
fn test_lua_scripts_contain_redis_commands() {
assert!(REDIS_INCR_SCRIPT.contains("INCRBY"));
assert!(REDIS_INCR_TTL_SCRIPT.contains("INCRBY"));
assert!(REDIS_INCR_TTL_SCRIPT.contains("EXPIRE"));
assert!(REDIS_GET_COUNT_SCRIPT.contains("GET"));
assert!(REDIS_RESET_SCRIPT.contains("DEL"));
}
#[test]
fn test_lua_script_feature_gate_connectivity() {
let manager = crate::oxcache_lua::OxcacheLuaManager::new();
assert!(
manager
.get_script(crate::oxcache_lua::LuaScriptType::FixedWindow)
.is_some()
);
}
}