SQL pools time out; task locks are a renewed five-minute lease
A 3-node rehearsal (PostgreSQL + NATS + Garage) found two ways a crash leaves work stuck: Pool hangs. The PostgreSQL pool (deadpool) was built with no timeouts, so a request waited for a free connection, and for one to be opened or recycled, for as long as it took: forever when the server stopped answering. MySQL's pool (mysql_async) has no wait timeout at all. - PostgreSQL: wait 30 s (or the store's timeout if longer), create the store's timeout or 15 s (it bounds the whole handshake, where tokio-postgres's connect_timeout covers only the TCP connect), recycle 10 s. The pool config is now always set, not only with poolMaxConnections. - MySQL: every connection is taken through MysqlStore::conn(), which gives up after 30 s. - Both: TCP keepalive after 60 s idle, so a server that vanished without closing the connection is noticed in minutes rather than the two-hour system default. The DataStore schema has no pool timeout settings, so these are fixed defaults; the store's own timeout bounds connecting on PostgreSQL. Task locks. A task lock lasted an hour, so after a hard crash the dead node's tasks waited up to an hour and five minutes. The lock is now a five-minute lease: while this node runs a task, the task manager renews its lock every third of the lifetime (InMemoryStore::renew_lock, a compare-and-set on the store backends and SET XX EX on Redis, which leaves a lock that already expired alone). A killed node's tasks run elsewhere within about five minutes plus the claim recheck. A task this node holds isn't handed to a worker again by the scan. store::pool_timeout (new): a local listener that accepts connections and never answers plays a hung server; a PostgreSQL store with a 2 s timeout returns an error in about 4 s, and a MySQL store in 30 s. Without the timeouts both wait for good. store::task_locks gains a task held for 1.5 lock lifetimes: its lease is still held, and released when the task ends.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! A database that accepts connections and then says nothing (a hung or
|
||||
//! half-dead server, a black-holed failover) gives a worker an error within
|
||||
//! the pool's timeouts. Upstream's pools had none, so the worker waited for
|
||||
//! good. No database is needed: a local listener that never answers plays
|
||||
//! the server.
|
||||
|
||||
use registry::schema::structs::DataStore;
|
||||
use std::time::{Duration, Instant};
|
||||
use store::{Store, ValueKey, write::ValueClass};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Accepts connections on a local port and never sends a byte.
|
||||
async fn silent_server() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((socket, _)) = listener.accept().await {
|
||||
held.push(socket);
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
/// Builds the store and reads a key; both must end, with an error for the
|
||||
/// read, well within `limit`.
|
||||
async fn assert_times_out(data_store: DataStore, limit: Duration) {
|
||||
let started = Instant::now();
|
||||
let result = tokio::time::timeout(limit, async {
|
||||
match Store::build(data_store).await {
|
||||
Ok(store) => store
|
||||
.get_value::<u64>(ValueKey::from(ValueClass::Property(0)))
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| err.to_string()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
match result {
|
||||
Ok(Err(err)) => println!("Got {err} after {elapsed:?}"),
|
||||
Ok(Ok(())) => panic!("a silent server answered?"),
|
||||
Err(_) => panic!("still waiting for a connection after {elapsed:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn postgres_pool_timeout() {
|
||||
use registry::schema::structs::PostgreSqlStore;
|
||||
|
||||
let port = silent_server().await;
|
||||
println!("Running PostgreSQL pool timeout test...");
|
||||
// The store's own timeout bounds opening a connection, handshake
|
||||
// included (tokio-postgres's connect_timeout covers only the TCP connect)
|
||||
assert_times_out(
|
||||
DataStore::PostgreSql(PostgreSqlStore {
|
||||
host: "127.0.0.1".into(),
|
||||
port: port as u64,
|
||||
database: "none".into(),
|
||||
timeout: Some(Duration::from_secs(2).into()),
|
||||
use_tls: false,
|
||||
..Default::default()
|
||||
}),
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "mysql")]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn mysql_pool_timeout() {
|
||||
use registry::schema::structs::MySqlStore;
|
||||
|
||||
let port = silent_server().await;
|
||||
println!("Running MySQL pool timeout test...");
|
||||
// mysql_async has no pool timeout; the store waits 30 s for a connection
|
||||
assert_times_out(
|
||||
DataStore::MySql(MySqlStore {
|
||||
host: "127.0.0.1".into(),
|
||||
port: port as u64,
|
||||
database: "none".into(),
|
||||
use_tls: false,
|
||||
..Default::default()
|
||||
}),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user