use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tocat_api::{BuildCtx, ByteSize, Ctx, Plugin, PluginError, PluginFactory, Result, Stage};
pub const NAME: &str = "throttle";
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ThrottleConfig {
#[serde(alias = "bandwidth", alias = "bps")]
pub rate: ByteSize,
#[serde(default)]
pub burst: Option<ByteSize>,
}
pub struct Throttle {
rate: f64,
burst: f64,
tokens: f64,
last: Instant,
}
impl Throttle {
fn debt(&self) -> Option<Duration> {
(self.tokens < 0.0).then(|| Duration::from_secs_f64(-self.tokens / self.rate))
}
}
impl Plugin for Throttle {
fn name(&self) -> &str {
NAME
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, input: &[u8]) -> Result<()> {
let now = Instant::now();
let elapsed = now.duration_since(self.last).as_secs_f64();
self.last = now;
self.tokens = (self.tokens + elapsed * self.rate).min(self.burst) - input.len() as f64;
ctx.pass_through();
if let Some(wait) = self.debt() {
ctx.pace(wait);
}
Ok(())
}
fn datagram_safe(&self) -> bool {
true
}
}
pub struct ThrottleFactory;
impl PluginFactory for ThrottleFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"hold this path to a bandwidth ceiling"
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: ThrottleConfig = ctx.config()?;
let rate = config.rate.bytes();
if rate == 0 {
return Err(PluginError::config(
NAME,
"rate must be greater than zero; to stop a stream use the `limit` plugin",
));
}
let burst = config.burst.map_or(rate, ByteSize::bytes).max(1);
Ok(Stage::filter(Throttle {
rate: rate as f64,
burst: burst as f64,
tokens: burst as f64,
last: Instant::now(),
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tocat_api::{
ChannelId, ChannelTarget, Direction, EffectSink, Emission, Emit, HostBuilder, LogLevel,
PipelineMeta, Result as PluginResult, StageInfo,
};
use super::*;
#[derive(Default)]
struct Recorder {
pace: Duration,
}
impl EffectSink for Recorder {
fn write(&mut self, _channel: ChannelId, _bytes: &[u8]) {}
fn log(&mut self, _level: LogLevel, _stage: &str, _message: &str) {}
fn pace(&mut self, delay: Duration) {
self.pace = self.pace.max(delay);
}
}
struct NullHost;
impl HostBuilder for NullHost {
fn open_channel(&mut self, _target: ChannelTarget) -> PluginResult<ChannelId> {
Ok(ChannelId(0))
}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "src", "sink")
}
fn build(config: serde_json::Value) -> Box<dyn Plugin> {
let map = config.as_object().expect("object").clone();
let meta = meta();
let mut host = NullHost;
let stage = StageInfo {
index: 0,
total: 1,
name: NAME,
upstream: "src",
downstream: "sink",
};
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage, &mut host);
match ThrottleFactory.build(&mut ctx).expect("build") {
Stage::Filter(plugin) => plugin,
Stage::External(_) => unreachable!("throttle is a filter"),
}
}
fn feed(plugin: &mut dyn Plugin, sink: &mut Recorder, input: &[u8]) -> Emit {
let meta = meta();
let mut emission = Emission::new();
{
let mut ctx = Ctx::new(&meta, NAME, input, &mut emission, sink);
plugin.on_bytes(&mut ctx, input).expect("on_bytes");
}
assert!(
emission.bytes().is_empty(),
"throttle must never copy the payload",
);
emission.emit()
}
#[test]
fn the_payload_is_never_touched() {
let mut plugin = build(json!({"rate": "1k"}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"hello"), Emit::Passthrough);
}
#[test]
fn traffic_within_the_burst_is_not_paced() {
let mut plugin = build(json!({"rate": "1k", "burst": "1k"}));
let mut sink = Recorder::default();
feed(&mut *plugin, &mut sink, &[0u8; 512]);
assert_eq!(sink.pace, Duration::ZERO, "the bucket starts full");
}
#[test]
fn overspending_asks_for_the_time_it_costs() {
let mut plugin = build(json!({"rate": "1k", "burst": "1k"}));
let mut sink = Recorder::default();
feed(&mut *plugin, &mut sink, &[0u8; 1024]);
feed(&mut *plugin, &mut sink, &[0u8; 1024]);
let asked = sink.pace.as_secs_f64();
assert!(
(0.95..=1.0).contains(&asked),
"expected about a second of waiting, got {asked}",
);
}
#[test]
fn a_chunk_larger_than_the_bucket_waits_rather_than_splitting() {
let mut plugin = build(json!({"rate": "1k", "burst": "1k"}));
let mut sink = Recorder::default();
assert_eq!(
feed(&mut *plugin, &mut sink, &[0u8; 4096]),
Emit::Passthrough,
"the chunk still goes out whole",
);
let asked = sink.pace.as_secs_f64();
assert!(
(2.95..=3.0).contains(&asked),
"3 KiB of debt at 1 KiB/s is about three seconds, got {asked}",
);
}
#[test]
fn a_rate_of_zero_is_rejected() {
let map = json!({"rate": 0}).as_object().expect("object").clone();
let meta = meta();
let mut host = NullHost;
let stage = StageInfo {
index: 0,
total: 1,
name: NAME,
upstream: "src",
downstream: "sink",
};
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage, &mut host);
assert!(ThrottleFactory.build(&mut ctx).is_err());
}
}