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>>,
}
impl InMemoryDistributedLimiter {
pub fn new() -> Self {
Self {
counters: Arc::new(DashMap::new()),
ttl_counters: Arc::new(DashMap::new()),
}
}
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> {
self.incr("_global", cost).await?;
Ok(true)
}
}
#[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);
entry.expires_at = expires_at;
} 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) {
if 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_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#"
local val = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
return val
"#;
#[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,
}
#[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,
}
}
async fn eval_lua_int(
&self,
script: &str,
keys: &[&str],
args: &[&str],
) -> Result<u64, LimiteronError> {
let value = self.cache.eval_lua(script, keys, args).await.map_err(|e| {
LimiteronError::StorageError(StorageError::QueryError(format!(
"Lua eval failed: {}",
e
)))
})?;
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.cache.eval_lua(script, keys, args).await.map_err(|e| {
LimiteronError::StorageError(StorageError::QueryError(format!(
"Lua eval failed: {}",
e
)))
})?;
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),
))),
}
}
}
#[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 result = self
.eval_lua_array(
crate::oxcache_lua::FIXED_WINDOW_SCRIPT,
&["_global"],
&[
&self.window_ms.to_string(),
&self.capacity.to_string(),
&now_ms.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()
);
}
}