Skip to content

Commit 71ef635

Browse files
authored
feat(server): Implement bandwidth-based rate limits (#255)
1 parent a9f3b74 commit 71ef635

4 files changed

Lines changed: 128 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

objectstore-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ anyhow = { workspace = true }
1515
argh = "0.1.13"
1616
axum = "0.8.4"
1717
axum-extra = "0.10.1"
18+
bytes = { workspace = true }
1819
console = "0.16.1"
1920
elegant-departure = { version = "0.3.2", features = ["tokio"] }
2021
figment = { version = "0.10.19", features = ["env", "test", "yaml"] }

objectstore-server/src/endpoints/objects.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
use anyhow::Context as _;
12
use std::io;
23
use std::time::SystemTime;
34

4-
use anyhow::Context;
55
use axum::body::Body;
6+
use axum::extract::State;
67
use axum::http::{HeaderMap, StatusCode};
78
use axum::response::{IntoResponse, Response};
89
use axum::routing;
@@ -15,6 +16,7 @@ use serde::Serialize;
1516
use crate::auth::AuthAwareService;
1617
use crate::endpoints::common::ApiResult;
1718
use crate::extractors::Xt;
19+
use crate::rate_limits::MeteredPayloadStream;
1820
use crate::state::ServiceState;
1921

2022
pub fn router() -> Router<ServiceState> {
@@ -39,6 +41,7 @@ pub struct InsertObjectResponse {
3941

4042
async fn objects_post(
4143
service: AuthAwareService,
44+
State(state): State<ServiceState>,
4245
Xt(context): Xt<ObjectContext>,
4346
headers: HeaderMap,
4447
body: Body,
@@ -48,6 +51,8 @@ async fn objects_post(
4851
metadata.time_created = Some(SystemTime::now());
4952

5053
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
54+
let stream = MeteredPayloadStream::from(stream, state.rate_limiter.bytes_accumulator()).boxed();
55+
5156
let response_id = service
5257
.insert_object(context, None, &metadata, stream)
5358
.await?;
@@ -58,10 +63,15 @@ async fn objects_post(
5863
Ok((StatusCode::CREATED, response).into_response())
5964
}
6065

61-
async fn object_get(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
66+
async fn object_get(
67+
service: AuthAwareService,
68+
State(state): State<ServiceState>,
69+
Xt(id): Xt<ObjectId>,
70+
) -> ApiResult<Response> {
6271
let Some((metadata, stream)) = service.get_object(&id).await? else {
6372
return Ok(StatusCode::NOT_FOUND.into_response());
6473
};
74+
let stream = MeteredPayloadStream::from(stream, state.rate_limiter.bytes_accumulator()).boxed();
6575

6676
let headers = metadata
6777
.to_headers("", false)
@@ -83,6 +93,7 @@ async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResu
8393

8494
async fn object_put(
8595
service: AuthAwareService,
96+
State(state): State<ServiceState>,
8697
Xt(id): Xt<ObjectId>,
8798
headers: HeaderMap,
8899
body: Body,
@@ -93,6 +104,7 @@ async fn object_put(
93104

94105
let ObjectId { context, key } = id;
95106
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
107+
let stream = MeteredPayloadStream::from(stream, state.rate_limiter.bytes_accumulator()).boxed();
96108
let response_id = service
97109
.insert_object(context, Some(key), &metadata, stream)
98110
.await?;

objectstore-server/src/rate_limits.rs

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
use std::pin::Pin;
2+
use std::sync::Arc;
13
use std::sync::Mutex;
2-
use std::time::Instant;
4+
use std::sync::atomic::AtomicU64;
5+
use std::task::{Context, Poll};
6+
use std::time::{Duration, Instant};
37

8+
use bytes::Bytes;
9+
use futures_util::Stream;
10+
use objectstore_service::PayloadStream;
411
use objectstore_service::id::ObjectContext;
512
use objectstore_types::scope::Scopes;
613
use serde::{Deserialize, Serialize};
@@ -102,6 +109,7 @@ pub struct BandwidthLimits {
102109
#[derive(Debug)]
103110
pub struct RateLimiter {
104111
config: RateLimits,
112+
bandwidth: BandwidthRateLimiter,
105113
global: Option<Mutex<TokenBucket>>,
106114
// NB: These maps grow unbounded but we accept this as we expect an overall limited
107115
// number of usecases and scopes. We emit gauge metrics to monitor their size.
@@ -110,6 +118,64 @@ pub struct RateLimiter {
110118
rules: papaya::HashMap<usize, Mutex<TokenBucket>>,
111119
}
112120

121+
#[derive(Debug)]
122+
struct BandwidthRateLimiter {
123+
config: BandwidthLimits,
124+
/// Accumulator that's incremented every time an operation that uses bandwidth is executed.
125+
accumulator: Arc<AtomicU64>,
126+
/// An estimate of the bandwidth that's currently being utilized in bytes per second.
127+
estimate: Arc<AtomicU64>,
128+
}
129+
130+
impl BandwidthRateLimiter {
131+
fn new(config: BandwidthLimits) -> Self {
132+
let accumulator = Arc::new(AtomicU64::new(0));
133+
let estimate = Arc::new(AtomicU64::new(0));
134+
135+
let accumulator_clone = Arc::clone(&accumulator);
136+
let estimate_clone = Arc::clone(&estimate);
137+
tokio::task::spawn(async move {
138+
Self::estimator(accumulator_clone, estimate_clone).await;
139+
});
140+
141+
Self {
142+
config,
143+
accumulator,
144+
estimate,
145+
}
146+
}
147+
148+
/// Estimates the current bandwidth utilization using an exponentially weighted moving average.
149+
///
150+
/// The calculation is based on the increments of `self.accumulator` happened in the last `TICK`.
151+
/// The estimate is stored in `self.estimate`, which can be queried for bandwidth-based rate-limiting.
152+
async fn estimator(accumulator: Arc<AtomicU64>, estimate: Arc<AtomicU64>) {
153+
const TICK: Duration = Duration::from_millis(50); // Recompute EWMA on every TICK
154+
const ALPHA: f64 = 0.2; // EWMA alpha parameter: 20% weight to new sample, 80% to previous average
155+
156+
let mut interval = tokio::time::interval(TICK);
157+
let to_bps = 1.0 / TICK.as_secs_f64(); // Conversion factor from bytes to bps
158+
let mut ewma: f64 = 0.0;
159+
loop {
160+
interval.tick().await;
161+
let current = accumulator.swap(0, std::sync::atomic::Ordering::Relaxed);
162+
let bps = (current as f64) * to_bps;
163+
ewma = ALPHA * bps + (1.0 - ALPHA) * ewma;
164+
165+
let ewma_int = ewma.floor() as u64;
166+
estimate.store(ewma_int, std::sync::atomic::Ordering::Relaxed);
167+
merni::gauge!("server.bandwidth.ewma"@b: ewma_int);
168+
}
169+
}
170+
171+
fn check(&self) -> bool {
172+
let Some(bps) = self.config.global_bps else {
173+
return true;
174+
};
175+
self.estimate.load(std::sync::atomic::Ordering::Relaxed) <= bps
176+
}
177+
}
178+
113179
impl RateLimiter {
114180
pub fn new(config: RateLimits) -> Self {
115181
let global = config
@@ -118,19 +184,25 @@ impl RateLimiter {
118184
.map(|rps| Mutex::new(TokenBucket::new(rps, config.throughput.burst)));
119185

120186
Self {
121-
config,
187+
config: config.clone(),
188+
bandwidth: BandwidthRateLimiter::new(config.bandwidth),
122189
global,
123190
usecases: papaya::HashMap::new(),
124191
scopes: papaya::HashMap::new(),
125192
rules: papaya::HashMap::new(),
126193
}
127194
}
128195

196+
/// Returns a reference to the shared bytes accumulator, used for bandwidth-based rate-limiting.
197+
pub fn bytes_accumulator(&self) -> Arc<AtomicU64> {
198+
Arc::clone(&self.bandwidth.accumulator)
199+
}
200+
129201
/// Checks if the given context is within the rate limits.
130202
///
131203
/// Returns `true` if the context is within the rate limits, `false` otherwise.
132204
pub fn check(&self, context: &ObjectContext) -> bool {
133-
self.check_throughput(context) && self.check_bandwidth(context)
205+
self.check_throughput(context) && self.bandwidth.check()
134206
}
135207

136208
fn check_throughput(&self, context: &ObjectContext) -> bool {
@@ -181,11 +253,6 @@ impl RateLimiter {
181253
true
182254
}
183255

184-
fn check_bandwidth(&self, _context: &ObjectContext) -> bool {
185-
// Not implemented
186-
true
187-
}
188-
189256
fn create_bucket(&self, rps: u32) -> Mutex<TokenBucket> {
190257
Mutex::new(TokenBucket::new(rps, self.config.throughput.burst))
191258
}
@@ -273,3 +340,40 @@ impl TokenBucket {
273340
}
274341
}
275342
}
343+
344+
/// A wrapper around a `PayloadStream` that measures bandwidth usage.
345+
///
346+
/// This behaves exactly as a `PayloadStream`, except that every time an item is polled,
347+
/// the accumulator is incremented by the size of the returned `Bytes` chunk.
348+
pub(crate) struct MeteredPayloadStream {
349+
inner: PayloadStream,
350+
accumulator: Arc<AtomicU64>,
351+
}
352+
353+
impl MeteredPayloadStream {
354+
pub fn from(inner: PayloadStream, accumulator: Arc<AtomicU64>) -> Self {
355+
Self { inner, accumulator }
356+
}
357+
}
358+
359+
impl std::fmt::Debug for MeteredPayloadStream {
360+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361+
f.debug_struct("MeteredPayloadStream")
362+
.field("accumulator", &self.accumulator)
363+
.finish()
364+
}
365+
}
366+
367+
impl Stream for MeteredPayloadStream {
368+
type Item = std::io::Result<Bytes>;
369+
370+
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
371+
let this = self.get_mut();
372+
let res = this.inner.as_mut().poll_next(cx);
373+
if let Poll::Ready(Some(Ok(ref bytes))) = res {
374+
this.accumulator
375+
.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
376+
}
377+
res
378+
}
379+
}

0 commit comments

Comments
 (0)