use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use polyoxide_core::{
Charge, Hold, LayerCharge, LayerId, RequestMeta, ResponseMeta, WindowQuotaTable,
};
use polyoxide_rt::{Instant, Runtime};
pub const USED_WEIGHT_HEADER: &str = "x-mbx-used-weight-1m";
pub const DEFAULT_BAN: Duration = Duration::from_secs(120);
pub const MAX_COOLDOWN: Duration = Duration::from_secs(3 * 24 * 60 * 60);
const RESERVED_FRACTION: u32 = 10;
const MINUTE_MS: u64 = 60_000;
fn after_reserve(published: u32) -> u32 {
published - published.div_ceil(RESERVED_FRACTION)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MinuteCharge {
pub(crate) minute: Option<u64>,
}
#[cfg(test)]
pub(crate) mod paused {
pub(crate) use tokio::time::Instant;
}
#[derive(Debug, Clone)]
pub(crate) enum Clock {
System,
#[cfg(test)]
Tokio {
start: paused::Instant,
start_ms: u64,
},
#[cfg(test)]
Manual(Arc<std::sync::atomic::AtomicU64>),
}
impl Clock {
pub(crate) fn now_ms(&self) -> u64 {
match self {
Self::System => polyoxide_venue::UnixMillis::now().0,
#[cfg(test)]
Self::Tokio { start, start_ms } => *start_ms + start.elapsed().as_millis() as u64,
#[cfg(test)]
Self::Manual(ms) => ms.load(std::sync::atomic::Ordering::SeqCst),
}
}
}
#[derive(Debug)]
pub(crate) struct Engine {
per_minute: u32,
paced_interval: Option<Duration>,
clock: Clock,
hold: Hold,
state: Mutex<State>,
}
#[derive(Debug)]
struct State {
minute: u64,
used: u32,
next_paced: Option<Instant>,
}
impl Engine {
pub(crate) fn new(
published_per_minute: u32,
paced: Option<(u32, Duration)>,
clock: Clock,
runtime: Arc<dyn Runtime>,
) -> Self {
let paced_interval =
paced.map(|(count, period)| WindowQuotaTable::paced_interval(count, period));
Self {
per_minute: after_reserve(published_per_minute),
paced_interval,
clock,
hold: Hold::with_ceiling_on(MAX_COOLDOWN, runtime),
state: Mutex::new(State {
minute: 0,
used: 0,
next_paced: None,
}),
}
}
pub(crate) fn per_minute(&self) -> u32 {
self.per_minute
}
pub(crate) fn runtime(&self) -> &Arc<dyn Runtime> {
self.hold.runtime()
}
pub(crate) fn used(&self) -> u32 {
let minute = self.clock.now_ms() / MINUTE_MS;
let state = self.lock();
if minute > state.minute {
0
} else {
state.used
}
}
pub(crate) async fn acquire_weight(&self, weight: u32) -> MinuteCharge {
loop {
self.await_cooldown().await;
match self.try_charge(weight) {
Ok(charge) => return charge,
Err(until_next_minute) => self.runtime().sleep(until_next_minute).await,
}
}
}
pub(crate) async fn acquire_paced(&self) -> MinuteCharge {
let Some(interval) = self.paced_interval else {
self.await_cooldown().await;
return MinuteCharge { minute: None };
};
loop {
self.await_cooldown().await;
let slot = self.reserve_paced_slot(interval);
self.runtime().sleep_until(slot).await;
if !self.in_cooldown() {
return MinuteCharge { minute: None };
}
}
}
fn try_charge(&self, weight: u32) -> Result<MinuteCharge, Duration> {
let now_ms = self.clock.now_ms();
let mut state = self.lock();
let minute = now_ms / MINUTE_MS;
if minute > state.minute {
state.minute = minute;
state.used = 0;
}
if state.used == 0 || state.used.saturating_add(weight) <= self.per_minute {
state.used = state.used.saturating_add(weight);
Ok(MinuteCharge {
minute: Some(state.minute),
})
} else {
Err(Duration::from_millis(MINUTE_MS - now_ms % MINUTE_MS))
}
}
pub(crate) fn record_used(&self, charge: MinuteCharge, used: u32) {
let Some(minute) = charge.minute else { return };
let mut state = self.lock();
if state.minute == minute && used > state.used {
state.used = used;
}
}
pub(crate) fn begin_cooldown(&self, delay: Duration) {
self.hold.extend(delay);
}
pub(crate) fn until_next_minute(&self) -> Duration {
let now_ms = self.clock.now_ms();
Duration::from_millis(MINUTE_MS - now_ms % MINUTE_MS)
}
pub(crate) async fn await_cooldown(&self) {
self.hold.wait().await;
}
fn in_cooldown(&self) -> bool {
self.hold.is_held()
}
fn reserve_paced_slot(&self, interval: Duration) -> Instant {
let now = self.runtime().now();
let mut state = self.lock();
let slot = state.next_paced.map_or(now, |next| next.max(now));
state.next_paced = Some(slot + interval);
slot
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) async fn charge(
&self,
meta: &RequestMeta<'_>,
weight: LayerId,
paced: Option<LayerId>,
) -> Charge {
let found = meta.costs.iter().find_map(|cost| {
if cost.layer == weight {
Some((cost.layer, Some(cost.units)))
} else if Some(cost.layer) == paced {
Some((cost.layer, None))
} else {
None
}
});
let Some((layer, weighed)) = found else {
self.await_cooldown().await;
return Charge::none();
};
let (units, window) = match weighed {
Some(units) => (units, self.acquire_weight(units).await.minute),
None => (1, self.acquire_paced().await.minute),
};
Charge::none().with(LayerCharge {
layer,
units,
window,
})
}
pub(crate) fn observe(&self, charge: &Charge, response: &ResponseMeta<'_>, weight: LayerId) {
let Some(used) = used_weight(response.headers) else {
return;
};
for layer in charge.layers() {
if layer.layer == weight {
self.record_used(
MinuteCharge {
minute: layer.window,
},
used,
);
}
}
}
}
fn used_weight(headers: &polyoxide_core::http::header::HeaderMap) -> Option<u32> {
headers
.get(USED_WEIGHT_HEADER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()
}
#[cfg(test)]
mod tests {
use polyoxide_core::http::header::{HeaderMap, HeaderValue};
use polyoxide_core::http::{Method, StatusCode};
use tokio::time::Instant;
use super::*;
const ON_A_MINUTE: u64 = 1_791_362_100_000;
const WEIGHT: LayerId = LayerId("test-weight");
const PACED: LayerId = LayerId("test-paced");
const FIVE_MINUTES: Duration = Duration::from_secs(300);
fn new_engine(paced: Option<(u32, Duration)>) -> Engine {
Engine::new(
2400,
paced,
Clock::Tokio {
start: Instant::now(),
start_ms: ON_A_MINUTE,
},
polyoxide_rt::default_runtime(),
)
}
fn cost(layer: LayerId, units: u32) -> polyoxide_core::Cost {
polyoxide_core::Cost {
layer,
units,
exact: true,
}
}
async fn charge(
engine: &Engine,
costs: &[polyoxide_core::Cost],
paced: Option<LayerId>,
) -> Charge {
let method = Method::GET;
let meta = RequestMeta {
method: &method,
path: "/probe",
query: &[],
costs,
};
engine.charge(&meta, WEIGHT, paced).await
}
fn observe(engine: &Engine, charge: &Charge, used: &str, layer: LayerId) {
let mut headers = HeaderMap::new();
headers.insert(USED_WEIGHT_HEADER, HeaderValue::from_str(used).unwrap());
engine.observe(
charge,
&ResponseMeta {
status: StatusCode::OK,
headers: &headers,
},
layer,
);
}
#[test]
fn the_budget_is_the_published_limit_less_a_tenth() {
for (published, budget) in [(2400, 2160), (6000, 5400)] {
let engine = Engine::new(
published,
None,
Clock::System,
polyoxide_rt::default_runtime(),
);
assert_eq!(engine.per_minute(), budget, "{published}");
}
}
#[tokio::test(start_paused = true)]
async fn a_charge_carries_the_layer_it_was_given_its_weight_and_its_minute() {
let engine = new_engine(None);
let charged = charge(&engine, &[cost(WEIGHT, 7)], None).await;
assert_eq!(
charged,
Charge::none().with(LayerCharge {
layer: WEIGHT,
units: 7,
window: Some(29_856_035),
})
);
assert_eq!(engine.used(), 7);
}
#[tokio::test(start_paused = true)]
async fn a_cost_on_another_layer_charges_nothing_and_still_waits_out_a_cooldown() {
let engine = new_engine(None);
engine.begin_cooldown(Duration::from_secs(3));
let start = Instant::now();
let charged = charge(&engine, &[cost(LayerId("other-weight"), 5)], None).await;
assert_eq!(charged, Charge::none());
assert_eq!(start.elapsed(), Duration::from_secs(3));
assert_eq!(engine.used(), 0);
}
#[tokio::test(start_paused = true)]
async fn a_paced_cost_takes_one_unit_and_no_minute() {
let paced = new_engine(Some((500, FIVE_MINUTES)));
let charged = charge(&paced, &[cost(PACED, 99)], Some(PACED)).await;
assert_eq!(
charged,
Charge::none().with(LayerCharge {
layer: PACED,
units: 1,
window: None,
})
);
assert_eq!(paced.used(), 0, "a paced request carries no weight");
let unpaced = new_engine(None);
let ignored = charge(&unpaced, &[cost(PACED, 99)], None).await;
assert_eq!(ignored, Charge::none());
}
#[tokio::test(start_paused = true)]
async fn an_engine_with_no_paced_layer_waits_out_a_cooldown_and_admits() {
let engine = new_engine(None);
engine.begin_cooldown(Duration::from_secs(10));
let start = Instant::now();
let charged = engine.acquire_paced().await;
assert_eq!(start.elapsed(), Duration::from_secs(10));
assert_eq!(charged, MinuteCharge { minute: None });
}
#[tokio::test(start_paused = true)]
async fn a_header_raises_the_layer_the_caller_names_and_no_other() {
let engine = new_engine(None);
let charged = charge(&engine, &[cost(WEIGHT, 1)], None).await;
observe(&engine, &charged, "100", LayerId("other-weight"));
assert_eq!(engine.used(), 1, "the charge holds no such layer");
observe(&engine, &charged, "100", WEIGHT);
assert_eq!(engine.used(), 100);
observe(&engine, &charged, "soon", WEIGHT);
assert_eq!(engine.used(), 100, "not a count");
observe(&engine, &charged, " 150 ", WEIGHT);
assert_eq!(engine.used(), 150, "whitespace around the count is ignored");
}
}