Skip to content

Commit c66dc06

Browse files
jubradclaude
andcommitted
balancerd: enforce a max connection limit
balancerd had no connection ceiling, so it accepted connections until the container hit its memory limit and was OOMKilled, dropping every established session rather than shedding only the load it could not serve. Add a `balancerd_max_connections` dyncfg (default 5000, 0 disables) enforced across the pgwire and HTTPS listeners by a shared limiter. Connections beyond the limit are refused with a fatal pgwire error (SQLSTATE 53300) or an HTTP 503, and the condition is visible through the new `mz_balancer_connection_rejected_total` and `mz_balancer_connection_limit` metrics plus a log line on entering and leaving the limited state. Closes: CLO-216 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2d0a559 commit c66dc06

6 files changed

Lines changed: 249 additions & 22 deletions

File tree

doc/user/data/metrics.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ metrics:
237237
- source
238238
source: src/balancerd/src/lib.rs
239239
visibility: internal
240+
- name: mz_balancer_connection_limit
241+
help: Maximum number of connections proxied at once, 0 if unlimited.
242+
source: src/balancerd/src/lib.rs
243+
visibility: internal
244+
- name: mz_balancer_connection_rejected_total
245+
help: Count of connections refused because the connection limit was reached.
246+
source: src/balancerd/src/lib.rs
247+
visibility: internal
240248
- name: mz_balancer_connection_status
241249
help: Count of completed network connections, by status
242250
labels:

misc/python/materialize/mzcompose/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ def get_default_system_parameters(
682682
"balancerd_sigterm_connection_wait",
683683
"balancerd_sigterm_listen_wait",
684684
"balancerd_inject_proxy_protocol_header_http",
685+
"balancerd_max_connections",
685686
"balancerd_log_filter",
686687
"balancerd_opentelemetry_filter",
687688
"balancerd_log_filter_defaults",

misc/python/materialize/parallel_workload/action.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3225,6 +3225,7 @@ def __init__(
32253225
"balancerd_sigterm_connection_wait",
32263226
"balancerd_sigterm_listen_wait",
32273227
"balancerd_inject_proxy_protocol_header_http",
3228+
"balancerd_max_connections",
32283229
"balancerd_log_filter",
32293230
"balancerd_opentelemetry_filter",
32303231
"balancerd_log_filter_defaults",

src/balancerd/src/dyncfgs.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ pub const INJECT_PROXY_PROTOCOL_HEADER_HTTP: Config<bool> = Config::new(
4343
"Whether to inject tcp proxy protocol headers to downstream http servers.",
4444
);
4545

46+
/// Maximum number of client connections to proxy at once.
47+
pub const MAX_CONNECTIONS: Config<u32> = Config::new(
48+
"balancerd_max_connections",
49+
5000,
50+
"Maximum number of client connections to proxy at once, across the pgwire and HTTPS \
51+
listeners. Connections beyond this are rejected. Zero disables the limit.",
52+
);
53+
4654
/// Sets the filter to apply to stderr logging.
4755
pub const LOGGING_FILTER: Config<&str> = Config::new(
4856
"balancerd_log_filter",
@@ -98,6 +106,7 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
98106
.add(&SIGTERM_CONNECTION_WAIT)
99107
.add(&SIGTERM_LISTEN_WAIT)
100108
.add(&INJECT_PROXY_PROTOCOL_HEADER_HTTP)
109+
.add(&MAX_CONNECTIONS)
101110
.add(&LOGGING_FILTER)
102111
.add(&OPENTELEMETRY_FILTER)
103112
.add(&LOGGING_FILTER_DEFAULTS)
@@ -124,6 +133,11 @@ pub(crate) fn set_defaults(
124133
INJECT_PROXY_PROTOCOL_HEADER_HTTP.name(),
125134
mz_dyncfg::ConfigVal::Bool(bool::from_str(v)?),
126135
)
136+
} else if k.as_str() == MAX_CONNECTIONS.name() {
137+
config_updates.add_dynamic(
138+
MAX_CONNECTIONS.name(),
139+
mz_dyncfg::ConfigVal::U32(u32::from_str(v)?),
140+
)
127141
} else {
128142
return Err(anyhow!("Invalid default config value {k}"));
129143
}

src/balancerd/src/lib.rs

Lines changed: 175 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use std::net::{IpAddr, SocketAddr};
2424
use std::path::PathBuf;
2525
use std::pin::Pin;
2626
use std::sync::Arc;
27+
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
2728
use std::time::{Duration, Instant};
2829

2930
use anyhow::Context;
@@ -46,7 +47,7 @@ use mz_dyncfg::ConfigSet;
4647
use mz_frontegg_auth::Authenticator as FronteggAuthentication;
4748
use mz_ore::cast::CastFrom;
4849
use mz_ore::id_gen::conn_id_org_uuid;
49-
use mz_ore::metrics::{ComputedGauge, IntCounter, IntGauge, MetricsRegistry};
50+
use mz_ore::metrics::{ComputedGauge, ComputedUIntGauge, IntCounter, IntGauge, MetricsRegistry};
5051
use mz_ore::netio::AsyncReady;
5152
use mz_ore::now::{NowFn, SYSTEM_TIME, epoch_to_uuid_v7};
5253
use mz_ore::task::{JoinSetExt, spawn};
@@ -72,13 +73,13 @@ use tokio_metrics::TaskMetrics;
7273
use tokio_openssl::SslStream;
7374
use tokio_postgres::error::SqlState;
7475
use tower::Service;
75-
use tracing::{debug, error, warn};
76+
use tracing::{debug, error, info, warn};
7677
use uuid::Uuid;
7778

7879
use crate::codec::{BackendMessage, FramedConn};
7980
use crate::dyncfgs::{
80-
INJECT_PROXY_PROTOCOL_HEADER_HTTP, SIGTERM_CONNECTION_WAIT, SIGTERM_LISTEN_WAIT,
81-
has_tracing_config_update, tracing_config,
81+
INJECT_PROXY_PROTOCOL_HEADER_HTTP, MAX_CONNECTIONS, SIGTERM_CONNECTION_WAIT,
82+
SIGTERM_LISTEN_WAIT, has_tracing_config_update, tracing_config,
8283
};
8384

8485
/// Balancer build information.
@@ -315,6 +316,7 @@ impl BalancerService {
315316
};
316317

317318
let metrics = ServerMetricsConfig::register_into(&self.cfg.metrics_registry);
319+
let limiter = ConnectionLimiter::new(&self.cfg.metrics_registry, self.configs.clone());
318320

319321
let mut set = JoinSet::new();
320322
let mut server_handles = Vec::new();
@@ -338,6 +340,7 @@ impl BalancerService {
338340
tls: pgwire_tls,
339341
internal_tls: self.cfg.internal_tls,
340342
metrics: ServerMetrics::new(metrics.clone(), "pgwire"),
343+
limiter: Arc::clone(&limiter),
341344
now: SYSTEM_TIME.clone(),
342345
};
343346
let (handle, stream) = self.pgwire;
@@ -370,6 +373,7 @@ impl BalancerService {
370373
resolve_template: Arc::from(addr),
371374
port,
372375
metrics: Arc::from(ServerMetrics::new(metrics, "https")),
376+
limiter,
373377
configs: self.configs.clone(),
374378
internal_tls: self.cfg.internal_tls,
375379
};
@@ -613,6 +617,91 @@ impl ServerMetrics {
613617
}
614618
}
615619

620+
/// Ceiling on the number of client connections proxied at once, shared by the pgwire and HTTPS
621+
/// listeners.
622+
///
623+
/// balancerd's memory use scales with the number of connections it proxies. Without a ceiling it
624+
/// keeps accepting until the container is OOM killed, which drops every established connection.
625+
/// Refusing new connections instead sheds only the load we cannot serve.
626+
#[derive(Debug)]
627+
struct ConnectionLimiter {
628+
configs: ConfigSet,
629+
active: AtomicU32,
630+
/// Whether connections are currently being refused, so that reaching and clearing the limit
631+
/// are logged once each rather than once per connection.
632+
limited: AtomicBool,
633+
rejected: IntCounter,
634+
_limit: ComputedUIntGauge,
635+
}
636+
637+
impl ConnectionLimiter {
638+
fn new(registry: &MetricsRegistry, configs: ConfigSet) -> Arc<Self> {
639+
let rejected = registry.register(metric!(
640+
name: "mz_balancer_connection_rejected_total",
641+
help: "Count of connections refused because the connection limit was reached.",
642+
));
643+
let limit = registry.register_computed_gauge(
644+
metric!(
645+
name: "mz_balancer_connection_limit",
646+
help: "Maximum number of connections proxied at once, 0 if unlimited.",
647+
),
648+
{
649+
let configs = configs.clone();
650+
move || u64::from(MAX_CONNECTIONS.get(&configs))
651+
},
652+
);
653+
Arc::new(ConnectionLimiter {
654+
configs,
655+
active: AtomicU32::new(0),
656+
limited: AtomicBool::new(false),
657+
rejected,
658+
_limit: limit,
659+
})
660+
}
661+
662+
/// Reserves capacity for one connection, or returns `None` if the limit has been reached, in
663+
/// which case the caller must refuse the connection.
664+
fn acquire(self: &Arc<Self>) -> Option<ConnectionGuard> {
665+
let limit = MAX_CONNECTIONS.get(&self.configs);
666+
let unlimited = limit == 0;
667+
match self
668+
.active
669+
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |active| {
670+
(unlimited || active < limit).then(|| active + 1)
671+
}) {
672+
Ok(prev) => {
673+
// Only clear the limited state once comfortably below the limit, so that
674+
// connections churning at the ceiling do not flap the log lines.
675+
if unlimited || prev + 1 <= limit - limit / 10 {
676+
if self.limited.swap(false, Ordering::Relaxed) {
677+
info!("accepting new connections again, limit is {limit}");
678+
}
679+
}
680+
Some(ConnectionGuard(Arc::clone(self)))
681+
}
682+
Err(active) => {
683+
self.rejected.inc();
684+
if !self.limited.swap(true, Ordering::Relaxed) {
685+
warn!(
686+
"refusing new connections: at the limit of {limit} connections \
687+
({active} active)"
688+
);
689+
}
690+
None
691+
}
692+
}
693+
}
694+
}
695+
696+
/// Releases the connection reserved by [`ConnectionLimiter::acquire`] when dropped.
697+
struct ConnectionGuard(Arc<ConnectionLimiter>);
698+
699+
impl Drop for ConnectionGuard {
700+
fn drop(&mut self) {
701+
self.0.active.fetch_sub(1, Ordering::SeqCst);
702+
}
703+
}
704+
616705
pub enum CancellationResolver {
617706
Directory(PathBuf),
618707
Static(String),
@@ -624,6 +713,7 @@ struct PgwireBalancer {
624713
cancellation_resolver: Arc<CancellationResolver>,
625714
resolver: Arc<BalancerResolver>,
626715
metrics: ServerMetrics,
716+
limiter: Arc<ConnectionLimiter>,
627717
now: NowFn,
628718
}
629719

@@ -637,6 +727,7 @@ impl PgwireBalancer {
637727
tls_mode: Option<TlsMode>,
638728
internal_tls: bool,
639729
metrics: &ServerMetrics,
730+
limiter: &Arc<ConnectionLimiter>,
640731
) -> Result<(), io::Error>
641732
where
642733
A: AsyncRead + AsyncWrite + AsyncReady + Send + Sync + Unpin,
@@ -663,6 +754,15 @@ impl PgwireBalancer {
663754
return conn.send(err).await;
664755
}
665756

757+
let Some(_conn_guard) = limiter.acquire() else {
758+
return conn
759+
.send(ErrorResponse::fatal(
760+
SqlState::TOO_MANY_CONNECTIONS,
761+
"balancer is at its connection limit",
762+
))
763+
.await;
764+
};
765+
666766
let resolved = match resolver.resolve(conn, user, metrics).await {
667767
Ok(v) => v,
668768
Err(err) => {
@@ -837,6 +937,7 @@ impl mz_server_core::Server for PgwireBalancer {
837937
let resolver = Arc::clone(&self.resolver);
838938
let inner_metrics = self.metrics.clone();
839939
let outer_metrics = self.metrics.clone();
940+
let limiter = Arc::clone(&self.limiter);
840941
let cancellation_resolver = Arc::clone(&self.cancellation_resolver);
841942
let conn_uuid = epoch_to_uuid_v7(&(self.now)());
842943
let peer_addr = conn.peer_addr();
@@ -910,6 +1011,7 @@ impl mz_server_core::Server for PgwireBalancer {
9101011
tls.map(|tls| tls.mode),
9111012
internal_tls,
9121013
&inner_metrics,
1014+
&limiter,
9131015
)
9141016
.await?;
9151017
conn.flush().await?;
@@ -1107,12 +1209,31 @@ async fn cancel_request(
11071209
}
11081210
}
11091211

1212+
/// Writes an HTTP error response to a client and closes the connection.
1213+
///
1214+
/// The proxied connections are raw TCP streams, so the HTTP framing has to be written by hand.
1215+
/// Errors are ignored: the connection is going away regardless.
1216+
async fn send_http_error(client_stream: &mut Box<dyn ClientStream>, status: &str, body: &str) {
1217+
let response = format!(
1218+
"HTTP/1.1 {status}\r\n\
1219+
Content-Type: text/plain\r\n\
1220+
Content-Length: {}\r\n\
1221+
Connection: close\r\n\
1222+
\r\n\
1223+
{body}",
1224+
body.len(),
1225+
);
1226+
let _ = client_stream.write_all(response.as_bytes()).await;
1227+
let _ = client_stream.shutdown().await;
1228+
}
1229+
11101230
struct HttpsBalancer {
11111231
resolver: Arc<TenantDnsResolver>,
11121232
tls: Option<ReloadingSslContext>,
11131233
resolve_template: Arc<str>,
11141234
port: u16,
11151235
metrics: Arc<ServerMetrics>,
1236+
limiter: Arc<ConnectionLimiter>,
11161237
configs: ConfigSet,
11171238
internal_tls: bool,
11181239
}
@@ -1203,6 +1324,7 @@ impl mz_server_core::Server for HttpsBalancer {
12031324
let port = self.port;
12041325
let inner_metrics = Arc::clone(&self.metrics);
12051326
let outer_metrics = Arc::clone(&self.metrics);
1327+
let limiter = Arc::clone(&self.limiter);
12061328
let peer_addr = conn.peer_addr();
12071329
let inject_proxy_headers = INJECT_PROXY_PROTOCOL_HEADER_HTTP.get(&self.configs);
12081330
Box::pin(async move {
@@ -1231,6 +1353,16 @@ impl mz_server_core::Server for HttpsBalancer {
12311353
}
12321354
_ => (Box::new(conn), None),
12331355
};
1356+
let Some(_conn_guard) = limiter.acquire() else {
1357+
send_http_error(
1358+
&mut client_stream,
1359+
"503 Service Unavailable",
1360+
"balancer is at its connection limit",
1361+
)
1362+
.await;
1363+
return Ok(());
1364+
};
1365+
12341366
let resolved =
12351367
Self::resolve(&resolver, &resolve_template, port, servername.as_deref())
12361368
.await?;
@@ -1242,24 +1374,12 @@ impl mz_server_core::Server for HttpsBalancer {
12421374
Ok(stream) => stream,
12431375
Err(e) => {
12441376
error!("failed to connect to upstream server: {e}");
1245-
let body = "upstream server not available";
1246-
// We know this is an HTTPs stream (see name
1247-
// HttpsBalancer), but we actually don't care what type
1248-
// of traffic it is and we only use raw tcp streams.In
1249-
// order to respond with HTTP we have to write this as a
1250-
// raw http message.
1251-
let response = format!(
1252-
"HTTP/1.1 502 Bad Gateway\r\n\
1253-
Content-Type: text/plain\r\n\
1254-
Content-Length: {}\r\n\
1255-
Connection: close\r\n\
1256-
\r\n\
1257-
{}",
1258-
body.len(),
1259-
body
1260-
);
1261-
let _ = client_stream.write_all(response.as_bytes()).await;
1262-
let _ = client_stream.shutdown().await;
1377+
send_http_error(
1378+
&mut client_stream,
1379+
"502 Bad Gateway",
1380+
"upstream server not available",
1381+
)
1382+
.await;
12631383
return Ok(());
12641384
}
12651385
};
@@ -1654,6 +1774,8 @@ struct ResolvedAddr {
16541774

16551775
#[cfg(test)]
16561776
mod tests {
1777+
use mz_dyncfg::ConfigUpdates;
1778+
16571779
use super::*;
16581780

16591781
#[mz_ore::test]
@@ -1726,4 +1848,35 @@ mod tests {
17261848
assert_eq!(strip_ipv6_brackets("[unclosed"), "[unclosed");
17271849
assert_eq!(strip_ipv6_brackets("unopened]"), "unopened]");
17281850
}
1851+
1852+
#[mz_ore::test]
1853+
fn test_connection_limiter() {
1854+
let configs = dyncfgs::all_dyncfgs(ConfigSet::default());
1855+
let set_max = |max: u32| {
1856+
let mut updates = ConfigUpdates::default();
1857+
updates.add(&MAX_CONNECTIONS, max);
1858+
updates.apply(&configs);
1859+
};
1860+
1861+
set_max(2);
1862+
let limiter = ConnectionLimiter::new(&MetricsRegistry::new(), configs.clone());
1863+
let first = limiter.acquire().expect("under the limit");
1864+
let second = limiter.acquire().expect("at the limit");
1865+
assert!(limiter.acquire().is_none());
1866+
assert_eq!(limiter.rejected.get(), 1);
1867+
1868+
// A connection closing frees capacity for a new one.
1869+
drop(second);
1870+
let _third = limiter.acquire().expect("capacity freed");
1871+
drop(first);
1872+
1873+
// Lowering the limit below the current count refuses new connections but leaves the
1874+
// established ones alone.
1875+
set_max(1);
1876+
assert!(limiter.acquire().is_none());
1877+
1878+
// Zero disables the limit.
1879+
set_max(0);
1880+
assert!(limiter.acquire().is_some());
1881+
}
17291882
}

0 commit comments

Comments
 (0)