PostgreSQL (crates/store/src/backend/postgres/main.rs:49-50): the deadpool pool was built with PoolConfig::new and no timeouts. Without poolMaxConnections it had no pool config at all. A request waited as long as it took for a free slot, a new connection or a recycled one. Against a server that stopped answering, that is forever. Now:
Timeout
Value
Notes
create
the store's timeout, else 15 s
Covers the whole handshake. tokio-postgres's connect_timeout only covers the TCP connect, so a server that accepts and then says nothing hung here.
wait
30 s, or create if that is longer
recycle
10 s
The pool config is now always set. Read replicas inherit it.
MySQL: mysql_async's pool has no wait timeout at all. Every connection now goes through MysqlStore::conn(), which gives up after 30 s (16 call sites).
Both: TCP keepalive starts after 60 s idle. A server that vanished without closing the connection is noticed in minutes, not after the two-hour system default. That matters for a query already sent on a live connection, which no pool timeout covers.
The DataStore schema has no pool-timeout settings. These are fixed defaults, apart from the store's existing timeout. Making them configurable needs a schema change; say if you want that.
(b) Task locks as a renewed lease
A task lock lasted 1 h (TaskLocks::DEFAULT_EXPIRY). After a hard crash, the dead node's tasks waited up to an hour plus the 5-minute recheck. The change is contained, so I made it:
Lease length. The lock is now a 5-minute lease.
Renewal. While a node runs a task, a heartbeat in the task manager renews its lease every third of the lifetime. It ticks every second, so a changed lifetime takes effect at once.
InMemoryStore::renew_lock. On the store backends it's a compare-and-set that only extends a lock that is still live. On Redis it's SET … XX EX. Sharded stores route it to the lock's member. A lease that has already expired is left alone (another node may have it), and the event is logged.
Scan. The claim scan no longer hands a task this node holds to a worker again.
Crash recovery. A killed node's tasks run elsewhere within about 5 minutes plus the claim recheck (25 s), instead of an hour.
Graceful stop. Unchanged from #35: locks are handed back at once.
Trade-off: a task only keeps its claim while its node keeps renewing. If a node's runtime stalls for more than 5 minutes, it can lose a claim while the task is still running. Before, a task that ran longer than an hour lost its claim the same way.
Tests
store::pool_timeout (new; --features postgres,mysql, no database needed). A local listener that accepts and never answers plays a hung server:
PostgreSQL store with a 2 s timeout: error ("Timeout occurred while creating a new object") after 4 s.
MySQL store: error after 30 s.
On main, PostgreSQL was still waiting at the test's 20 s limit, and MySQL at its 60 s limit.
store::task_locks::task_lock_tests, new step 3: a task held for 1.5 lock lifetimes keeps its lease, and its lock is free the moment the task ends.
RocksDb and PostgreSql: pass.
On main: fails ("lease lapsed while the task ran").
store::store_tests, which exercises every backend path through the new connection helpers:
PostgreSql: pass.
MySql: registry, import/export and ops all passed. The run then stopped in the statement-timeout step, which needs a MariaDB container on port 3308 that isn't available here.
system::system_tests (RocksDb): pass.
After rebasing onto #40:task_lock_tests, postgres_pool_timeout and cluster::task_roles::task_role_tests all pass on PostgreSql.
The Redis renew_lock path compiles (--features redis), but I didn't run it: there's no Redis here.
## (a) Pool timeouts
**PostgreSQL** (`crates/store/src/backend/postgres/main.rs:49-50`): the deadpool pool was built with `PoolConfig::new` and no timeouts. Without `poolMaxConnections` it had no pool config at all. A request waited as long as it took for a free slot, a new connection or a recycled one. Against a server that stopped answering, that is forever. Now:
| Timeout | Value | Notes |
|---|---|---|
| create | the store's `timeout`, else 15 s | Covers the whole handshake. tokio-postgres's `connect_timeout` only covers the TCP connect, so a server that accepts and then says nothing hung here. |
| wait | 30 s, or `create` if that is longer | |
| recycle | 10 s | |
The pool config is now always set. Read replicas inherit it.
**MySQL**: mysql_async's pool has no wait timeout at all. Every connection now goes through `MysqlStore::conn()`, which gives up after 30 s (16 call sites).
**Both**: TCP keepalive starts after 60 s idle. A server that vanished without closing the connection is noticed in minutes, not after the two-hour system default. That matters for a query already sent on a live connection, which no pool timeout covers.
The DataStore schema has no pool-timeout settings. These are fixed defaults, apart from the store's existing `timeout`. Making them configurable needs a schema change; say if you want that.
## (b) Task locks as a renewed lease
A task lock lasted 1 h (`TaskLocks::DEFAULT_EXPIRY`). After a hard crash, the dead node's tasks waited up to an hour plus the 5-minute recheck. The change is contained, so I made it:
- **Lease length.** The lock is now a **5-minute lease**.
- **Renewal.** While a node runs a task, a heartbeat in the task manager renews its lease every third of the lifetime. It ticks every second, so a changed lifetime takes effect at once.
- **`InMemoryStore::renew_lock`.** On the store backends it's a compare-and-set that only extends a lock that is still live. On Redis it's `SET … XX EX`. Sharded stores route it to the lock's member. A lease that has already expired is left alone (another node may have it), and the event is logged.
- **Scan.** The claim scan no longer hands a task this node holds to a worker again.
- **Crash recovery.** A killed node's tasks run elsewhere within about 5 minutes plus the claim recheck (25 s), instead of an hour.
- **Graceful stop.** Unchanged from #35: locks are handed back at once.
Trade-off: a task only keeps its claim while its node keeps renewing. If a node's runtime stalls for more than 5 minutes, it can lose a claim while the task is still running. Before, a task that ran longer than an hour lost its claim the same way.
## Tests
- **`store::pool_timeout`** (new; `--features postgres,mysql`, no database needed). A local listener that accepts and never answers plays a hung server:
- PostgreSQL store with a 2 s timeout: error ("Timeout occurred while creating a new object") after 4 s.
- MySQL store: error after 30 s.
- On `main`, PostgreSQL was still waiting at the test's 20 s limit, and MySQL at its 60 s limit.
- **`store::task_locks::task_lock_tests`**, new step 3: a task held for 1.5 lock lifetimes keeps its lease, and its lock is free the moment the task ends.
- RocksDb and PostgreSql: pass.
- On `main`: fails ("lease lapsed while the task ran").
- **`store::store_tests`**, which exercises every backend path through the new connection helpers:
- PostgreSql: pass.
- MySql: registry, import/export and ops all passed. The run then stopped in the statement-timeout step, which needs a MariaDB container on port 3308 that isn't available here.
- **`system::system_tests`** (RocksDb): pass.
- **After rebasing onto #40:** `task_lock_tests`, `postgres_pool_timeout` and `cluster::task_roles::task_role_tests` all pass on PostgreSql.
- The Redis `renew_lock` path compiles (`--features redis`), but I didn't run it: there's no Redis here.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
(a) Pool timeouts
PostgreSQL (
crates/store/src/backend/postgres/main.rs:49-50): the deadpool pool was built withPoolConfig::newand no timeouts. WithoutpoolMaxConnectionsit had no pool config at all. A request waited as long as it took for a free slot, a new connection or a recycled one. Against a server that stopped answering, that is forever. Now:timeout, else 15 sconnect_timeoutonly covers the TCP connect, so a server that accepts and then says nothing hung here.createif that is longerThe pool config is now always set. Read replicas inherit it.
MySQL: mysql_async's pool has no wait timeout at all. Every connection now goes through
MysqlStore::conn(), which gives up after 30 s (16 call sites).Both: TCP keepalive starts after 60 s idle. A server that vanished without closing the connection is noticed in minutes, not after the two-hour system default. That matters for a query already sent on a live connection, which no pool timeout covers.
The DataStore schema has no pool-timeout settings. These are fixed defaults, apart from the store's existing
timeout. Making them configurable needs a schema change; say if you want that.(b) Task locks as a renewed lease
A task lock lasted 1 h (
TaskLocks::DEFAULT_EXPIRY). After a hard crash, the dead node's tasks waited up to an hour plus the 5-minute recheck. The change is contained, so I made it:InMemoryStore::renew_lock. On the store backends it's a compare-and-set that only extends a lock that is still live. On Redis it'sSET … XX EX. Sharded stores route it to the lock's member. A lease that has already expired is left alone (another node may have it), and the event is logged.Trade-off: a task only keeps its claim while its node keeps renewing. If a node's runtime stalls for more than 5 minutes, it can lose a claim while the task is still running. Before, a task that ran longer than an hour lost its claim the same way.
Tests
store::pool_timeout(new;--features postgres,mysql, no database needed). A local listener that accepts and never answers plays a hung server:main, PostgreSQL was still waiting at the test's 20 s limit, and MySQL at its 60 s limit.store::task_locks::task_lock_tests, new step 3: a task held for 1.5 lock lifetimes keeps its lease, and its lock is free the moment the task ends.main: fails ("lease lapsed while the task ran").store::store_tests, which exercises every backend path through the new connection helpers:system::system_tests(RocksDb): pass.task_lock_tests,postgres_pool_timeoutandcluster::task_roles::task_role_testsall pass on PostgreSql.renew_lockpath compiles (--features redis), but I didn't run it: there's no Redis here.