Skip to content

Commit ce6366a

Browse files
committed
fix(operator): TCP keepalives + per-query timeout on operator-side libpq
The operator's tokio-postgres clients run over raw TCP sockets opened with no keepalives, so a NAT-evicted or silently-dead connection (e.g. between reconciles, between queries on the same client, or while a long-running DROP SCHEMA CASCADE is in flight) wedges the reconcile indefinitely. Other replicas keep reconciling, but the stuck one never makes progress. Mirror the libpq URI keepalives we already use in the migration Job (60s idle / 10s interval / 3 retries — detects dead sockets within ~90s) by setting them on the raw TcpStream via socket2 before handing the stream to tokio-postgres::connect_raw. As a backstop for the case where keepalives still ACK but the query itself stalls, wrap each query/execute call in a 5-minute tokio::time::timeout. The timeout is generous on purpose — intent is runaway detection, not a query SLA — but bounded enough that the reconcile fails and the controller retries instead of sitting forever. Port-forwarded connections (used out-of-cluster, e.g. CI / dev) go through the kube API server WebSocket and keep their own keepalive handling there; no socket-level change is meaningful for that path.
1 parent dde780b commit ce6366a

3 files changed

Lines changed: 108 additions & 21 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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ schemars = { version = "1.2.1", features = ["jiff02"] }
2525
serde = { version = "1.0.228", features = ["derive"] }
2626
serde_json = "1.0.150"
2727
serde_yaml = "0.9.34"
28+
socket2 = "0.6.2"
2829
thiserror = "2.0.18"
2930
tokio = { version = "1.50.0", features = ["full"] }
3031
tokio-postgres = "0.7"

src/controllers/postgres.rs

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,104 @@
1-
use std::collections::HashSet;
1+
use std::{collections::HashSet, time::Duration};
22

33
use k8s_openapi::api::core::v1::{Pod, Secret};
44
use kube::{
55
Api, Client, ResourceExt,
66
api::{ListParams, Portforwarder},
77
};
8+
use socket2::{SockRef, TcpKeepalive};
89
use tokio::net::TcpStream;
9-
use tokio_postgres::NoTls;
10+
use tokio_postgres::{NoTls, Row, ToStatement, types::ToSql};
1011
use tracing::{debug, info, warn};
1112

1213
use crate::error::{Error, Result};
1314

1415
pub const DEFAULT_PG_VERSION: i32 = 18;
1516

17+
/// Per-query timeout. Backstop for hung connections that survive TCP
18+
/// keepalives (e.g. a server that ACKs keepalive probes but the query
19+
/// itself wedges). Generous because `DROP SCHEMA ... CASCADE` on a
20+
/// populated dbt schema can take minutes; intent is runaway detection,
21+
/// not a query SLA.
22+
const QUERY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
23+
24+
/// TCP keepalive parameters for operator-side libpq sockets. Matches the
25+
/// settings the migration Job uses in its libpq URI (see [[fix(migration)
26+
/// TCP keepalives]]). Detects NAT-evicted / silently-dead sockets within
27+
/// ~90s so reconciles can fail and retry instead of hanging forever.
28+
const KEEPALIVE_IDLE: Duration = Duration::from_secs(60);
29+
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(10);
30+
const KEEPALIVE_RETRIES: u32 = 3;
31+
32+
fn set_tcp_keepalive(stream: &TcpStream) -> Result<()> {
33+
let ka = TcpKeepalive::new()
34+
.with_time(KEEPALIVE_IDLE)
35+
.with_interval(KEEPALIVE_INTERVAL)
36+
.with_retries(KEEPALIVE_RETRIES);
37+
SockRef::from(stream)
38+
.set_tcp_keepalive(&ka)
39+
.map_err(|e| Error::MissingField(format!("failed to set TCP keepalive: {e}")))?;
40+
Ok(())
41+
}
42+
43+
/// Run a parameterised query with the global [`QUERY_TIMEOUT`]. Returns a
44+
/// timeout-shaped error if the query doesn't complete in time, so the
45+
/// reconcile fails fast instead of hanging on a wedged connection.
46+
pub async fn query_timeout<T>(
47+
pg: &tokio_postgres::Client,
48+
stmt: &T,
49+
params: &[&(dyn ToSql + Sync)],
50+
) -> Result<Vec<Row>>
51+
where
52+
T: ?Sized + ToStatement,
53+
{
54+
tokio::time::timeout(QUERY_TIMEOUT, pg.query(stmt, params))
55+
.await
56+
.map_err(|_| Error::MissingField(format!("query timed out after {QUERY_TIMEOUT:?}")))?
57+
.map_err(Into::into)
58+
}
59+
60+
pub async fn query_one_timeout<T>(
61+
pg: &tokio_postgres::Client,
62+
stmt: &T,
63+
params: &[&(dyn ToSql + Sync)],
64+
) -> Result<Row>
65+
where
66+
T: ?Sized + ToStatement,
67+
{
68+
tokio::time::timeout(QUERY_TIMEOUT, pg.query_one(stmt, params))
69+
.await
70+
.map_err(|_| Error::MissingField(format!("query timed out after {QUERY_TIMEOUT:?}")))?
71+
.map_err(Into::into)
72+
}
73+
74+
pub async fn query_opt_timeout<T>(
75+
pg: &tokio_postgres::Client,
76+
stmt: &T,
77+
params: &[&(dyn ToSql + Sync)],
78+
) -> Result<Option<Row>>
79+
where
80+
T: ?Sized + ToStatement,
81+
{
82+
tokio::time::timeout(QUERY_TIMEOUT, pg.query_opt(stmt, params))
83+
.await
84+
.map_err(|_| Error::MissingField(format!("query timed out after {QUERY_TIMEOUT:?}")))?
85+
.map_err(Into::into)
86+
}
87+
88+
pub async fn execute_timeout<T>(
89+
pg: &tokio_postgres::Client,
90+
stmt: &T,
91+
params: &[&(dyn ToSql + Sync)],
92+
) -> Result<u64>
93+
where
94+
T: ?Sized + ToStatement,
95+
{
96+
tokio::time::timeout(QUERY_TIMEOUT, pg.execute(stmt, params))
97+
.await
98+
.map_err(|_| Error::MissingField(format!("execute timed out after {QUERY_TIMEOUT:?}")))?
99+
.map_err(Into::into)
100+
}
101+
16102
/// Holds a Postgres client and any resources that must stay alive for the
17103
/// duration of the connection (e.g. a port-forwarder).
18104
pub struct PgConnection {
@@ -67,6 +153,7 @@ async fn connect_via_tcp(
67153
let stream = TcpStream::connect(&addr)
68154
.await
69155
.map_err(|e| Error::MissingField(format!("failed to connect to {addr}: {e}")))?;
156+
set_tcp_keepalive(&stream)?;
70157

71158
let mut config = tokio_postgres::Config::new();
72159
config.user(user);
@@ -195,15 +282,15 @@ pub async fn discover_restore_database(
195282
.await?;
196283
let pg = &conn.client;
197284

198-
let row = pg
199-
.query_opt(
200-
"SELECT datname FROM pg_database \
201-
WHERE datname NOT IN ('postgres', 'template0', 'template1') \
202-
ORDER BY pg_database_size(datname) DESC \
203-
LIMIT 1",
204-
&[],
205-
)
206-
.await?;
285+
let row = query_opt_timeout(
286+
pg,
287+
"SELECT datname FROM pg_database \
288+
WHERE datname NOT IN ('postgres', 'template0', 'template1') \
289+
ORDER BY pg_database_size(datname) DESC \
290+
LIMIT 1",
291+
&[],
292+
)
293+
.await?;
207294

208295
match row {
209296
Some(r) => {
@@ -227,9 +314,7 @@ pub async fn discover_restore_database(
227314
/// you're going to make multiple queries on the same database so the
228315
/// connection can be reused.
229316
pub async fn database_size_on(pg: &tokio_postgres::Client) -> Result<u64> {
230-
let row = pg
231-
.query_one("SELECT pg_database_size(current_database())", &[])
232-
.await?;
317+
let row = query_one_timeout(pg, "SELECT pg_database_size(current_database())", &[]).await?;
233318
let size: i64 = row.get(0);
234319
Ok(size as u64)
235320
}
@@ -271,12 +356,12 @@ pub async fn existing_schemas_on(
271356
if candidates.is_empty() {
272357
return Ok(Vec::new());
273358
}
274-
let rows = pg
275-
.query(
276-
"SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname = ANY($1)",
277-
&[&candidates],
278-
)
279-
.await?;
359+
let rows = query_timeout(
360+
pg,
361+
"SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname = ANY($1)",
362+
&[&candidates],
363+
)
364+
.await?;
280365
let found: HashSet<String> = rows.iter().map(|r| r.get::<_, String>(0)).collect();
281366
Ok(candidates
282367
.iter()
@@ -291,7 +376,7 @@ pub async fn drop_schemas_on(pg: &tokio_postgres::Client, schemas: &[String]) ->
291376
for schema in schemas {
292377
let stmt = format!("DROP SCHEMA IF EXISTS {} CASCADE", quote_ident(schema));
293378
debug!(schema = schema, "dropping schema");
294-
pg.execute(stmt.as_str(), &[]).await?;
379+
execute_timeout(pg, stmt.as_str(), &[]).await?;
295380
}
296381
Ok(())
297382
}

0 commit comments

Comments
 (0)