Expected Behavior
Either the SQL session refresh recovers from every error state it can itself create, or a persistently failing membership heartbeat escalates (recreates its connection, or terminates the process so a supervisor can restart it). A cluster that cannot dispatch a single task should not report healthy.
Actual Behavior
On a self-hosted 4-role cluster (v1.31.2, Postgres via GCP Cloud SQL, one replica per role), the ringpop membership heartbeat began failing permanently:
UpsertClusterMembership operation failed. Error: sql: database is closed
ringpop.(*monitor).startHeartbeatUpsertLoop → monitor.go:366
cluster_membership rows aged past the 20s liveness cutoff, matching/history/worker dropped out of the ring (tdbg membership list-gossip: frontend: 1, everything else 0), and the frontend answered every PollWorkflowTaskQueue / PollActivityTaskQueue with Unavailable: Not enough hosts to serve the request — for 29 hours, until a manual rolling restart. Throughout:
- all pods
1/1 Running, 0 restarts (the processes never crash);
temporal operator cluster health reported SERVING the whole time — the gRPC health status is set once at startup (workflow_handler.go#L394-L401) and never re-evaluated against ring contents;
- no workflow failed, because none could start.
The database instance was healthy the entire time (no failover, no maintenance, concurrent backups completing; confirmed from the Cloud SQL operations log). The pool was closed from inside the Temporal process.
Root cause analysis (v1.31.2 source)
Two defects compound:
1. DatabaseHandle can close the live pool and never replace it
The session-refresh machinery introduced in #5926 (db_handle.go) is the only thing that closes the shared *sql.DB at runtime, and it has two problems:
- Destroy-before-throttle.
reconnect(force=true) does h.db.Store(nil) and go prevConn.Close() before the 1-second throttle check; if throttled, it returns leaving no pool at all. And since ConvertError cannot tell which pool an error came from, a late-arriving error from an already-replaced pool destroys the freshly created healthy one. Under a burst of connection errors this churns: concurrent operations race h.db.Load() against go prevConn.Close() and observe Go's sql: database is closed.
sql: database is closed is not a refresh trigger. ConvertError only reconnects on needsRefresh(err) (for Postgres: a *pgconn.PgError with a connection-failure SQLSTATE — driver/pgx.go#L80-L86), driver.ErrBadConn, io.EOF/ErrUnexpectedEOF, or ECONNRESET/ABORTED/REFUSED. Go's errDBClosed matches none of these. So the one error state the refresher itself creates is one it can never heal: the raw error is wrapped into serviceerror.Unavailable and retried forever against the same closed pool.
2. The membership heartbeat never escalates
startHeartbeatUpsertLoop (monitor.go#L358-L377) logs the error and sleeps 10–15s, unconditionally, forever — no consecutive-failure counter, no connection recreation, no fatal. Only startup failures are fatal (Start(), lines 142–151). Combined with (1), the process runs indefinitely with a permanently dead heartbeat. Recovery is further blocked because bootstrapRingPop only trusts rows with LastHeartbeatWithin: 20s (healthyHostLastHeartbeatCutoff), so once all pods' heartbeats fail, the discovery table is effectively empty.
Our incident logs are consistent with the handle spending most of the outage nil (no usable database connection found) with periodic races onto just-closed pools (54 × sql: database is closed over 29h in the history service alone).
Proposed fix
Any one of these breaks the failure chain; (1) + (3) together would both heal the pool and remove the zombie mode:
- Treat
sql: database is closed as refresh-triggering. In needsRefresh / ConvertError, classify the closed-pool error (it is not driver.ErrBadConn and not matchable via errors.Is — match Go's errDBClosed string, or probe with db.Ping on unclassified errors) so the handle heals the state it created itself.
- Check the throttle before destroying the pool. In
reconnect(force=true), evaluate the 1-second throttle first, and only then Store(nil) + close; additionally tag errors with a pool generation so a stale pool's error cannot destroy its healthy replacement.
- Escalate a persistently failing membership heartbeat. In
startHeartbeatUpsertLoop, count consecutive failures and logger.Fatal after N (e.g. 12 ≈ 2–3 minutes), matching the loop's own startup behavior — supervised deployments then self-heal instead of running as zombies.
- Reflect ring membership in the standard gRPC health status. The ring-aware
AdminService.DeepHealthCheck exists but nothing calls it by default; if the frontend's health status considered ring contents, operator cluster health and k8s probes could see this class of failure.
Prior art
Steps to Reproduce the Problem
Hard to trigger deterministically because it requires losing the h.db.Load() vs go prevConn.Close() race, but the ingredients are:
- Run any server role against Postgres (pgx driver) with default config.
- Inject a burst of connection-level errors (e.g. kill backend connections repeatedly / brief network partition to the DB) so multiple concurrent operations trip
ConvertError → reconnect(force=true) in close succession.
- Observe operations begin failing with
sql: database is closed or no usable database connection found; once the ringpop heartbeat is among them for >20s, membership empties and the frontend serves Not enough hosts to serve the request while operator cluster health still reports SERVING. The state persists until process restart.
Specifications
- Version: 1.31.2 (also inspected
main; relevant files identical)
- Persistence: PostgreSQL (Cloud SQL, private IP, TLS), pgx driver,
maxConns: 20, maxIdleConns: 20, maxConnLifetime: 1h
- Platform: GKE, one pod per role (frontend/history/matching/worker), official Helm chart 1.6.0
Expected Behavior
Either the SQL session refresh recovers from every error state it can itself create, or a persistently failing membership heartbeat escalates (recreates its connection, or terminates the process so a supervisor can restart it). A cluster that cannot dispatch a single task should not report healthy.
Actual Behavior
On a self-hosted 4-role cluster (v1.31.2, Postgres via GCP Cloud SQL, one replica per role), the ringpop membership heartbeat began failing permanently:
cluster_membershiprows aged past the 20s liveness cutoff, matching/history/worker dropped out of the ring (tdbg membership list-gossip:frontend: 1, everything else0), and the frontend answered everyPollWorkflowTaskQueue/PollActivityTaskQueuewithUnavailable: Not enough hosts to serve the request— for 29 hours, until a manual rolling restart. Throughout:1/1 Running, 0 restarts (the processes never crash);temporal operator cluster healthreported SERVING the whole time — the gRPC health status is set once at startup (workflow_handler.go#L394-L401) and never re-evaluated against ring contents;The database instance was healthy the entire time (no failover, no maintenance, concurrent backups completing; confirmed from the Cloud SQL operations log). The pool was closed from inside the Temporal process.
Root cause analysis (v1.31.2 source)
Two defects compound:
1.
DatabaseHandlecan close the live pool and never replace itThe session-refresh machinery introduced in #5926 (db_handle.go) is the only thing that closes the shared
*sql.DBat runtime, and it has two problems:reconnect(force=true)doesh.db.Store(nil)andgo prevConn.Close()before the 1-second throttle check; if throttled, it returns leaving no pool at all. And sinceConvertErrorcannot tell which pool an error came from, a late-arriving error from an already-replaced pool destroys the freshly created healthy one. Under a burst of connection errors this churns: concurrent operations raceh.db.Load()againstgo prevConn.Close()and observe Go'ssql: database is closed.sql: database is closedis not a refresh trigger.ConvertErroronly reconnects onneedsRefresh(err)(for Postgres: a*pgconn.PgErrorwith a connection-failure SQLSTATE — driver/pgx.go#L80-L86),driver.ErrBadConn,io.EOF/ErrUnexpectedEOF, orECONNRESET/ABORTED/REFUSED. Go'serrDBClosedmatches none of these. So the one error state the refresher itself creates is one it can never heal: the raw error is wrapped intoserviceerror.Unavailableand retried forever against the same closed pool.2. The membership heartbeat never escalates
startHeartbeatUpsertLoop(monitor.go#L358-L377) logs the error and sleeps 10–15s, unconditionally, forever — no consecutive-failure counter, no connection recreation, no fatal. Only startup failures are fatal (Start(), lines 142–151). Combined with (1), the process runs indefinitely with a permanently dead heartbeat. Recovery is further blocked becausebootstrapRingPoponly trusts rows withLastHeartbeatWithin: 20s(healthyHostLastHeartbeatCutoff), so once all pods' heartbeats fail, the discovery table is effectively empty.Our incident logs are consistent with the handle spending most of the outage nil (
no usable database connection found) with periodic races onto just-closed pools (54 ×sql: database is closedover 29h in the history service alone).Proposed fix
Any one of these breaks the failure chain; (1) + (3) together would both heal the pool and remove the zombie mode:
sql: database is closedas refresh-triggering. InneedsRefresh/ConvertError, classify the closed-pool error (it is notdriver.ErrBadConnand not matchable viaerrors.Is— match Go'serrDBClosedstring, or probe withdb.Pingon unclassified errors) so the handle heals the state it created itself.reconnect(force=true), evaluate the 1-second throttle first, and only thenStore(nil)+ close; additionally tag errors with a pool generation so a stale pool's error cannot destroy its healthy replacement.startHeartbeatUpsertLoop, count consecutive failures andlogger.Fatalafter N (e.g. 12 ≈ 2–3 minutes), matching the loop's own startup behavior — supervised deployments then self-heal instead of running as zombies.AdminService.DeepHealthCheckexists but nothing calls it by default; if the frontend's health status considered ring contents,operator cluster healthand k8s probes could see this class of failure.Prior art
DatabaseHandlewedged on RDS Postgres in v1.25.0; sql: Only update last refresh time when we refresh #6538 fixed only thelastRefreshstarvation, not the destroy-before-throttle ordering or theerrDBClosedgap (both still present onmainas of this writing —db_handle.goandmonitor.goare byte-identical to v1.31.2).UpsertClusterMembership … session has been closedlooping forever); open since 2021.no usable database connection), the louder CrashLoop variant.Steps to Reproduce the Problem
Hard to trigger deterministically because it requires losing the
h.db.Load()vsgo prevConn.Close()race, but the ingredients are:ConvertError → reconnect(force=true)in close succession.sql: database is closedorno usable database connection found; once the ringpop heartbeat is among them for >20s, membership empties and the frontend servesNot enough hosts to serve the requestwhileoperator cluster healthstill reports SERVING. The state persists until process restart.Specifications
main; relevant files identical)maxConns: 20, maxIdleConns: 20, maxConnLifetime: 1h