Skip to content

Commit 0bc8c3f

Browse files
authored
ref(server): Introduce a queue to the concurrency limiter (#562)
The concurrency limiter can now absorb brief capacity bursts by queuing requests instead of rejecting them immediately. This is implemented through semaphores: an outer semaphore (capacity `max + queue`) gates total participants, and an inner semaphore (capacity `max`) gates execution. Requests that pass the outer gate wait up to a configurable timeout for an execution permit; requests that exceed both limits are rejected with `AtCapacity` as before. With the default `concurrency_queue = 0`, both semaphores have the same capacity and behavior is identical to the current immediate-reject model. In this case, there is no queuing and no added latency. Two new config fields control the queue. Sizing guidance: set the queue depth to roughly `permit_release_rate × acceptable_added_latency`, and keep the timeout comfortably below client request timeouts. ```yaml service: max_concurrency: 500 concurrency_queue: 100 concurrency_queue_timeout: 1s ``` Batch `stream()` reservations continue to use non-blocking `try_acquire_many` and do not enter the queue. Ref FS-447
1 parent 65c8eaf commit 0bc8c3f

9 files changed

Lines changed: 415 additions & 88 deletions

File tree

objectstore-server/docs/architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ Direct 503 rejection is preferred over readiness-based backpressure:
232232
Beyond rate limiting and the web concurrency limit, the
233233
[`StorageService`](objectstore_service::StorageService) enforces a second
234234
layer of backpressure through a concurrency limit on in-flight backend
235-
operations, configured via `service.max_concurrency`. When exceeded, requests
236-
receive HTTP 429. See the [service architecture docs](objectstore_service) for
237-
details.
235+
operations, configured via `service.max_concurrency` and related options.
236+
When exceeded, requests receive HTTP 429. See the [service architecture
237+
docs](objectstore_service) for details.
238238

239239
## KEDA Metrics
240240

objectstore-server/src/config.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,8 @@ pub struct Config {
508508
/// # Environment Variables
509509
///
510510
/// - `OS__SERVICE__MAX_CONCURRENCY`
511+
/// - `OS__SERVICE__CONCURRENCY_QUEUE`
512+
/// - `OS__SERVICE__CONCURRENCY_QUEUE_TIMEOUT`
511513
#[derive(Debug, Deserialize, Serialize)]
512514
#[serde(default)]
513515
pub struct Service {
@@ -521,12 +523,39 @@ pub struct Service {
521523
///
522524
/// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
523525
pub max_concurrency: u32,
526+
527+
/// Maximum number of requests that may wait for a concurrency permit.
528+
///
529+
/// When all `max_concurrency` execution slots are held, up to this many
530+
/// additional requests will park and wait (for at most
531+
/// `concurrency_queue_timeout`) instead of being rejected immediately.
532+
/// Requests beyond that are rejected with HTTP 429.
533+
///
534+
/// Sizing guidance: `concurrency_queue ≈ permit_release_rate ×
535+
/// acceptable_added_latency`.
536+
///
537+
/// # Default
538+
///
539+
/// `0`
540+
pub concurrency_queue: u32,
541+
542+
/// Maximum time a request may wait in the concurrency queue.
543+
///
544+
/// Ignored when `concurrency_queue` is `0`.
545+
///
546+
/// # Default
547+
///
548+
/// `1s`
549+
#[serde(with = "humantime_serde")]
550+
pub concurrency_queue_timeout: Duration,
524551
}
525552

526553
impl Default for Service {
527554
fn default() -> Self {
528555
Self {
529556
max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
557+
concurrency_queue: 0,
558+
concurrency_queue_timeout: Duration::from_secs(1),
530559
}
531560
}
532561
}

objectstore-server/src/state.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::time::Duration;
99
use anyhow::Result;
1010
use bytes::Bytes;
1111
use futures_util::Stream;
12+
use objectstore_service::concurrency::ConcurrencyLimiter;
1213
use objectstore_service::id::ObjectContext;
1314
use objectstore_service::{StorageService, backend};
1415
use tokio::runtime::Handle;
@@ -61,8 +62,11 @@ impl Services {
6162
tokio::spawn(track_allocator_metrics(config.runtime.metrics_interval));
6263

6364
let backend = backend::from_config(config.storage.clone()).await?;
64-
let service =
65-
StorageService::new(backend).with_concurrency_limit(config.service.max_concurrency);
65+
let concurrency = ConcurrencyLimiter::new(config.service.max_concurrency).with_queue(
66+
config.service.concurrency_queue,
67+
config.service.concurrency_queue_timeout,
68+
);
69+
let service = StorageService::new(backend).with_concurrency(concurrency);
6670
service.start();
6771

6872
let key_directory = Arc::new(PublicKeyDirectory::from_config(&config.auth).await?);

objectstore-server/tests/limits.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,10 @@ async fn test_batch_at_capacity_returns_429() -> Result<()> {
629629
// With max_concurrency=0 the service has no permits available, so
630630
// BatchExecutor::new() returns AtCapacity and the endpoint responds 429.
631631
let server = TestServer::with_config(Config {
632-
service: Service { max_concurrency: 0 },
632+
service: Service {
633+
max_concurrency: 0,
634+
..Default::default()
635+
},
633636
auth: AuthZ {
634637
enforce: false,
635638
..Default::default()

objectstore-service/docs/architecture.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,20 +175,20 @@ during metadata creation by the server.
175175

176176
# Backpressure
177177

178-
The service applies backpressure to protect backends from overload. Rather than
179-
queueing work when capacity is exhausted, the service rejects operations
180-
immediately so the caller can shed load or retry.
178+
The service applies backpressure to protect backends from overload and to
179+
prevent exhaustion of internal resources such as memory.
181180

182181
## Concurrency Limit
183182

184-
A semaphore caps the total number of in-flight backend operations across all
185-
callers. A permit is acquired before each operation is spawned and held until
186-
the task completes — including on panic — so the limit counts *running*
187-
operations, not queued ones. When no permits are available, the operation fails
188-
with [`Error::AtCapacity`](error::Error::AtCapacity).
183+
A concurrency limiter caps in-flight
184+
backend operations. When all execution permits are held, new operations are
185+
queued — adding latency instead of rejecting immediately. The queue itself is
186+
bounded in both depth and time: operations that cannot be served within those
187+
limits fail with [`Error::AtCapacity`](error::Error::AtCapacity).
189188

190-
The default limit is [`DEFAULT_CONCURRENCY_LIMIT`](service::DEFAULT_CONCURRENCY_LIMIT). Callers can override it via
191-
[`StorageService::with_concurrency_limit`].
189+
The default execution limit is
190+
[`DEFAULT_CONCURRENCY_LIMIT`](service::DEFAULT_CONCURRENCY_LIMIT). See
191+
[`StorageService::with_concurrency`] for configuration.
192192

193193
## Multipart Uploads
194194

0 commit comments

Comments
 (0)