use super::traits::{Limiter, RateLimitSnapshot, validate_cost};
use crate::clock::{Clock, SystemClock};
use crate::constants::MAX_SLIDING_LOG_REQUESTS;
use crate::error::LimiteronError;
use async_trait::async_trait;
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
pub struct SlidingWindowLogLimiter {
max_requests: u64,
window: Duration,
clock: Arc<dyn Clock>,
state: Mutex<LogState>,
}
#[derive(Debug)]
struct LogState {
entries: VecDeque<(u64, u64)>,
total_count: u64,
expired_cost: u64,
scanned: usize,
}
impl LogState {
fn effective_count(&self) -> u64 {
self.total_count - self.expired_cost
}
fn effective_len(&self) -> usize {
self.entries.len() - self.scanned
}
}
impl SlidingWindowLogLimiter {
pub fn new(max_requests: u64, window: Duration) -> Result<Self, LimiteronError> {
Self::with_clock(max_requests, window, Arc::new(SystemClock))
}
pub fn with_clock(
max_requests: u64,
window: Duration,
clock: Arc<dyn Clock>,
) -> Result<Self, LimiteronError> {
if max_requests == 0 {
return Err(LimiteronError::ConfigError(
"sliding window log max_requests must be non-zero".to_string(),
));
}
if window.is_zero() {
return Err(LimiteronError::ConfigError(
"sliding window log window must be non-zero".to_string(),
));
}
if max_requests > MAX_SLIDING_LOG_REQUESTS {
return Err(LimiteronError::ConfigError(format!(
"sliding window log max_requests must not exceed {MAX_SLIDING_LOG_REQUESTS}"
)));
}
Ok(Self {
max_requests,
window,
clock,
state: Mutex::new(LogState {
entries: VecDeque::new(),
total_count: 0,
expired_cost: 0,
scanned: 0,
}),
})
}
pub fn window(&self) -> Duration {
self.window
}
pub fn max_requests(&self) -> u64 {
self.max_requests
}
pub async fn window_count(&self) -> u64 {
let now = self.clock.unix_timestamp_nanos();
let mut state = self.state.lock();
self.scan_expired(&mut state, now);
state.effective_count()
}
pub async fn logged_entries(&self) -> usize {
let now = self.clock.unix_timestamp_nanos();
let mut state = self.state.lock();
self.scan_expired(&mut state, now);
state.effective_len()
}
fn window_nanos(&self) -> u64 {
u64::try_from(self.window.as_nanos()).unwrap_or(u64::MAX)
}
fn scan_expired(&self, state: &mut LogState, now: u64) {
let window_nanos = self.window_nanos();
while state.scanned < state.entries.len() {
let (ts, cost) = state.entries[state.scanned];
if ts.saturating_add(window_nanos) > now {
break;
}
state.expired_cost += cost;
state.scanned += 1;
}
}
fn evict_confirmed(&self, state: &mut LogState) {
while state.scanned > 0 {
state.entries.pop_front();
state.scanned -= 1;
}
state.total_count -= state.expired_cost;
state.expired_cost = 0;
if state.entries.is_empty() {
state.entries.shrink_to_fit();
}
}
}
#[async_trait]
impl Limiter for SlidingWindowLogLimiter {
async fn allow(&self, cost: u64) -> Result<bool, LimiteronError> {
validate_cost(cost)?;
let mut state = self.state.lock();
let now = self.clock.unix_timestamp_nanos();
self.scan_expired(&mut state, now);
self.evict_confirmed(&mut state);
if cost > self.max_requests.saturating_sub(state.effective_count()) {
return Ok(false);
}
let ts = state.entries.back().map_or(now, |&(last, _)| now.max(last));
state.entries.push_back((ts, cost));
state.total_count += cost;
Ok(true)
}
async fn peek(&self, cost: u64) -> Result<RateLimitSnapshot, LimiteronError> {
validate_cost(cost)?;
let now = self.clock.unix_timestamp_nanos();
let mut state = self.state.lock();
self.scan_expired(&mut state, now);
let oldest = state.entries.get(state.scanned).map(|&(ts, _)| ts);
Ok(self.snapshot(state.effective_count(), oldest, now))
}
async fn remaining(&self) -> Result<RateLimitSnapshot, LimiteronError> {
let now = self.clock.unix_timestamp_nanos();
let mut state = self.state.lock();
self.scan_expired(&mut state, now);
let oldest = state.entries.get(state.scanned).map(|&(ts, _)| ts);
Ok(self.snapshot(state.effective_count(), oldest, now))
}
}
impl SlidingWindowLogLimiter {
fn snapshot(&self, count: u64, oldest: Option<u64>, now: u64) -> RateLimitSnapshot {
let reset_secs = oldest
.map(|ts| {
let remain_nanos = ts.saturating_add(self.window_nanos()).saturating_sub(now);
(remain_nanos as u128).div_ceil(1_000_000_000) as u64
})
.unwrap_or(0);
RateLimitSnapshot {
limit: self.max_requests,
remaining: self.max_requests.saturating_sub(count),
reset_secs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::MockClock;
#[tokio::test]
async fn test_sliding_log_basic_allow_and_reject() {
let limiter = SlidingWindowLogLimiter::new(3, Duration::from_secs(1)).unwrap();
for _ in 0..3 {
assert!(limiter.allow(1).await.unwrap());
}
assert_eq!(limiter.window_count().await, 3);
assert!(!limiter.allow(1).await.unwrap());
assert_eq!(
limiter.window_count().await,
3,
"a rejected request must not enter the log queue"
);
}
#[tokio::test]
async fn test_sliding_log_cost_accounting() {
let limiter = SlidingWindowLogLimiter::new(10, Duration::from_secs(1)).unwrap();
assert!(limiter.allow(4).await.unwrap());
assert!(limiter.allow(5).await.unwrap());
assert_eq!(limiter.window_count().await, 9);
assert!(!limiter.allow(2).await.unwrap());
assert!(limiter.allow(1).await.unwrap());
assert_eq!(limiter.window_count().await, 10);
}
#[tokio::test]
async fn test_sliding_log_exact_boundary_slide() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(2, Duration::from_secs(1), clock).unwrap();
assert!(limiter.allow(2).await.unwrap());
assert!(!limiter.allow(1).await.unwrap());
mock_clock.advance(Duration::from_secs(1));
assert!(
limiter.allow(2).await.unwrap(),
"entries exactly at full window age should have slid out"
);
assert_eq!(limiter.window_count().await, 2);
}
#[tokio::test]
async fn test_sliding_log_partial_expiry_batch_evict() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(10, Duration::from_secs(1), clock).unwrap();
for _ in 0..3 {
assert!(limiter.allow(1).await.unwrap());
}
mock_clock.advance(Duration::from_millis(600));
assert!(limiter.allow(2).await.unwrap());
assert_eq!(limiter.window_count().await, 5);
mock_clock.advance(Duration::from_millis(600));
assert_eq!(
limiter.window_count().await,
2,
"exactly 3 expired costs should be evicted"
);
assert_eq!(limiter.logged_entries().await, 1);
assert!(limiter.allow(8).await.unwrap());
assert!(!limiter.allow(1).await.unwrap());
}
#[tokio::test]
async fn test_sliding_log_bounded_memory() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(5, Duration::from_millis(10), clock).unwrap();
for round in 0..50 {
mock_clock.advance(Duration::from_millis(5));
let _ = limiter.allow(1).await.unwrap();
let len = limiter.logged_entries().await;
assert!(
len <= 5,
"the log queue must be bounded by the window, round {round} length {len} is out of bounds"
);
}
}
#[tokio::test]
async fn test_sliding_log_reject_path_no_repeated_rescan() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(4, Duration::from_secs(1), clock).unwrap();
for _ in 0..4 {
assert!(limiter.allow(1).await.unwrap());
}
assert!(
!limiter.allow(1).await.unwrap(),
"must reject once the window is full"
);
mock_clock.advance(Duration::from_secs(2));
{
let mut state = limiter.state.lock();
assert_eq!(
state.entries.len(),
4,
"idle-period entries stay resident (lazy eviction)"
);
limiter.scan_expired(&mut state, mock_clock.unix_timestamp_nanos());
assert_eq!(
state.scanned, 4,
"the first read must confirm all 4 entries as expired"
);
for _ in 0..100 {
limiter.scan_expired(&mut state, mock_clock.unix_timestamp_nanos());
assert_eq!(
state.scanned, 4,
"the cursor must not move with no newly expired entries"
);
}
}
assert_eq!(
limiter.window_count().await,
0,
"resident expired entries do not count toward the effective count"
);
assert_eq!(limiter.logged_entries().await, 0);
for _ in 0..4 {
assert!(
limiter.allow(1).await.unwrap(),
"quota should recover after expired residents are cleared"
);
}
for _ in 0..10 {
assert!(
!limiter.allow(1).await.unwrap(),
"must reject once the window fills again"
);
}
assert_eq!(limiter.window_count().await, 4);
}
#[tokio::test]
async fn test_sliding_log_idle_evict_releases_capacity() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(8, Duration::from_millis(10), clock).unwrap();
for _ in 0..8 {
assert!(limiter.allow(1).await.unwrap());
}
let high_water = {
let state = limiter.state.lock();
state.entries.capacity()
};
assert!(
high_water > 0,
"there should be allocated buffer after the high water mark"
);
mock_clock.advance(Duration::from_secs(1));
assert!(
limiter.allow(1).await.unwrap(),
"should allow again once fully expired"
);
let state = limiter.state.lock();
assert!(
state.entries.capacity() < high_water,
"the buffer should shrink after the queue empties (capacity {} not below the high water mark {high_water})",
state.entries.capacity()
);
}
#[tokio::test]
async fn test_sliding_log_peek_remaining_pure() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(10, Duration::from_secs(1), clock).unwrap();
assert!(limiter.allow(10).await.unwrap());
mock_clock.advance(Duration::from_millis(999));
let snap = limiter.peek(5).await.unwrap();
assert_eq!(snap.limit, 10);
assert_eq!(snap.remaining, 0, "the window is still full at 999ms");
assert!(!snap.allows(5));
for _ in 0..100 {
let _ = limiter.peek(1).await.unwrap();
}
assert_eq!(limiter.window_count().await, 10);
{
let state = limiter.state.lock();
assert_eq!(state.entries.len(), 1, "peek must not pop entries");
}
mock_clock.advance(Duration::from_millis(1));
let snap = limiter.remaining().await.unwrap();
assert_eq!(snap.remaining, 10);
assert!(limiter.allow(10).await.unwrap());
}
#[tokio::test]
async fn test_sliding_log_snapshot_reset_secs() {
let mock_clock = Arc::new(MockClock::new());
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(10, Duration::from_secs(2), clock).unwrap();
let snap = limiter.remaining().await.unwrap();
assert_eq!(snap.reset_secs, 0);
assert!(limiter.allow(1).await.unwrap());
mock_clock.advance(Duration::from_secs(1));
let snap = limiter.remaining().await.unwrap();
assert_eq!(snap.remaining, 9);
assert_eq!(
snap.reset_secs, 1,
"reset should be the remaining seconds until the oldest entry slides out"
);
}
#[tokio::test]
async fn test_sliding_log_clock_rollback_stays_conservative() {
let mock_clock = Arc::new(MockClock::with_instant(std::time::Instant::now(), 10));
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(10, Duration::from_millis(500), clock).unwrap();
assert!(limiter.allow(3).await.unwrap());
mock_clock.set_time(std::time::Instant::now(), 9);
assert_eq!(
limiter.window_count().await,
3,
"entries in the turn-back period must not be evicted wrongly (judging by the post-turn-back now)"
);
assert!(limiter.allow(3).await.unwrap());
assert_eq!(limiter.window_count().await, 6);
{
let state = limiter.state.lock();
let sorted = state
.entries
.iter()
.zip(state.entries.iter().skip(1))
.all(|(&(a, _), &(b, _))| a <= b);
assert!(sorted, "entry timestamps must keep the ascending invariant");
assert_eq!(
state.entries.back().map(|&(ts, _)| ts),
Some(10_000_000_000),
"new entries in the turn-back period must align with the tail timestamp"
);
}
}
#[tokio::test]
async fn test_sliding_log_peek_before_rollback_is_permissive() {
let mock_clock = Arc::new(MockClock::with_instant(std::time::Instant::now(), 10));
let clock: Arc<dyn Clock> = mock_clock.clone();
let limiter =
SlidingWindowLogLimiter::with_clock(4, Duration::from_millis(500), clock).unwrap();
for _ in 0..4 {
assert!(limiter.allow(1).await.unwrap());
}
mock_clock.advance(Duration::from_millis(600));
assert_eq!(limiter.peek(1).await.unwrap().remaining, 4);
mock_clock.set_time(std::time::Instant::now(), 10);
mock_clock.advance(Duration::from_millis(200));
{
let state = limiter.state.lock();
assert_eq!(
state.scanned, 4,
"the cursor is irreversible: the confirmed prefix must not regress on turn-back"
);
}
assert!(
limiter.allow(4).await.unwrap(),
"the confirmed-expired prefix still counts as expired after turn-back (permissive)"
);
let mock_clock2 = Arc::new(MockClock::with_instant(std::time::Instant::now(), 10));
let clock2: Arc<dyn Clock> = mock_clock2.clone();
let limiter2 =
SlidingWindowLogLimiter::with_clock(4, Duration::from_millis(500), clock2).unwrap();
for _ in 0..4 {
assert!(limiter2.allow(1).await.unwrap());
}
mock_clock2.set_time(std::time::Instant::now(), 10);
mock_clock2.advance(Duration::from_millis(200));
assert!(
!limiter2.allow(4).await.unwrap(),
"turn-back judgment is conservative with no observation call: an unexpired entry must reject"
);
}
#[tokio::test]
async fn test_sliding_log_zero_cost_rejected() {
let limiter = SlidingWindowLogLimiter::new(10, Duration::from_secs(1)).unwrap();
assert!(limiter.allow(0).await.is_err());
assert!(limiter.peek(0).await.is_err());
}
#[tokio::test]
async fn test_sliding_log_cost_exceeds_max_rejected() {
let limiter = SlidingWindowLogLimiter::new(10, Duration::from_secs(1)).unwrap();
assert!(limiter.allow(1_000_001).await.is_err());
}
#[tokio::test]
async fn test_sliding_log_invalid_config_rejected() {
assert!(
SlidingWindowLogLimiter::new(0, Duration::from_secs(1)).is_err(),
"max_requests 0 must be rejected"
);
assert!(
SlidingWindowLogLimiter::new(10, Duration::ZERO).is_err(),
"a zero window must be rejected"
);
}
#[tokio::test]
async fn test_sliding_log_accessors() {
let limiter = SlidingWindowLogLimiter::new(42, Duration::from_secs(7)).unwrap();
assert_eq!(limiter.max_requests(), 42);
assert_eq!(limiter.window(), Duration::from_secs(7));
}
#[tokio::test]
async fn test_sliding_log_concurrent_allow_respects_budget() {
let limiter = Arc::new(SlidingWindowLogLimiter::new(100, Duration::from_secs(60)).unwrap());
let admitted = Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let limiter = Arc::clone(&limiter);
let admitted = Arc::clone(&admitted);
handles.push(tokio::spawn(async move {
for _ in 0..50 {
if limiter.allow(1).await.unwrap() {
admitted.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}));
}
for handle in handles {
handle.await.unwrap();
}
assert_eq!(
admitted.load(std::sync::atomic::Ordering::Relaxed),
100,
"the total concurrent grants must exactly equal the quota"
);
assert_eq!(limiter.window_count().await, 100);
}
#[tokio::test]
async fn test_sliding_log_check_default_impl_maps_rejection() {
use crate::limiters::Limiter;
let limiter = SlidingWindowLogLimiter::new(1, Duration::from_secs(1)).unwrap();
assert!(limiter.check("any_key").await.is_ok());
let result = limiter.check("any_key").await;
assert!(
result.is_err(),
"check must return Err once the window is full"
);
}
}