Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions objectstore-server/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,9 @@ Direct 503 rejection is preferred over readiness-based backpressure:
Beyond rate limiting and the web concurrency limit, the
[`StorageService`](objectstore_service::StorageService) enforces a second
layer of backpressure through a concurrency limit on in-flight backend
operations, configured via `service.max_concurrency`. When exceeded, requests
receive HTTP 429. See the [service architecture docs](objectstore_service) for
details.
operations, configured via `service.max_concurrency` and related options.
When exceeded, requests receive HTTP 429. See the [service architecture
Comment thread
lcian marked this conversation as resolved.
docs](objectstore_service) for details.

## KEDA Metrics

Expand Down
29 changes: 29 additions & 0 deletions objectstore-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,8 @@ pub struct Config {
/// # Environment Variables
///
/// - `OS__SERVICE__MAX_CONCURRENCY`
/// - `OS__SERVICE__CONCURRENCY_QUEUE`
/// - `OS__SERVICE__CONCURRENCY_QUEUE_TIMEOUT`
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct Service {
Expand All @@ -521,12 +523,39 @@ pub struct Service {
///
/// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
pub max_concurrency: u32,

/// Maximum number of requests that may wait for a concurrency permit.
///
/// When all `max_concurrency` execution slots are held, up to this many
/// additional requests will park and wait (for at most
/// `concurrency_queue_timeout`) instead of being rejected immediately.
/// Requests beyond that are rejected with HTTP 429.
///
/// Sizing guidance: `concurrency_queue ≈ permit_release_rate ×
/// acceptable_added_latency`.
///
/// # Default
///
/// `0`
pub concurrency_queue: u32,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub concurrency_queue: u32,
pub concurrency_queue_size: u32,

Maybe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not entirely happy with this. Was also thinking of max_queued. Let's wait for the follow-up that introduces another parameter for batch requests and then reassess.


/// Maximum time a request may wait in the concurrency queue.
///
/// Ignored when `concurrency_queue` is `0`.
///
/// # Default
///
/// `1s`
#[serde(with = "humantime_serde")]
pub concurrency_queue_timeout: Duration,
}

impl Default for Service {
fn default() -> Self {
Self {
max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
concurrency_queue: 0,
concurrency_queue_timeout: Duration::from_secs(1),
}
}
}
Expand Down
8 changes: 6 additions & 2 deletions objectstore-server/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::time::Duration;
use anyhow::Result;
use bytes::Bytes;
use futures_util::Stream;
use objectstore_service::concurrency::ConcurrencyLimiter;
use objectstore_service::id::ObjectContext;
use objectstore_service::{StorageService, backend};
use tokio::runtime::Handle;
Expand Down Expand Up @@ -61,8 +62,11 @@ impl Services {
tokio::spawn(track_allocator_metrics(config.runtime.metrics_interval));

let backend = backend::from_config(config.storage.clone()).await?;
let service =
StorageService::new(backend).with_concurrency_limit(config.service.max_concurrency);
let concurrency = ConcurrencyLimiter::new(config.service.max_concurrency).with_queue(
config.service.concurrency_queue,
config.service.concurrency_queue_timeout,
);
let service = StorageService::new(backend).with_concurrency(concurrency);
service.start();

let key_directory = Arc::new(PublicKeyDirectory::from_config(&config.auth).await?);
Expand Down
5 changes: 4 additions & 1 deletion objectstore-server/tests/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,10 @@ async fn test_batch_at_capacity_returns_429() -> Result<()> {
// With max_concurrency=0 the service has no permits available, so
// BatchExecutor::new() returns AtCapacity and the endpoint responds 429.
let server = TestServer::with_config(Config {
service: Service { max_concurrency: 0 },
service: Service {
max_concurrency: 0,
..Default::default()
},
auth: AuthZ {
enforce: false,
..Default::default()
Expand Down
20 changes: 10 additions & 10 deletions objectstore-service/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,20 @@ during metadata creation by the server.

# Backpressure

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

## Concurrency Limit

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

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

## Multipart Uploads

Expand Down
Loading
Loading