Skip to content

Commit 00bf8d5

Browse files
authored
fix(operator): TCP keepalives + per-query timeout on operator-side libpq (#55)
2 parents dde780b + ce6366a commit 00bf8d5

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)