Skip to content

Commit b955614

Browse files
committed
feat: TCP keepalive for Postgres and MySQL connections
Adds `TcpKeepalive` and `net::connect_tcp_with_keepalive`, plus `tcp_keepalive()` on `PgConnectOptions` and `MySqlConnectOptions` and libpq-compatible URL parameters for Postgres (`keepalives`, `keepalives_idle`, `keepalives_interval`, `keepalives_count`). Keepalive is off by default, so nothing changes for existing users. Motivation: without it, a connection whose server disappeared *without* closing the socket never finds out. A failover, a killed container, or a dropped NAT mapping leaves the client blocked reading a response that will never arrive; there is nothing left to retransmit, so no RST is ever provoked and the read waits forever. `TCP_NODELAY`, which is all SQLx sets today, does not help, and a server-side `statement_timeout` cannot fire on a server that is gone. This is the case @abonander allowed for in #3559 (comment): "I suppose that's still preferable to it hanging forever on a read that will never complete." Worth adding on the objection raised there, that a keepalive timeout is only noticed the next time the socket is used: that is not true of a blocked reader. When the probes are exhausted the kernel sets `sk_err` to `ETIMEDOUT` and wakes anyone parked on the socket, so the pending read fails rather than waiting for someone to poke it. We hit this in production: a maintenance loop that had a query in flight when its Postgres instance went away stopped doing work permanently, while other loops in the same process recovered in seconds. Reproduced by holding an `ACCESS EXCLUSIVE` lock so the query blocks server-side, then removing the server from the network and restarting it. Implementation follows #3559 by @xuehaonan27, rebased onto the current `connect_tcp` and reduced to an additive API: `connect_tcp` keeps its signature and delegates, so external drivers are unaffected.
1 parent 1d674f5 commit b955614

9 files changed

Lines changed: 295 additions & 8 deletions

File tree

sqlx-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ url = { version = "2.2.2" }
100100
bstr = { version = "1.0.1", default-features = false, features = ["std"], optional = true }
101101
hashlink = "0.11.0"
102102
indexmap = "2.0"
103+
socket2 = { version = "0.6.5", features = ["all"] }
103104
event-listener = "5.2.0"
104105
hashbrown = "0.16.0"
105106

sqlx-core/src/net/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ mod socket;
22
pub mod tls;
33

44
pub use socket::{
5-
connect_tcp, connect_uds, BufferedSocket, Socket, SocketIntoBox, WithSocket, WriteBuffer,
5+
connect_tcp, connect_tcp_with_keepalive, connect_uds, BufferedSocket, Socket, SocketIntoBox,
6+
TcpKeepalive, WithSocket, WriteBuffer,
67
};

sqlx-core/src/net/socket/mod.rs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ use std::task::{ready, Context, Poll};
77
pub use buffered::{BufferedSocket, WriteBuffer};
88
use bytes::BufMut;
99
use cfg_if::cfg_if;
10+
pub use tcp_keepalive::TcpKeepalive;
1011

1112
use crate::io::ReadBuf;
1213

1314
mod buffered;
15+
mod tcp_keepalive;
1416

1517
pub trait Socket: Send + Sync + Unpin + 'static {
1618
fn try_read(&mut self, buf: &mut dyn ReadBuf) -> io::Result<usize>;
@@ -185,23 +187,69 @@ pub async fn connect_tcp<Ws: WithSocket>(
185187
host: &str,
186188
port: u16,
187189
with_socket: Ws,
190+
) -> crate::Result<Ws::Output> {
191+
connect_tcp_with_keepalive(host, port, with_socket, None).await
192+
}
193+
194+
/// Open a TCP socket to `host` and `port`, optionally configuring TCP keepalive on it.
195+
///
196+
/// Without keepalive, a connection whose server disappeared without closing the socket
197+
/// (a failover, a killed container, a dropped NAT mapping) is only discovered the next
198+
/// time the client writes to it. A connection blocked reading a response waits forever.
199+
pub async fn connect_tcp_with_keepalive<Ws: WithSocket>(
200+
host: &str,
201+
port: u16,
202+
with_socket: Ws,
203+
keepalive: Option<&TcpKeepalive>,
188204
) -> crate::Result<Ws::Output> {
189205
#[cfg(feature = "_rt-tokio")]
190206
if crate::rt::rt_tokio::available() {
191-
return Ok(with_socket
192-
.with_socket(tokio::net::TcpStream::connect((host, port)).await?)
193-
.await);
207+
let stream = tokio::net::TcpStream::connect((host, port)).await?;
208+
set_tcp_keepalive(&stream, keepalive)?;
209+
210+
return Ok(with_socket.with_socket(stream).await);
194211
}
195212

196213
cfg_if! {
197214
if #[cfg(feature = "_rt-async-io")] {
198-
Ok(with_socket.with_socket(connect_tcp_async_io(host, port).await?).await)
215+
let stream = connect_tcp_async_io(host, port).await?;
216+
set_tcp_keepalive(&stream, keepalive)?;
217+
218+
Ok(with_socket.with_socket(stream).await)
199219
} else {
200-
crate::rt::missing_rt((host, port, with_socket))
220+
crate::rt::missing_rt((host, port, with_socket, keepalive))
201221
}
202222
}
203223
}
204224

225+
#[cfg(all(unix, any(feature = "_rt-tokio", feature = "_rt-async-io")))]
226+
fn set_tcp_keepalive<S: std::os::fd::AsFd>(
227+
stream: &S,
228+
keepalive: Option<&TcpKeepalive>,
229+
) -> crate::Result<()> {
230+
let Some(keepalive) = keepalive else {
231+
return Ok(());
232+
};
233+
234+
socket2::SockRef::from(stream).set_tcp_keepalive(&keepalive.to_socket2())?;
235+
236+
Ok(())
237+
}
238+
239+
#[cfg(all(windows, any(feature = "_rt-tokio", feature = "_rt-async-io")))]
240+
fn set_tcp_keepalive<S: std::os::windows::io::AsSocket>(
241+
stream: &S,
242+
keepalive: Option<&TcpKeepalive>,
243+
) -> crate::Result<()> {
244+
let Some(keepalive) = keepalive else {
245+
return Ok(());
246+
};
247+
248+
socket2::SockRef::from(stream).set_tcp_keepalive(&keepalive.to_socket2())?;
249+
250+
Ok(())
251+
}
252+
205253
/// Open a TCP socket to `host` and `port`.
206254
///
207255
/// If `host` is a hostname, attempt to connect to each address it resolves to.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
use std::time::Duration;
2+
3+
/// TCP keepalive parameters for a connection's socket.
4+
///
5+
/// Keepalive is what lets a client notice that its server is gone when the
6+
/// server disappeared without closing the socket: a failover, a killed
7+
/// container, a NAT table that dropped the mapping. Without it, a connection
8+
/// blocked reading a response it will never receive waits forever, because
9+
/// there is nothing left to retransmit and so nothing to time out.
10+
///
11+
/// The parameters mirror libpq's `keepalives_idle`, `keepalives_interval` and
12+
/// `keepalives_count`, and are applied with `setsockopt` after connecting.
13+
///
14+
/// Support varies by platform: `interval` is ignored on OpenBSD and Solaris,
15+
/// and `retries` is ignored on OpenBSD, Solaris, watchOS and tvOS. `idle` is
16+
/// supported everywhere keepalive itself is.
17+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18+
pub struct TcpKeepalive {
19+
/// Idle time after which the first keepalive probe is sent.
20+
pub idle: Option<Duration>,
21+
/// Time between probes once the first one has been sent.
22+
pub interval: Option<Duration>,
23+
/// Number of unacknowledged probes before the connection is dropped.
24+
pub retries: Option<u32>,
25+
}
26+
27+
impl TcpKeepalive {
28+
/// Keepalive with no parameters overridden, leaving the system defaults in
29+
/// place. On Linux those are 7200s idle, 75s interval, 9 retries.
30+
pub fn new() -> Self {
31+
Self::default()
32+
}
33+
34+
/// Sets the idle time after which the first keepalive probe is sent.
35+
pub fn with_idle(mut self, idle: Duration) -> Self {
36+
self.idle = Some(idle);
37+
self
38+
}
39+
40+
/// Sets the time between keepalive probes.
41+
pub fn with_interval(mut self, interval: Duration) -> Self {
42+
self.interval = Some(interval);
43+
self
44+
}
45+
46+
/// Sets the number of unacknowledged probes before the connection is dropped.
47+
pub fn with_retries(mut self, retries: u32) -> Self {
48+
self.retries = Some(retries);
49+
self
50+
}
51+
52+
#[cfg(any(feature = "_rt-tokio", feature = "_rt-async-io"))]
53+
pub(crate) fn to_socket2(self) -> socket2::TcpKeepalive {
54+
let mut keepalive = socket2::TcpKeepalive::new();
55+
56+
if let Some(idle) = self.idle {
57+
keepalive = keepalive.with_time(idle);
58+
}
59+
60+
#[cfg(not(any(target_os = "openbsd", target_os = "solaris")))]
61+
if let Some(interval) = self.interval {
62+
keepalive = keepalive.with_interval(interval);
63+
}
64+
65+
#[cfg(not(any(
66+
target_os = "openbsd",
67+
target_os = "solaris",
68+
target_os = "watchos",
69+
target_os = "tvos",
70+
)))]
71+
if let Some(retries) = self.retries {
72+
keepalive = keepalive.with_retries(retries);
73+
}
74+
75+
keepalive
76+
}
77+
}
78+
79+
#[cfg(test)]
80+
mod tests {
81+
use super::*;
82+
83+
#[test]
84+
fn keepalive_is_disabled_by_default() {
85+
assert_eq!(TcpKeepalive::new(), TcpKeepalive::default());
86+
assert!(TcpKeepalive::new().idle.is_none());
87+
}
88+
89+
#[test]
90+
fn builders_set_each_parameter() {
91+
let keepalive = TcpKeepalive::new()
92+
.with_idle(Duration::from_secs(30))
93+
.with_interval(Duration::from_secs(10))
94+
.with_retries(3);
95+
96+
assert_eq!(keepalive.idle, Some(Duration::from_secs(30)));
97+
assert_eq!(keepalive.interval, Some(Duration::from_secs(10)));
98+
assert_eq!(keepalive.retries, Some(3));
99+
}
100+
}

sqlx-mysql/src/connection/establish.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ impl MySqlConnection {
1717

1818
let handshake = match &options.socket {
1919
Some(path) => crate::net::connect_uds(path, do_handshake).await?,
20-
None => crate::net::connect_tcp(&options.host, options.port, do_handshake).await?,
20+
None => {
21+
crate::net::connect_tcp_with_keepalive(
22+
&options.host,
23+
options.port,
24+
do_handshake,
25+
options.tcp_keepalive.as_ref(),
26+
)
27+
.await?
28+
}
2129
};
2230

2331
let stream = handshake?;

sqlx-mysql/src/options/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod connect;
44
mod parse;
55
mod ssl_mode;
66

7+
use crate::net::TcpKeepalive;
78
use crate::{connection::LogSettings, net::tls::CertificateInput};
89
pub use ssl_mode::MySqlSslMode;
910

@@ -80,6 +81,7 @@ pub struct MySqlConnectOptions {
8081
pub(crate) no_engine_substitution: bool,
8182
pub(crate) timezone: Option<String>,
8283
pub(crate) set_names: bool,
84+
pub(crate) tcp_keepalive: Option<TcpKeepalive>,
8385
}
8486

8587
impl Default for MySqlConnectOptions {
@@ -105,6 +107,7 @@ impl MySqlConnectOptions {
105107
ssl_client_cert: None,
106108
ssl_client_key: None,
107109
statement_cache_capacity: 100,
110+
tcp_keepalive: None,
108111
log_settings: Default::default(),
109112
pipes_as_concat: true,
110113
enable_cleartext_plugin: false,
@@ -286,6 +289,31 @@ impl MySqlConnectOptions {
286289
self
287290
}
288291

292+
/// Configure TCP keepalive on the connection's socket.
293+
///
294+
/// Disabled by default, matching the socket default. Enable it when connections
295+
/// are long-lived and the server may disappear without closing the socket (a
296+
/// failover, a killed container, a dropped NAT mapping): without keepalive, a
297+
/// connection blocked reading a response that will never arrive waits forever.
298+
///
299+
/// # Example
300+
///
301+
/// ```rust
302+
/// # use std::time::Duration;
303+
/// # use sqlx_core::net::TcpKeepalive;
304+
/// # use sqlx_mysql::MySqlConnectOptions;
305+
/// let options = MySqlConnectOptions::new().tcp_keepalive(
306+
/// TcpKeepalive::new()
307+
/// .with_idle(Duration::from_secs(30))
308+
/// .with_interval(Duration::from_secs(10))
309+
/// .with_retries(3),
310+
/// );
311+
/// ```
312+
pub fn tcp_keepalive(mut self, keepalive: TcpKeepalive) -> Self {
313+
self.tcp_keepalive = Some(keepalive);
314+
self
315+
}
316+
289317
/// Sets the capacity of the connection's statement cache in a number of stored
290318
/// distinct statements. Caching is handled using LRU, meaning when the
291319
/// amount of queries hits the defined limit, the oldest statement will get

sqlx-postgres/src/connection/stream.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@ impl PgStream {
4444
pub(super) async fn connect(options: &PgConnectOptions) -> Result<Self, Error> {
4545
let socket_result = match options.fetch_socket() {
4646
Some(ref path) => net::connect_uds(path, MaybeUpgradeTls(options)).await?,
47-
None => net::connect_tcp(&options.host, options.port, MaybeUpgradeTls(options)).await?,
47+
None => {
48+
net::connect_tcp_with_keepalive(
49+
&options.host,
50+
options.port,
51+
MaybeUpgradeTls(options),
52+
options.tcp_keepalive.as_ref(),
53+
)
54+
.await?
55+
}
4856
};
4957

5058
let socket = socket_result?;

sqlx-postgres/src/options/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
55

66
pub use ssl_mode::PgSslMode;
77

8+
use crate::net::TcpKeepalive;
89
use crate::{connection::LogSettings, net::tls::CertificateInput};
910

1011
mod connect;
@@ -30,6 +31,7 @@ pub struct PgConnectOptions {
3031
pub(crate) log_settings: LogSettings,
3132
pub(crate) extra_float_digits: Option<Cow<'static, str>>,
3233
pub(crate) options: Option<String>,
34+
pub(crate) tcp_keepalive: Option<TcpKeepalive>,
3335
}
3436

3537
impl Default for PgConnectOptions {
@@ -95,6 +97,7 @@ impl PgConnectOptions {
9597
statement_cache_capacity: 100,
9698
application_name: var("PGAPPNAME").ok(),
9799
extra_float_digits: Some("2".into()),
100+
tcp_keepalive: None,
98101
log_settings: Default::default(),
99102
options: var("PGOPTIONS").ok(),
100103
}
@@ -416,6 +419,34 @@ impl PgConnectOptions {
416419
/// // don't send the option at all (Postgres 9 and older)
417420
/// .extra_float_digits(None);
418421
/// ```
422+
/// Configure TCP keepalive on the connection's socket.
423+
///
424+
/// Disabled by default, matching the socket default. Enable it when connections
425+
/// are long-lived and the server may disappear without closing the socket (a
426+
/// failover, a killed container, a dropped NAT mapping): without keepalive, a
427+
/// connection blocked reading a response that will never arrive waits forever.
428+
///
429+
/// The equivalent libpq parameters (`keepalives`, `keepalives_idle`,
430+
/// `keepalives_interval`, `keepalives_count`) are also accepted in the
431+
/// connection URL.
432+
///
433+
/// # Example
434+
///
435+
/// ```rust
436+
/// # use std::time::Duration;
437+
/// # /// # use sqlx_postgres::PgConnectOptions;
438+
/// let options = PgConnectOptions::new().tcp_keepalive(
439+
/// TcpKeepalive::new()
440+
/// .with_idle(Duration::from_secs(30))
441+
/// .with_interval(Duration::from_secs(10))
442+
/// .with_retries(3),
443+
/// );
444+
/// ```
445+
pub fn tcp_keepalive(mut self, keepalive: TcpKeepalive) -> Self {
446+
self.tcp_keepalive = Some(keepalive);
447+
self
448+
}
449+
419450
pub fn extra_float_digits(mut self, extra_float_digits: impl Into<Option<i8>>) -> Self {
420451
self.extra_float_digits = extra_float_digits.into().map(|it| it.to_string().into());
421452
self

0 commit comments

Comments
 (0)