From 2c1d7235f0b03cc60de59a58a38a6ae069426dbe Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 21 Jul 2026 12:23:38 +0200 Subject: [PATCH] fix: shrink MySQL connection pool (50->8) so a multi-op event doesn't exhaust the shared server's max_connections (Error 1040: Too many connections), which was silently failing the on-air widget/award_refs/grid reads and making the UI look frozen --- internal/db/mysql.go | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/db/mysql.go b/internal/db/mysql.go index 3469c80..36592c3 100644 --- a/internal/db/mysql.go +++ b/internal/db/mysql.go @@ -129,6 +129,22 @@ func translateTextColumns(s string) string { // OpenMySQL opens the shared MySQL logbook, creating the database if needed, // then applies the (translated) embedded migrations. multiStatements is enabled // so multi-statement migration files run in a single Exec. +// tuneMySQLPool sizes the connection pool for a SHARED server. A multi-operator +// special event runs many OpsLog instances against ONE MySQL, whose max_connections +// is a hard global limit (often 100–151). A big per-instance pool (we used 50) let +// a handful of ops exhaust the server — "Error 1040: Too many connections" — after +// which queries (the on-air widget, award_refs, the grid) fail silently and the UI +// looks frozen. A modest cap with brisk idle reaping keeps each instance's server +// footprint small so ~10+ ops fit, while still absorbing the UI's concurrent +// logbook queries. No max lifetime: a slow first migration can run for minutes on a +// single connection and reaping it mid-migration drops the selected database. +func tuneMySQLPool(conn *sql.DB) { + conn.SetMaxOpenConns(8) + conn.SetMaxIdleConns(3) + conn.SetConnMaxIdleTime(30 * time.Second) // return idle connections to the server promptly + conn.SetConnMaxLifetime(0) +} + func OpenMySQL(c MySQLConfig) (*sql.DB, error) { if strings.TrimSpace(c.Host) == "" { return nil, fmt.Errorf("host is required") @@ -141,18 +157,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) { if err != nil { return nil, fmt.Errorf("open mysql: %w", err) } - // The UI fires bursts of concurrent queries (the Preferences dialog alone - // loads ~8 settings in parallel, plus the grid and live cluster status). A - // low cap turns such a burst into a deadlock-like stall against a remote - // server, so keep a generous pool with idle reaping. SQLite ran uncapped. - conn.SetMaxOpenConns(50) - conn.SetMaxIdleConns(10) - conn.SetConnMaxIdleTime(90 * time.Second) - // No max lifetime: a slow server's first migration can run for minutes on a - // single connection, and reaping it mid-migration drops the selected database - // (surfacing as "Unknown database"). Idle connections are still recycled - // after 90s, and the driver retries stale pooled connections. - conn.SetConnMaxLifetime(0) + tuneMySQLPool(conn) // Connect DIRECTLY to the database first. Only if that fails — the DB doesn't // exist yet (first-time setup) or the server is unreachable — do we pay for the // server-level connect + CREATE DATABASE (two more handshakes). On a normal @@ -167,10 +172,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) { if err != nil { return nil, fmt.Errorf("open mysql: %w", err) } - conn.SetMaxOpenConns(50) - conn.SetMaxIdleConns(10) - conn.SetConnMaxIdleTime(90 * time.Second) - conn.SetConnMaxLifetime(0) + tuneMySQLPool(conn) if err := conn.Ping(); err != nil { _ = conn.Close() return nil, fmt.Errorf("connect to %s: %w", name, err)