use std::collections::HashMap;
use crate::concurrency::Duration;
use crate::concurrency::Instant;
use crate::factory::routing::RouteResult;
use crate::factory::routing::Router;
use crate::factory::Job;
use crate::factory::JobKey;
use crate::factory::WorkerId;
use crate::factory::WorkerProperties;
use crate::ActorProcessingErr;
use crate::Message;
use crate::State;
pub const MAX_LB_BALANCE: usize = isize::MAX as usize;
pub trait RateLimiter: State {
fn check(&mut self) -> bool;
fn bump(&mut self);
}
#[derive(Debug, bon::Builder)]
pub struct RateLimitedRouter<TRouter, TRateLimit> {
pub router: TRouter,
pub rate_limiter: TRateLimit,
}
impl<TKey, TMsg, TRouter, TRateLimit> Router<TKey, TMsg> for RateLimitedRouter<TRouter, TRateLimit>
where
TKey: JobKey,
TMsg: Message,
TRouter: Router<TKey, TMsg>,
TRateLimit: RateLimiter,
{
fn route_message(
&mut self,
job: Job<TKey, TMsg>,
pool_size: usize,
worker_hint: Option<WorkerId>,
worker_pool: &mut HashMap<WorkerId, WorkerProperties<TKey, TMsg>>,
) -> Result<RouteResult<TKey, TMsg>, ActorProcessingErr> {
if !self.rate_limiter.check() {
if let Some(wid) = worker_hint {
if worker_pool
.get(&wid)
.is_some_and(WorkerProperties::is_available)
{
self.router.on_worker_availability_change(wid, true);
}
}
Ok(RouteResult::RateLimited(job))
} else {
let result = self
.router
.route_message(job, pool_size, worker_hint, worker_pool);
if matches!(result, Ok(RouteResult::Handled)) {
self.rate_limiter.bump();
}
result
}
}
fn choose_target_worker(
&mut self,
job: &Job<TKey, TMsg>,
pool_size: usize,
worker_hint: Option<WorkerId>,
worker_pool: &HashMap<WorkerId, WorkerProperties<TKey, TMsg>>,
) -> Option<WorkerId> {
self.router
.choose_target_worker(job, pool_size, worker_hint, worker_pool)
}
fn is_factory_queueing(&self) -> bool {
self.router.is_factory_queueing()
}
fn on_worker_availability_change(&mut self, wid: WorkerId, available: bool) {
self.router.on_worker_availability_change(wid, available);
}
}
#[derive(Debug)]
pub struct LeakyBucketRateLimiter {
pub refill: usize,
pub interval: Duration,
pub max: usize,
pub balance: usize,
deadline: Option<Instant>,
}
#[bon::bon]
impl LeakyBucketRateLimiter {
#[builder]
pub fn new(
refill: usize,
interval: Duration,
#[builder(default = MAX_LB_BALANCE)] max: usize,
initial: Option<usize>,
) -> LeakyBucketRateLimiter {
LeakyBucketRateLimiter {
refill,
interval,
max,
balance: initial.unwrap_or(max).min(max),
deadline: Instant::now().checked_add(interval),
}
}
fn refresh(&mut self, now: Instant) {
let Some(deadline) = self.deadline else {
return;
};
if now < deadline {
return;
}
let interval_nanos = self.interval.as_nanos();
if interval_nanos == 0 {
self.balance = self.balance.saturating_add(self.refill).min(self.max);
self.deadline = Some(now);
return;
}
let since_nanos = now.saturating_duration_since(deadline).as_nanos();
let periods =
usize::try_from((since_nanos / interval_nanos).saturating_add(1)).unwrap_or(usize::MAX);
let tokens = periods.saturating_mul(self.refill).min(MAX_LB_BALANCE);
let nanos_since_last_period = since_nanos % interval_nanos;
let seconds = u64::try_from(nanos_since_last_period / 1_000_000_000).unwrap_or(u64::MAX);
let subsec_nanos =
u32::try_from(nanos_since_last_period % 1_000_000_000).unwrap_or(u32::MAX);
self.deadline = now.checked_add(
self.interval
.saturating_sub(Duration::new(seconds, subsec_nanos)),
);
self.balance = self.balance.saturating_add(tokens).min(self.max);
}
}
impl RateLimiter for LeakyBucketRateLimiter {
fn check(&mut self) -> bool {
let now = Instant::now();
self.refresh(now);
self.balance > 0
}
fn bump(&mut self) {
if self.balance > 0 {
self.balance -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::concurrency::sleep;
use crate::factory::discard::WorkerDiscardSettings;
use crate::factory::routing::QueuerRouting;
use crate::factory::WorkerMessage;
use crate::{Actor, ActorRef};
struct RouterWorker;
#[cfg_attr(feature = "async-trait", crate::async_trait)]
impl Actor for RouterWorker {
type Msg = WorkerMessage<(), ()>;
type State = ();
type Arguments = ();
async fn pre_start(
&self,
_myself: ActorRef<Self::Msg>,
_: (),
) -> Result<Self::State, ActorProcessingErr> {
Ok(())
}
}
struct RejectAll;
impl RateLimiter for RejectAll {
fn check(&mut self) -> bool {
false
}
fn bump(&mut self) {}
}
#[crate::concurrency::test]
async fn rate_limit_rejection_restores_a_reserved_available_worker() {
let (worker, handle) = Actor::spawn(None, RouterWorker, ()).await.unwrap();
let mut pool = HashMap::from([(
0,
WorkerProperties::new(
"test".to_string(),
0,
worker.clone(),
WorkerDiscardSettings::None,
None,
handle,
None,
),
)]);
let mut router = RateLimitedRouter {
router: QueuerRouting::<(), ()>::default(),
rate_limiter: RejectAll,
};
router.on_worker_availability_change(0, true);
let job = Job::new((), ());
let selected = router
.choose_target_worker(&job, 1, None, &pool)
.expect("an available worker should be reserved");
assert!(matches!(
router.route_message(job, 1, Some(selected), &mut pool),
Ok(RouteResult::RateLimited(_))
));
assert_eq!(
router.choose_target_worker(&Job::new((), ()), 1, None, &pool),
Some(0)
);
worker.stop(None);
pool.get_mut(&0)
.and_then(WorkerProperties::get_join_handle)
.unwrap()
.await
.unwrap();
}
#[crate::concurrency::test]
async fn test_basic_leaky_bucket() {
let mut limiter = LeakyBucketRateLimiter::builder()
.refill(1)
.initial(1)
.interval(Duration::from_millis(100))
.build();
assert!(limiter.check());
limiter.bump();
assert!(!limiter.check());
sleep(limiter.interval * 2).await;
assert!(limiter.check());
limiter.bump();
assert!(limiter.check());
limiter.bump();
assert!(!limiter.check());
}
#[crate::concurrency::test]
async fn test_leaky_bucket_max() {
let mut limiter = LeakyBucketRateLimiter::builder()
.refill(1)
.initial(1)
.max(1)
.interval(Duration::from_millis(100))
.build();
assert!(limiter.check());
limiter.bump();
assert!(!limiter.check());
sleep(limiter.interval * 2).await;
assert!(limiter.check());
limiter.bump();
assert!(!limiter.check());
}
#[test]
fn test_leaky_bucket_handles_zero_interval_and_saturates() {
let mut limiter = LeakyBucketRateLimiter::builder()
.refill(usize::MAX)
.initial(usize::MAX)
.interval(Duration::ZERO)
.max(2)
.build();
assert_eq!(2, limiter.balance);
limiter.bump();
assert!(limiter.check());
assert_eq!(2, limiter.balance);
}
#[test]
fn test_leaky_bucket_tracks_sub_millisecond_periods() {
let mut limiter = LeakyBucketRateLimiter::builder()
.refill(1)
.initial(0)
.interval(Duration::from_micros(500))
.max(10)
.build();
let first_deadline = limiter.deadline.expect("deadline should be representable");
limiter.refresh(first_deadline + Duration::from_micros(1_250));
assert_eq!(3, limiter.balance);
assert_eq!(
first_deadline + Duration::from_micros(1_500),
limiter
.deadline
.expect("deadline should remain representable")
);
}
#[test]
fn test_leaky_bucket_handles_unrepresentable_interval() {
let mut limiter = LeakyBucketRateLimiter::builder()
.refill(1)
.initial(0)
.interval(Duration::MAX)
.max(10)
.build();
assert!(limiter.deadline.is_none());
assert!(!limiter.check());
}
}