use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use tokio::time::Instant;
use crate::wasm_runtime::UserId;
pub const DEFAULT_PER_USER_OP_RATE_LIMIT: u64 = 10;
pub const DEFAULT_PER_USER_OP_BURST: u64 = 100;
pub const DEFAULT_PER_USER_EXPORT_MIN_INTERVAL_SECS: u64 = 10;
const MAX_TRACKED_USERS: usize = 100_000;
const EVICTION_HYSTERESIS_DIVISOR: usize = 10;
fn eviction_low_water_mark(cap: usize) -> usize {
cap.saturating_sub(cap / EVICTION_HYSTERESIS_DIVISOR).max(1)
}
struct TokenBucket {
capacity: f64,
refill_per_sec: f64,
state: Mutex<BucketState>,
}
struct BucketState {
tokens: f64,
last_refill: Instant,
last_access: Instant,
}
impl TokenBucket {
fn new(capacity: u64, refill_per_sec: u64, now: Instant) -> Self {
let capacity = capacity as f64;
Self {
capacity,
refill_per_sec: refill_per_sec as f64,
state: Mutex::new(BucketState {
tokens: capacity,
last_refill: now,
last_access: now,
}),
}
}
fn try_acquire(&self, now: Instant) -> bool {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
let elapsed = now.saturating_duration_since(state.last_refill);
state.last_refill = now;
state.last_access = now;
if self.refill_per_sec > 0.0 {
state.tokens =
(state.tokens + elapsed.as_secs_f64() * self.refill_per_sec).min(self.capacity);
}
if state.tokens >= 1.0 {
state.tokens -= 1.0;
true
} else {
false
}
}
fn evictability(&self, now: Instant) -> (bool, Instant) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
let elapsed = now.saturating_duration_since(state.last_refill);
state.last_refill = now;
if self.refill_per_sec > 0.0 {
state.tokens =
(state.tokens + elapsed.as_secs_f64() * self.refill_per_sec).min(self.capacity);
}
let safe = state.tokens >= self.capacity - 1.0 && state.tokens >= 1.0;
(safe, state.last_access)
}
}
#[derive(Clone)]
pub(crate) struct UserOpRateLimiter {
inner: Arc<Inner>,
}
struct Inner {
op_rate: u64,
op_burst: u64,
op_buckets: DashMap<UserId, TokenBucket>,
export_min_interval_secs: u64,
last_export: DashMap<UserId, Instant>,
max_tracked_users: usize,
sweeps: std::sync::atomic::AtomicU64,
}
impl Inner {
fn evict_op_buckets_if_needed(&self, now: Instant) {
let len = self.op_buckets.len();
if len <= self.max_tracked_users {
return;
}
self.sweeps
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let target_removals = len - eviction_low_water_mark(self.max_tracked_users);
let mut candidates: Vec<(UserId, Instant)> = self
.op_buckets
.iter()
.filter_map(|entry| {
let (safe, last_access) = entry.value().evictability(now);
safe.then(|| (*entry.key(), last_access))
})
.collect();
candidates.sort_by_key(|(_, last_access)| *last_access);
let mut removed = 0;
for (key, _) in candidates.into_iter().take(target_removals) {
if self
.op_buckets
.remove_if(&key, |_, bucket| bucket.evictability(now).0)
.is_some()
{
removed += 1;
}
}
if self.op_buckets.len() > self.max_tracked_users && removed < target_removals {
static WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if WARNED
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::Relaxed,
)
.is_ok()
{
tracing::warn!(
tracked = self.op_buckets.len(),
cap = self.max_tracked_users,
"per-user op-rate map over cap with too few idle entries to \
evict (most tracked users are actively throttling). Holding \
over cap rather than resetting an active limiter; self-corrects \
as buckets refill. Possible distributed token flood."
);
}
}
}
fn evict_last_export_if_needed(&self, now: Instant, min_interval: u64) {
let len = self.last_export.len();
if len <= self.max_tracked_users {
return;
}
self.sweeps
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let target_removals = len - eviction_low_water_mark(self.max_tracked_users);
let min = std::time::Duration::from_secs(min_interval);
let mut candidates: Vec<(UserId, Instant)> = self
.last_export
.iter()
.filter_map(|entry| {
let last = *entry.value();
(now.saturating_duration_since(last) >= min).then(|| (*entry.key(), last))
})
.collect();
candidates.sort_by_key(|(_, last)| *last);
for (key, _) in candidates.into_iter().take(target_removals) {
self.last_export
.remove_if(&key, |_, last| now.saturating_duration_since(*last) >= min);
}
}
}
impl UserOpRateLimiter {
pub(crate) fn new(op_rate: u64, op_burst: u64, export_min_interval_secs: u64) -> Self {
Self::with_cap(
op_rate,
op_burst,
export_min_interval_secs,
MAX_TRACKED_USERS,
)
}
fn with_cap(
op_rate: u64,
op_burst: u64,
export_min_interval_secs: u64,
max_tracked_users: usize,
) -> Self {
Self {
inner: Arc::new(Inner {
op_rate,
op_burst: op_burst.max(1),
op_buckets: DashMap::new(),
export_min_interval_secs,
last_export: DashMap::new(),
max_tracked_users: max_tracked_users.max(1),
sweeps: std::sync::atomic::AtomicU64::new(0),
}),
}
}
pub(crate) fn op_limiting_enabled(&self) -> bool {
self.inner.op_rate > 0
}
pub(crate) fn try_acquire_op(&self, user: &UserId) -> bool {
self.try_acquire_op_at(user, Instant::now())
}
pub(crate) fn try_acquire_op_at(&self, user: &UserId, now: Instant) -> bool {
if self.inner.op_rate == 0 {
return true;
}
let mut is_new = false;
let admitted = {
let bucket = self.inner.op_buckets.entry(*user).or_insert_with(|| {
is_new = true;
TokenBucket::new(self.inner.op_burst, self.inner.op_rate, now)
});
bucket.try_acquire(now)
};
if is_new {
self.inner.evict_op_buckets_if_needed(now);
}
admitted
}
pub(crate) fn try_acquire_export(&self, user: &UserId) -> bool {
self.try_acquire_export_at(user, Instant::now())
}
pub(crate) fn try_acquire_export_at(&self, user: &UserId, now: Instant) -> bool {
let min_interval = self.inner.export_min_interval_secs;
if min_interval == 0 {
return true;
}
let mut is_new = false;
let admitted = {
let mut entry = self.inner.last_export.entry(*user).or_insert_with(|| {
is_new = true;
now - std::time::Duration::from_secs(min_interval)
});
let elapsed = now.saturating_duration_since(*entry);
if elapsed.as_secs() >= min_interval {
*entry = now;
true
} else {
false
}
};
if is_new {
self.inner.evict_last_export_if_needed(now, min_interval);
}
admitted
}
#[cfg(test)]
pub(crate) fn tracked_op_users(&self) -> usize {
self.inner.op_buckets.len()
}
#[cfg(test)]
pub(crate) fn tracked_export_users(&self) -> usize {
self.inner.last_export.len()
}
#[cfg(test)]
pub(crate) fn sweeps_performed(&self) -> u64 {
self.inner.sweeps.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct UserOpRateLimitConfig {
pub op_rate: u64,
pub op_burst: u64,
pub export_min_interval_secs: u64,
}
impl UserOpRateLimitConfig {
pub(crate) fn build(self) -> UserOpRateLimiter {
UserOpRateLimiter::new(self.op_rate, self.op_burst, self.export_min_interval_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uid(byte: u8) -> UserId {
UserId::new([byte; 32])
}
#[tokio::test(start_paused = true)]
async fn under_rate_always_passes() {
let limiter = UserOpRateLimiter::new(10, 50, 0);
let user = uid(1);
let mut now = Instant::now();
for _ in 0..200 {
assert!(
limiter.try_acquire_op_at(&user, now),
"under-rate must pass"
);
now += std::time::Duration::from_millis(1000);
}
}
#[tokio::test(start_paused = true)]
async fn burst_then_reject() {
let limiter = UserOpRateLimiter::new(10, 5, 0);
let user = uid(2);
let now = Instant::now();
for i in 0..5 {
assert!(
limiter.try_acquire_op_at(&user, now),
"burst op {i} should pass"
);
}
assert!(
!limiter.try_acquire_op_at(&user, now),
"op beyond burst capacity must be rejected"
);
}
#[tokio::test(start_paused = true)]
async fn tokens_refill_over_time() {
let limiter = UserOpRateLimiter::new(10, 5, 0);
let user = uid(3);
let t0 = Instant::now();
for _ in 0..5 {
assert!(limiter.try_acquire_op_at(&user, t0));
}
assert!(!limiter.try_acquire_op_at(&user, t0), "burst exhausted");
let t1 = t0 + std::time::Duration::from_millis(500);
for i in 0..5 {
assert!(
limiter.try_acquire_op_at(&user, t1),
"refilled op {i} should pass"
);
}
assert!(
!limiter.try_acquire_op_at(&user, t1),
"refill is capped at burst, so the 6th rejects"
);
let t2 = t1 + std::time::Duration::from_millis(100);
assert!(limiter.try_acquire_op_at(&user, t2), "1 token refilled");
assert!(!limiter.try_acquire_op_at(&user, t2), "only 1 refilled");
}
#[tokio::test(start_paused = true)]
async fn disabled_never_limits() {
let limiter = UserOpRateLimiter::new(0, 50, 0);
assert!(!limiter.op_limiting_enabled());
let user = uid(4);
let now = Instant::now();
for _ in 0..10_000 {
assert!(limiter.try_acquire_op_at(&user, now));
}
assert_eq!(
limiter.tracked_op_users(),
0,
"disabled limiter must not allocate buckets"
);
}
#[tokio::test(start_paused = true)]
async fn distinct_users_have_independent_buckets() {
let limiter = UserOpRateLimiter::new(10, 3, 0);
let a = uid(10);
let b = uid(11);
let now = Instant::now();
for _ in 0..3 {
assert!(limiter.try_acquire_op_at(&a, now));
}
assert!(!limiter.try_acquire_op_at(&a, now), "A exhausted");
for _ in 0..3 {
assert!(limiter.try_acquire_op_at(&b, now), "B independent of A");
}
assert!(!limiter.try_acquire_op_at(&b, now), "B now exhausted too");
}
#[tokio::test(start_paused = true)]
async fn same_user_shares_one_bucket() {
let limiter = UserOpRateLimiter::new(10, 3, 0);
let user = uid(20);
let now = Instant::now();
assert!(limiter.try_acquire_op_at(&user, now)); assert!(limiter.try_acquire_op_at(&user, now)); assert!(limiter.try_acquire_op_at(&user, now)); assert!(
!limiter.try_acquire_op_at(&user, now),
"shared bucket: 4th rejected regardless of which connection"
);
assert_eq!(limiter.tracked_op_users(), 1, "one bucket for one user");
}
#[test]
fn concurrent_acquire_admits_at_most_burst() {
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering};
const THREADS: usize = 64;
const BURST: u64 = 10;
let limiter = UserOpRateLimiter::new(1_000_000, BURST, 0);
let user = uid(99);
let now = Instant::now();
let admitted = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(THREADS));
std::thread::scope(|scope| {
for _ in 0..THREADS {
let limiter = limiter.clone();
let admitted = admitted.clone();
let barrier = barrier.clone();
let user = user;
scope.spawn(move || {
barrier.wait();
for _ in 0..4 {
if limiter.try_acquire_op_at(&user, now) {
admitted.fetch_add(1, Ordering::SeqCst);
}
}
});
}
});
assert_eq!(
admitted.load(Ordering::SeqCst),
BURST as usize,
"with no refill, exactly `burst` tokens may be admitted across all \
concurrent threads — more means a check-then-act over-admit race"
);
}
#[tokio::test(start_paused = true)]
async fn export_min_interval_enforced() {
let limiter = UserOpRateLimiter::new(10, 50, 10);
let user = uid(30);
let t0 = Instant::now();
assert!(
limiter.try_acquire_export_at(&user, t0),
"first export passes"
);
assert!(
!limiter.try_acquire_export_at(&user, t0),
"immediate second export rejected"
);
let t_mid = t0 + std::time::Duration::from_secs(9);
assert!(
!limiter.try_acquire_export_at(&user, t_mid),
"9s later still under the 10s interval"
);
let t_ok = t0 + std::time::Duration::from_secs(10);
assert!(
limiter.try_acquire_export_at(&user, t_ok),
"10s later export is allowed"
);
}
#[tokio::test(start_paused = true)]
async fn export_disabled_never_limits() {
let limiter = UserOpRateLimiter::new(10, 50, 0);
let user = uid(31);
let now = Instant::now();
for _ in 0..100 {
assert!(
limiter.try_acquire_export_at(&user, now),
"export limiting disabled (0): every export passes"
);
}
}
#[tokio::test(start_paused = true)]
async fn export_buckets_are_per_user() {
let limiter = UserOpRateLimiter::new(10, 50, 10);
let a = uid(40);
let b = uid(41);
let now = Instant::now();
assert!(limiter.try_acquire_export_at(&a, now));
assert!(!limiter.try_acquire_export_at(&a, now), "A throttled");
assert!(
limiter.try_acquire_export_at(&b, now),
"B's export interval is independent of A's"
);
}
#[tokio::test(start_paused = true)]
async fn op_burst_zero_is_clamped_to_one() {
let limiter = UserOpRateLimiter::new(10, 0, 0);
let user = uid(50);
let now = Instant::now();
assert!(
limiter.try_acquire_op_at(&user, now),
"burst 0 must be clamped to 1 — first op passes, not deny-all"
);
assert!(
!limiter.try_acquire_op_at(&user, now),
"with clamped burst=1 the second same-instant op is rejected"
);
}
#[tokio::test(start_paused = true)]
async fn export_sub_second_boundary_still_rejects() {
let limiter = UserOpRateLimiter::new(10, 50, 10);
let user = uid(51);
let t0 = Instant::now();
assert!(limiter.try_acquire_export_at(&user, t0), "first export");
let t_95 = t0 + std::time::Duration::from_millis(9_500);
assert!(
!limiter.try_acquire_export_at(&user, t_95),
"9.5s < 10s interval → still rejected"
);
let t_10 = t0 + std::time::Duration::from_millis(10_000);
assert!(
limiter.try_acquire_export_at(&user, t_10),
"exactly 10s → allowed"
);
}
#[tokio::test(start_paused = true)]
async fn op_bucket_map_is_bounded() {
let limiter = UserOpRateLimiter::with_cap(1000, 1000, 0, 4);
let mut now = Instant::now();
for i in 0..50u8 {
let user = uid(i);
assert!(limiter.try_acquire_op_at(&user, now));
now += std::time::Duration::from_millis(10);
assert!(
limiter.tracked_op_users() <= 4,
"op-bucket map must never exceed the cap (was {} after user {i})",
limiter.tracked_op_users()
);
}
}
#[tokio::test(start_paused = true)]
async fn eviction_never_resets_active_flooder() {
let limiter = UserOpRateLimiter::with_cap(1, 3, 0, 2);
let flooder = uid(200);
let t0 = Instant::now();
assert!(limiter.try_acquire_op_at(&flooder, t0));
assert!(limiter.try_acquire_op_at(&flooder, t0));
assert!(limiter.try_acquire_op_at(&flooder, t0));
assert!(
!limiter.try_acquire_op_at(&flooder, t0),
"flooder is now depleted/throttled"
);
let mut now = t0;
for i in 0..40u8 {
now += std::time::Duration::from_millis(1);
let other = uid(i); assert!(limiter.try_acquire_op_at(&other, now));
}
let t_check = now + std::time::Duration::from_millis(1);
assert!(
!limiter.try_acquire_op_at(&flooder, t_check),
"eviction must NOT have reset the active flooder's depleted bucket"
);
}
#[tokio::test(start_paused = true)]
async fn eviction_never_resets_active_flooder_burst_one() {
let limiter = UserOpRateLimiter::with_cap(1, 1, 0, 2);
let flooder = uid(200);
let t0 = Instant::now();
assert!(limiter.try_acquire_op_at(&flooder, t0));
assert!(
!limiter.try_acquire_op_at(&flooder, t0),
"burst==1 flooder is now depleted/throttled (tokens == 0.0)"
);
for i in 0..40u8 {
let other = uid(i); assert!(limiter.try_acquire_op_at(&other, t0));
}
assert!(
!limiter.try_acquire_op_at(&flooder, t0),
"burst==1: eviction must NOT have reset the depleted flooder's bucket"
);
}
#[tokio::test(start_paused = true)]
async fn export_map_is_bounded() {
let limiter = UserOpRateLimiter::with_cap(10, 50, 1, 3);
let mut now = Instant::now();
for i in 0..30u8 {
let user = uid(i);
assert!(limiter.try_acquire_export_at(&user, now));
now += std::time::Duration::from_millis(1100); assert!(
limiter.tracked_export_users() <= 3,
"export map must never exceed the cap (was {} after user {i})",
limiter.tracked_export_users()
);
}
}
fn uid32(n: u32) -> UserId {
let mut b = [0u8; 32];
b[..4].copy_from_slice(&n.to_le_bytes());
UserId::new(b)
}
#[tokio::test(start_paused = true)]
async fn eviction_is_amortized_under_token_churn() {
let cap = 100;
let limiter = UserOpRateLimiter::with_cap(1000, 1000, 0, cap);
let mut now = Instant::now();
let inserts = 2000u32;
for i in 0..inserts {
let user = uid32(i);
assert!(limiter.try_acquire_op_at(&user, now));
now += std::time::Duration::from_millis(1);
assert!(
limiter.tracked_op_users() <= cap + 1,
"map must stay bounded (≤ cap+1) during churn; was {} at insert {i}",
limiter.tracked_op_users()
);
}
let sweeps = limiter.sweeps_performed();
assert!(
sweeps <= 400,
"hysteresis must bound sweeps far below per-insert ({sweeps} sweeps \
over {inserts} inserts; per-insert would be ~1900)"
);
}
}