Two wins, one root cause: every API request was opening TWO fresh MySQL
connections plus four wasted round-trips before the real query.
Old per-request shape (GET /acc/):
1. DB() constructor → open conn, SHOW TABLES LIKE 'acc',
SHOW TABLES LIKE 'user', close
2. db.query() → open conn, run SELECT, close
That's ~4 round-trips for ~10 ms of useful work. With the dashboard's
30 s auto-refresh and two open tabs (accounts + users), the api-server
churned through ~10 fresh MySQL connections every minute even when
nothing changed.
Changes:
- app/db.py: introduce a process-wide MySQLConnectionPool (size 8 by
default, override with DB_POOL_SIZE). DB() now just touches the cached
pool — no schema check, no fresh handshake. query()/execute() rent a
connection from the pool and return it via conn.close().
- app/db.py: extract the schema check into verify_tables_once() — runs
once at WSGI boot inside create_app() so a misconfigured DB still
fails fast at startup.
- app/cm_api.py: _close_database_connection() removed; the finally
blocks that wrapped every route are gone too. Pool reclamation lives
inside DB now.
- app/cm_api.py: create_app() and run() invoke verify_tables_once()
once at startup instead of CM_API.__init__ doing nothing useful.
Net: ~4× round-trip reduction per request, no MySQL handshake on the
hot path. With two gunicorn workers × pool_size 8 = 16 max in-flight
connections, well under MySQL's default max_connections=151.
(The user asked about 'batching the queries' — but the queries already
return the full row set in one shot. The bottleneck was connection
churn, not query shape. If row count grows past the comfortable single-
fetch range later, swap to LIMIT/OFFSET pagination at the API + table
component layer.)
135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
import os
|
||
import threading
|
||
import time
|
||
|
||
import mysql.connector
|
||
from mysql.connector import Error, pooling
|
||
|
||
|
||
def _get_required_env(name: str) -> str:
|
||
value = os.getenv(name)
|
||
if value is None or value == "":
|
||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||
return value
|
||
|
||
|
||
# Process-wide MySQL connection pool. Gunicorn forks workers; each worker
|
||
# gets its own pool (the global is rebuilt per process at first use).
|
||
_pool: "pooling.MySQLConnectionPool | None" = None
|
||
_pool_lock = threading.Lock()
|
||
|
||
|
||
def _build_pool() -> "pooling.MySQLConnectionPool":
|
||
config = {
|
||
"host": _get_required_env("DB_HOST"),
|
||
"user": _get_required_env("DB_USER"),
|
||
"password": _get_required_env("DB_PASSWORD"),
|
||
"database": _get_required_env("DB_NAME"),
|
||
"port": int(_get_required_env("DB_PORT")),
|
||
"connection_timeout": int(_get_required_env("DB_CONNECTION_TIMEOUT")),
|
||
"pool_name": "cm_pool",
|
||
"pool_size": int(os.getenv("DB_POOL_SIZE", "8")),
|
||
"pool_reset_session": True,
|
||
}
|
||
return pooling.MySQLConnectionPool(**config)
|
||
|
||
|
||
def _get_pool() -> "pooling.MySQLConnectionPool":
|
||
"""Lazily build the per-process pool with retry, then memoize."""
|
||
global _pool
|
||
if _pool is not None:
|
||
return _pool
|
||
with _pool_lock:
|
||
if _pool is not None:
|
||
return _pool
|
||
retries = max(1, int(_get_required_env("DB_CONNECT_RETRIES")))
|
||
delay = float(_get_required_env("DB_CONNECT_RETRY_DELAY"))
|
||
last_err: "Exception | None" = None
|
||
for attempt in range(1, retries + 1):
|
||
try:
|
||
_pool = _build_pool()
|
||
return _pool
|
||
except Error as e:
|
||
last_err = e
|
||
if attempt < retries:
|
||
print(
|
||
f"MySQL pool init failed ({e}); "
|
||
f"retry {attempt}/{retries} in {delay}s..."
|
||
)
|
||
time.sleep(delay)
|
||
raise RuntimeError(
|
||
f"Failed to build MySQL pool after {retries} attempts: {last_err}"
|
||
)
|
||
|
||
|
||
def verify_tables_once() -> None:
|
||
"""Run once at app startup to confirm schema is present.
|
||
|
||
Previously the DB() constructor ran two SHOW TABLES LIKE queries on
|
||
every request — wasted round-trips. Now the check happens once when
|
||
create_app() boots the WSGI app; subsequent requests just rent a
|
||
connection from the pool.
|
||
"""
|
||
conn = _get_pool().get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
try:
|
||
cursor.execute("SHOW TABLES LIKE 'acc'")
|
||
if not cursor.fetchone():
|
||
raise Exception("Table 'acc' does not exist")
|
||
cursor.execute("SHOW TABLES LIKE 'user'")
|
||
if not cursor.fetchone():
|
||
raise Exception("Table 'user' does not exist")
|
||
finally:
|
||
cursor.close()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
class DB:
|
||
"""Thin handle backed by the process-wide MySQL pool.
|
||
|
||
Constructing DB() is now ~free — it just touches the (cached) pool.
|
||
Each query()/execute() rents a connection from the pool and returns
|
||
it on completion via conn.close() (which the pool intercepts and
|
||
releases the connection back instead of actually closing it).
|
||
|
||
Pool size caps in-flight queries per worker; tune with DB_POOL_SIZE
|
||
(default 8). Two gunicorn workers × pool_size 8 = 16 max
|
||
connections — comfortably under MySQL's default max_connections.
|
||
"""
|
||
|
||
def __init__(self):
|
||
_get_pool()
|
||
|
||
def query(self, sql, params=None):
|
||
conn = _get_pool().get_connection()
|
||
try:
|
||
cursor = conn.cursor(dictionary=True)
|
||
try:
|
||
cursor.execute(sql, params or ())
|
||
return cursor.fetchall()
|
||
finally:
|
||
cursor.close()
|
||
except Error as e:
|
||
print(f"Error executing query: {e}")
|
||
return []
|
||
finally:
|
||
conn.close()
|
||
|
||
def execute(self, sql, params=None):
|
||
conn = _get_pool().get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
try:
|
||
cursor.execute(sql, params or ())
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
cursor.close()
|
||
except Error as e:
|
||
print(f"Error executing query: {e}")
|
||
return False
|
||
finally:
|
||
conn.close()
|