Aug 27 - Add MySQL optimize and security check
This commit is contained in:
@@ -0,0 +1,963 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
scripts/db_health.py -- MySQL health check + index optimizer
|
||||
==============================================================
|
||||
|
||||
A standalone operations tool. It does NOT import the request layer, does not
|
||||
touch the uploads tree, and by DEFAULT writes nothing at all -- the plain
|
||||
invocation is strictly read-only (SELECTs against INFORMATION_SCHEMA / SHOW /
|
||||
EXPLAIN).
|
||||
|
||||
What it checks
|
||||
--------------
|
||||
1. SQLAlchemy engine options pool_pre_ping / pool_recycle -- the two that
|
||||
decide whether you get "MySQL server has
|
||||
gone away" after an idle overnight.
|
||||
2. Server + connection settings max_connections vs the Gunicorn pool,
|
||||
wait_timeout vs pool_recycle,
|
||||
innodb_buffer_pool_size vs the live data
|
||||
size, slow-query-log state, sql_mode.
|
||||
3. Schema hygiene non-InnoDB tables, non-utf8mb4 charsets,
|
||||
mixed collations (a collation mismatch on a
|
||||
join column silently disables the index).
|
||||
4. Table + index footprint row estimates, data/index bytes.
|
||||
5. Missing indexes a curated, individually justified list
|
||||
checked against what is really there.
|
||||
6. Redundant indexes leftmost-prefix duplicates. FK-backed
|
||||
indexes are excluded from drop candidates --
|
||||
dropping one fails with errno 150.
|
||||
7. Unindexed foreign keys should be empty on InnoDB; a gap here means
|
||||
every parent DELETE scans the child table.
|
||||
8. EXPLAIN on hot queries the dashboard, both list pages, the SLA
|
||||
cron, the mobile notification poll. Flags
|
||||
full table scans, filesorts, temp tables.
|
||||
|
||||
What it can change (only when explicitly asked)
|
||||
-----------------------------------------------
|
||||
--apply-indexes CREATE the missing recommended indexes (idempotent --
|
||||
re-checks INFORMATION_SCHEMA first, per CLAUDE.md rule 16;
|
||||
CREATE INDEX IF NOT EXISTS needs MySQL >= 8.0.12).
|
||||
--analyze ANALYZE TABLE on every table. Cheap, non-locking on
|
||||
InnoDB, refreshes optimizer cardinality statistics.
|
||||
Safe to run in production.
|
||||
--optimize OPTIMIZE TABLE -- rebuilds each table to reclaim space
|
||||
after large deletes. Requires --yes. This LOCKS each
|
||||
table for the rebuild: maintenance window only.
|
||||
|
||||
It never DROPs anything. Redundant indexes are reported with the exact SQL to
|
||||
run by hand, because "unused" is a judgement call the tool should not make for
|
||||
you.
|
||||
|
||||
Usage
|
||||
-----
|
||||
cd /home/jqc/janitorial_qc
|
||||
source venv/bin/activate
|
||||
|
||||
python scripts/db_health.py # read-only report
|
||||
python scripts/db_health.py --json /tmp/db.json # + machine-readable
|
||||
python scripts/db_health.py --no-explain # skip the EXPLAIN pass
|
||||
python scripts/db_health.py --emit-migration migrations/versions/phase54_perf_indexes.py \
|
||||
--revision phase54_perf_indexes
|
||||
python scripts/db_health.py --apply-indexes # create missing indexes
|
||||
python scripts/db_health.py --analyze # refresh statistics
|
||||
python scripts/db_health.py --optimize --yes # rebuild (locks!)
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
0 no findings at or above --fail-on
|
||||
1 findings at or above --fail-on (default 'none' -- never fails)
|
||||
2 could not connect / not a MySQL database / refused a destructive flag
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
from app import create_app, db # noqa: E402
|
||||
|
||||
|
||||
# -- Output helpers ----------------------------------------------------------
|
||||
|
||||
class C:
|
||||
OK = '\033[92m'
|
||||
WARN = '\033[93m'
|
||||
ERR = '\033[91m'
|
||||
DIM = '\033[90m'
|
||||
BOLD = '\033[1m'
|
||||
END = '\033[0m'
|
||||
|
||||
|
||||
_NO_COLOR = (not sys.stdout.isatty()) or bool(os.environ.get('NO_COLOR'))
|
||||
|
||||
|
||||
def _c(code, s):
|
||||
return s if _NO_COLOR else f'{code}{s}{C.END}'
|
||||
|
||||
|
||||
def header(title):
|
||||
print()
|
||||
print(_c(C.BOLD, f'-- {title} ' + '-' * max(0, 72 - len(title))))
|
||||
|
||||
|
||||
SEVERITY_ORDER = {'info': 0, 'low': 1, 'medium': 2, 'high': 3}
|
||||
|
||||
FINDINGS = []
|
||||
|
||||
|
||||
def finding(severity, area, message, fix=None):
|
||||
"""Record a finding and print it. severity: info|low|medium|high."""
|
||||
FINDINGS.append({'severity': severity, 'area': area,
|
||||
'message': message, 'fix': fix})
|
||||
tag = {
|
||||
'info': _c(C.DIM, 'INFO '),
|
||||
'low': _c(C.DIM, 'LOW '),
|
||||
'medium': _c(C.WARN, 'MED '),
|
||||
'high': _c(C.ERR, 'HIGH '),
|
||||
}[severity]
|
||||
print(f' {tag} {message}')
|
||||
if fix:
|
||||
for line in fix.strip().splitlines():
|
||||
print(_c(C.DIM, f' {line}'))
|
||||
|
||||
|
||||
def ok(message):
|
||||
print(f' {_c(C.OK, "OK ")} {message}')
|
||||
|
||||
|
||||
def human_bytes(n):
|
||||
n = float(n or 0)
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if n < 1024:
|
||||
return f'{n:.1f} {unit}'
|
||||
n /= 1024
|
||||
return f'{n:.1f} PB'
|
||||
|
||||
|
||||
# -- The recommended index set -----------------------------------------------
|
||||
#
|
||||
# Each entry is justified by a specific query in the application. An index that
|
||||
# nothing runs is pure write-amplification, so nothing speculative belongs here.
|
||||
#
|
||||
# (table, index_name, 'col, col', priority, why)
|
||||
#
|
||||
# priority: 'high' -- a hot path scans without it (every page load / every cron)
|
||||
# 'medium' -- a heavy but less frequent report or filter
|
||||
# 'low' -- worth having as the table grows
|
||||
|
||||
RECOMMENDED_INDEXES = [
|
||||
# --- notifications: polled by the bell on every page AND by every iPad ---
|
||||
('notifications', 'ix_notif_user_read_created', 'user_id, is_read, created_at',
|
||||
'high',
|
||||
"GET /api/v1/notifications and the in-app bell both run "
|
||||
"WHERE user_id=? AND is_read=0 [AND created_at > ?] ORDER BY created_at. "
|
||||
"Only user_id is indexed today, so MySQL reads every notification ever "
|
||||
"sent to that user and then sorts them."),
|
||||
|
||||
('notifications', 'ix_notif_digest_user', 'digest_pending, user_id',
|
||||
'medium',
|
||||
"send_pending_digests() runs SELECT DISTINCT user_id WHERE "
|
||||
"digest_pending=1. The existing single-column digest_pending index cannot "
|
||||
"satisfy the DISTINCT from the index alone."),
|
||||
|
||||
# --- issues: the SLA cron scans these every 30 minutes, forever ----------
|
||||
('issues', 'ix_issues_status_reported', 'status, reported_at',
|
||||
'high',
|
||||
"send_sla_alerts() selects every open/in_progress/pending_verification "
|
||||
"issue every 30 min, and the issues list orders by reported_at DESC after "
|
||||
"filtering on status. One composite serves both; ix_issues_status then "
|
||||
"becomes a redundant prefix of it."),
|
||||
|
||||
('issues', 'ix_issues_resolved_at', 'resolved_at',
|
||||
'medium',
|
||||
"The dashboard 'Resolved Today' card and the SLA Compliance report both "
|
||||
"filter on resolved_at ranges; the column is unindexed."),
|
||||
|
||||
('issues', 'ix_issues_area_status', 'area_id, status',
|
||||
'medium',
|
||||
"Every facility-scoped issue query outer-joins areas and ORs on "
|
||||
"Area.facility_id. area_id carries only the plain FK index; pairing it "
|
||||
"with status lets the legacy area-routed half of the OR resolve from the "
|
||||
"index instead of reading rows."),
|
||||
|
||||
('issues', 'ix_issues_handler_status', 'handler_type, status',
|
||||
'low',
|
||||
"Dashboard handler-split cards and the ?handler_type= list filter."),
|
||||
|
||||
# --- inspections --------------------------------------------------------
|
||||
('inspections', 'ix_insp_followup_status', 'follow_up_required, status',
|
||||
'medium',
|
||||
"The Pending Follow-ups dashboard card and the ?status=follow_up filter "
|
||||
"run WHERE follow_up_required=1 AND status='completed' AND NOT EXISTS("
|
||||
"child re-inspection)."),
|
||||
|
||||
('inspections', 'ix_insp_followup_assignee', 'follow_up_assigned_to',
|
||||
'low',
|
||||
"phase53 follow-up ownership (Inspection.follow_up_owned_by) filters on "
|
||||
"this column. It is a late-added nullable FK -- confirm it is indexed."),
|
||||
|
||||
# --- audit_logs: append-only, and the fastest-growing table here ---------
|
||||
('audit_logs', 'ix_audit_entity', 'entity_type, entity_id',
|
||||
'medium',
|
||||
"The audit trail is filtered by entity. entity_type alone is very low "
|
||||
"cardinality (about a dozen distinct values), so it is close to useless "
|
||||
"on its own."),
|
||||
|
||||
('audit_logs', 'ix_audit_user_created', 'user_id, created_at',
|
||||
'low',
|
||||
"Per-user audit filtering with the default created_at DESC ordering."),
|
||||
|
||||
# --- issue_comments -----------------------------------------------------
|
||||
('issue_comments', 'ix_comments_issue_created', 'issue_id, created_at',
|
||||
'medium',
|
||||
"Both the web issue detail and GET /api/v1/issues/<id>/comments read the "
|
||||
"thread ordered by created_at. The plain FK index on issue_id leaves the "
|
||||
"sort to a filesort."),
|
||||
|
||||
# --- scheduled_inspections: cron every 30 min ---------------------------
|
||||
('scheduled_inspections', 'ix_sched_active_due', 'active, next_due_date',
|
||||
'medium',
|
||||
"The reminder cron and the dashboard panel both read "
|
||||
"WHERE active=1 ORDER BY next_due_date."),
|
||||
|
||||
# --- support ------------------------------------------------------------
|
||||
('support_chat_messages', 'ix_scm_session_created', 'session_id, created_at',
|
||||
'low',
|
||||
"Transcript rendering reads a session's messages in created_at order."),
|
||||
]
|
||||
|
||||
|
||||
# -- Representative hot queries for the EXPLAIN pass -------------------------
|
||||
#
|
||||
# Literal-free: every value is a bound parameter, so this pass reflects what the
|
||||
# app actually sends and cannot itself be a vector for anything.
|
||||
|
||||
EXPLAIN_QUERIES = [
|
||||
('dashboard: open issues (scoped)',
|
||||
"SELECT i.id FROM issues i LEFT JOIN areas a ON i.area_id = a.id "
|
||||
"WHERE i.status IN ('open','in_progress')",
|
||||
{}),
|
||||
|
||||
('dashboard: resolved today',
|
||||
"SELECT COUNT(*) FROM issues WHERE status='resolved' "
|
||||
"AND resolved_at >= :d0 AND resolved_at < :d1",
|
||||
{'d0': '2026-01-01 00:00:00', 'd1': '2026-01-02 00:00:00'}),
|
||||
|
||||
('dashboard: pending follow-ups',
|
||||
"SELECT COUNT(*) FROM inspections i WHERE i.follow_up_required = 1 "
|
||||
"AND i.status = 'completed' AND NOT EXISTS "
|
||||
"(SELECT 1 FROM inspections c WHERE c.parent_inspection_id = i.id)",
|
||||
{}),
|
||||
|
||||
('issues list: page 1, newest first',
|
||||
"SELECT i.id FROM issues i LEFT JOIN areas a ON i.area_id = a.id "
|
||||
"ORDER BY i.reported_at DESC LIMIT 25",
|
||||
{}),
|
||||
|
||||
('inspections list: page 1, newest first',
|
||||
"SELECT i.id FROM inspections i ORDER BY i.inspection_date DESC LIMIT 25",
|
||||
{}),
|
||||
|
||||
('SLA cron: candidate scan (every 30 min)',
|
||||
"SELECT id FROM issues WHERE status IN "
|
||||
"('open','in_progress','pending_verification')",
|
||||
{}),
|
||||
|
||||
('mobile: unread notification poll',
|
||||
"SELECT id FROM notifications WHERE user_id = :uid AND is_read = 0 "
|
||||
"AND created_at > :since ORDER BY created_at ASC LIMIT 50",
|
||||
{'uid': 1, 'since': '2026-01-01 00:00:00'}),
|
||||
|
||||
('digest cron: pending recipients',
|
||||
"SELECT DISTINCT user_id FROM notifications WHERE digest_pending = 1",
|
||||
{}),
|
||||
|
||||
('reports: avg score by facility (30d)',
|
||||
"SELECT f.id, AVG(i.overall_score) FROM facilities f "
|
||||
"JOIN inspections i ON f.id = i.facility_id "
|
||||
"WHERE i.inspection_date >= :d0 AND i.status='completed' "
|
||||
"AND i.overall_score IS NOT NULL GROUP BY f.id",
|
||||
{'d0': '2026-01-01 00:00:00'}),
|
||||
|
||||
('audit trail: newest first',
|
||||
"SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT 50",
|
||||
{}),
|
||||
]
|
||||
|
||||
|
||||
# -- Introspection -----------------------------------------------------------
|
||||
|
||||
def db_name(conn):
|
||||
return conn.execute(text('SELECT DATABASE()')).scalar()
|
||||
|
||||
|
||||
def is_mysql(conn):
|
||||
return conn.dialect.name in ('mysql', 'mariadb')
|
||||
|
||||
|
||||
def server_version(conn):
|
||||
return conn.execute(text('SELECT VERSION()')).scalar()
|
||||
|
||||
|
||||
def global_vars(conn, names):
|
||||
out = {}
|
||||
for n in names:
|
||||
try:
|
||||
row = conn.execute(
|
||||
text('SHOW GLOBAL VARIABLES LIKE :n'), {'n': n}).fetchone()
|
||||
if row:
|
||||
out[row[0]] = row[1]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def fetch_tables(conn, schema):
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, ENGINE, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH,
|
||||
TABLE_COLLATION
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
|
||||
"""), {'s': schema}).fetchall()
|
||||
return [{'name': r[0], 'engine': r[1], 'rows': r[2] or 0,
|
||||
'data': r[3] or 0, 'index': r[4] or 0, 'collation': r[5]}
|
||||
for r in rows]
|
||||
|
||||
|
||||
def fetch_indexes(conn, schema):
|
||||
"""Return {table: {index_name: {'cols': [...], 'unique': bool}}}."""
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = :s
|
||||
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
||||
"""), {'s': schema}).fetchall()
|
||||
out = {}
|
||||
for tbl, idx, seq, col, non_unique in rows:
|
||||
entry = out.setdefault(tbl, {}).setdefault(
|
||||
idx, {'cols': [], 'unique': not non_unique})
|
||||
entry['cols'].append(col)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_fk_protection(conn, schema):
|
||||
"""Index names and columns MySQL relies on for FOREIGN KEY constraints.
|
||||
|
||||
Dropping such an index fails with errno 150, so these must never be
|
||||
offered as drop candidates no matter how redundant they look.
|
||||
"""
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = :s AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
"""), {'s': schema}).fetchall()
|
||||
fk_names = {}
|
||||
for tbl, name in rows:
|
||||
fk_names.setdefault(tbl, set()).add(name)
|
||||
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = :s AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
"""), {'s': schema}).fetchall()
|
||||
fk_cols = {}
|
||||
for tbl, col in rows:
|
||||
fk_cols.setdefault(tbl, set()).add(col)
|
||||
return fk_names, fk_cols
|
||||
|
||||
|
||||
def index_exists(conn, schema, table, index_name):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t AND INDEX_NAME = :i
|
||||
"""), {'s': schema, 't': table, 'i': index_name}).scalar() > 0
|
||||
|
||||
|
||||
def table_exists(conn, schema, table):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t
|
||||
"""), {'s': schema, 't': table}).scalar() > 0
|
||||
|
||||
|
||||
def column_exists(conn, schema, table, column):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t AND COLUMN_NAME = :c
|
||||
"""), {'s': schema, 't': table, 'c': column}).scalar() > 0
|
||||
|
||||
|
||||
# -- Checks ------------------------------------------------------------------
|
||||
|
||||
def check_engine_options(app):
|
||||
header('SQLAlchemy engine options')
|
||||
opts = app.config.get('SQLALCHEMY_ENGINE_OPTIONS') or {}
|
||||
uri = app.config.get('SQLALCHEMY_DATABASE_URI', '')
|
||||
|
||||
if not opts:
|
||||
finding('high', 'engine',
|
||||
'SQLALCHEMY_ENGINE_OPTIONS is not set -- the pool runs on '
|
||||
'library defaults with no liveness check and no recycle.',
|
||||
"Add to config.Config:\n"
|
||||
" SQLALCHEMY_ENGINE_OPTIONS = {\n"
|
||||
" 'pool_pre_ping': True, # kills 'MySQL server has gone away'\n"
|
||||
" 'pool_recycle': 1800, # must be < MySQL wait_timeout\n"
|
||||
" 'pool_size': 5, 'max_overflow': 5,\n"
|
||||
" 'pool_timeout': 30,\n"
|
||||
" }")
|
||||
else:
|
||||
if not opts.get('pool_pre_ping'):
|
||||
finding('high', 'engine',
|
||||
'pool_pre_ping is off -- an idle connection killed by MySQL '
|
||||
'surfaces as OperationalError 2006 on the next request.',
|
||||
"SQLALCHEMY_ENGINE_OPTIONS['pool_pre_ping'] = True")
|
||||
else:
|
||||
ok('pool_pre_ping enabled')
|
||||
|
||||
recycle = opts.get('pool_recycle')
|
||||
if not recycle or recycle < 0:
|
||||
finding('medium', 'engine',
|
||||
'pool_recycle is unset -- connections are held indefinitely '
|
||||
'and can outlive the server-side wait_timeout.',
|
||||
"SQLALCHEMY_ENGINE_OPTIONS['pool_recycle'] = 1800")
|
||||
else:
|
||||
ok(f'pool_recycle = {recycle}s')
|
||||
|
||||
if 'mysql' in uri and 'charset=' not in uri:
|
||||
finding('low', 'engine',
|
||||
'DATABASE_URL has no ?charset= -- PyMySQL negotiates the server '
|
||||
'default, which on older installs is latin1.',
|
||||
'Append ?charset=utf8mb4 to DATABASE_URL.')
|
||||
return opts
|
||||
|
||||
|
||||
def check_server(conn, opts, tables):
|
||||
header('MySQL server settings')
|
||||
print(f' {_c(C.DIM, "version")} {server_version(conn)}')
|
||||
|
||||
g = global_vars(conn, [
|
||||
'max_connections', 'wait_timeout', 'interactive_timeout',
|
||||
'innodb_buffer_pool_size', 'slow_query_log', 'long_query_time',
|
||||
'sql_mode', 'character_set_server', 'collation_server',
|
||||
'innodb_flush_log_at_trx_commit', 'table_open_cache',
|
||||
'performance_schema',
|
||||
])
|
||||
|
||||
# --- pool sizing vs max_connections -------------------------------------
|
||||
try:
|
||||
maxc = int(g.get('max_connections', 0))
|
||||
except (TypeError, ValueError):
|
||||
maxc = 0
|
||||
pool_size = (opts or {}).get('pool_size', 5)
|
||||
overflow = (opts or {}).get('max_overflow', 10)
|
||||
try:
|
||||
import multiprocessing
|
||||
workers = multiprocessing.cpu_count() * 2 + 1 # gunicorn_config.py
|
||||
except Exception:
|
||||
workers = 9
|
||||
worst_case = workers * (pool_size + overflow)
|
||||
print(f' {_c(C.DIM, "max_connections")} {maxc} '
|
||||
f'{_c(C.DIM, "gunicorn workers")} ~{workers} '
|
||||
f'{_c(C.DIM, "worst-case pool")} {worst_case}')
|
||||
if maxc and worst_case > maxc * 0.8:
|
||||
finding('high', 'server',
|
||||
f'{workers} sync workers x (pool_size {pool_size} + overflow '
|
||||
f'{overflow}) = {worst_case} possible connections against '
|
||||
f'max_connections={maxc}. Under load that returns "Too many '
|
||||
f'connections" instead of queueing.',
|
||||
'Either cap the pool (pool_size 5 / max_overflow 5) or raise '
|
||||
'max_connections. A sync worker only ever needs one connection '
|
||||
'in steady state.')
|
||||
elif maxc:
|
||||
ok(f'connection ceiling has headroom ({worst_case} worst case / {maxc})')
|
||||
|
||||
# --- wait_timeout vs pool_recycle ---------------------------------------
|
||||
try:
|
||||
wait = int(g.get('wait_timeout', 0))
|
||||
except (TypeError, ValueError):
|
||||
wait = 0
|
||||
recycle = (opts or {}).get('pool_recycle')
|
||||
if wait and recycle and recycle >= wait:
|
||||
finding('high', 'server',
|
||||
f'pool_recycle ({recycle}s) is not below wait_timeout ({wait}s) '
|
||||
f'-- the server closes the connection before SQLAlchemy retires '
|
||||
f'it, which is exactly the case pool_recycle exists to prevent.',
|
||||
f'Set pool_recycle to roughly {int(wait * 0.6)}.')
|
||||
elif wait:
|
||||
ok(f'wait_timeout = {wait}s')
|
||||
|
||||
# --- buffer pool vs live data -------------------------------------------
|
||||
try:
|
||||
pool_bytes = int(g.get('innodb_buffer_pool_size', 0))
|
||||
except (TypeError, ValueError):
|
||||
pool_bytes = 0
|
||||
total = sum(t['data'] + t['index'] for t in tables)
|
||||
print(f' {_c(C.DIM, "innodb_buffer_pool_size")} {human_bytes(pool_bytes)} '
|
||||
f'{_c(C.DIM, "data+indexes")} {human_bytes(total)}')
|
||||
if pool_bytes and total > pool_bytes:
|
||||
finding('medium', 'server',
|
||||
f'The working set ({human_bytes(total)}) exceeds the InnoDB '
|
||||
f'buffer pool ({human_bytes(pool_bytes)}) -- hot pages are being '
|
||||
f'evicted and re-read from disk.',
|
||||
'On a dedicated box set innodb_buffer_pool_size to about '
|
||||
'60-70% of RAM.')
|
||||
elif pool_bytes:
|
||||
ok('the entire dataset fits in the InnoDB buffer pool')
|
||||
|
||||
# --- slow query log ------------------------------------------------------
|
||||
if str(g.get('slow_query_log', 'OFF')).upper() in ('OFF', '0'):
|
||||
finding('medium', 'observability',
|
||||
'slow_query_log is OFF -- there is no record of which queries '
|
||||
'are actually slow in production, so tuning stays guesswork.',
|
||||
"SET GLOBAL slow_query_log = 'ON';\n"
|
||||
"SET GLOBAL long_query_time = 0.5;\n"
|
||||
"(and persist both in my.cnf so they survive a restart)")
|
||||
else:
|
||||
ok(f"slow_query_log ON (long_query_time={g.get('long_query_time')})")
|
||||
|
||||
# --- sql_mode ------------------------------------------------------------
|
||||
mode = g.get('sql_mode', '') or ''
|
||||
if 'STRICT_TRANS_TABLES' not in mode and 'STRICT_ALL_TABLES' not in mode:
|
||||
finding('medium', 'integrity',
|
||||
'sql_mode has no STRICT mode -- an over-long string or an '
|
||||
'invalid ENUM value is silently coerced instead of raising.',
|
||||
'Add STRICT_TRANS_TABLES to sql_mode in my.cnf.')
|
||||
else:
|
||||
ok('STRICT mode enabled')
|
||||
return g
|
||||
|
||||
|
||||
def check_schema_hygiene(conn, schema, tables):
|
||||
header('Schema hygiene')
|
||||
bad_engine = [t for t in tables if (t['engine'] or '').upper() != 'INNODB']
|
||||
if bad_engine:
|
||||
finding('high', 'schema',
|
||||
'Non-InnoDB tables found: ' +
|
||||
', '.join(f"{t['name']} ({t['engine']})" for t in bad_engine),
|
||||
'MyISAM has no transactions and locks the whole table on write.\n'
|
||||
+ '\n'.join(f'ALTER TABLE {t["name"]} ENGINE=InnoDB;'
|
||||
for t in bad_engine))
|
||||
else:
|
||||
ok('all tables are InnoDB')
|
||||
|
||||
collations = {}
|
||||
for t in tables:
|
||||
collations.setdefault(t['collation'] or 'unknown', []).append(t['name'])
|
||||
|
||||
non_utf8mb4 = {c: n for c, n in collations.items()
|
||||
if not str(c).startswith('utf8mb4')}
|
||||
if non_utf8mb4:
|
||||
for coll, names in non_utf8mb4.items():
|
||||
finding('medium', 'schema',
|
||||
f'{len(names)} table(s) are {coll}, not utf8mb4: '
|
||||
+ ', '.join(names[:8]) + ('...' if len(names) > 8 else ''),
|
||||
'utf8mb3/latin1 cannot store emoji or many non-Latin names, '
|
||||
'and a join between two different collations cannot use an '
|
||||
'index on that column.\n'
|
||||
+ '\n'.join(
|
||||
f'ALTER TABLE {n} CONVERT TO CHARACTER SET utf8mb4 '
|
||||
f'COLLATE utf8mb4_unicode_ci;' for n in names[:8]))
|
||||
else:
|
||||
ok('all tables are utf8mb4')
|
||||
|
||||
if len(collations) > 1:
|
||||
finding('low', 'schema',
|
||||
'Mixed collations across tables: '
|
||||
+ ', '.join(sorted(str(c) for c in collations))
|
||||
+ '. Joining two columns with different collations silently '
|
||||
'disables index use on the join.')
|
||||
|
||||
|
||||
def check_table_footprint(tables):
|
||||
header('Table footprint (largest first)')
|
||||
print(' ' + _c(C.DIM, 'table'.ljust(32) + 'rows~'.rjust(12)
|
||||
+ 'data'.rjust(12) + 'index'.rjust(12)))
|
||||
for t in tables[:15]:
|
||||
print(f' {t["name"][:32].ljust(32)}'
|
||||
f'{str(t["rows"]).rjust(12)}'
|
||||
f'{human_bytes(t["data"]).rjust(12)}'
|
||||
f'{human_bytes(t["index"]).rjust(12)}')
|
||||
for t in tables:
|
||||
if t['index'] > t['data'] * 2 and t['data'] > 10 * 1024 * 1024:
|
||||
finding('low', 'indexes',
|
||||
f'{t["name"]}: index bytes ({human_bytes(t["index"])}) are '
|
||||
f'more than twice the data ({human_bytes(t["data"])}) -- '
|
||||
f'likely over-indexed, which slows every write.')
|
||||
|
||||
|
||||
def check_missing_indexes(conn, schema, existing):
|
||||
header('Missing indexes (recommended set)')
|
||||
missing = []
|
||||
for table, name, cols, prio, why in RECOMMENDED_INDEXES:
|
||||
if not table_exists(conn, schema, table):
|
||||
continue
|
||||
col_list = [c.strip() for c in cols.split(',')]
|
||||
if any(not column_exists(conn, schema, table, c) for c in col_list):
|
||||
continue # column absent in this schema version -- skip quietly
|
||||
|
||||
tbl_idx = existing.get(table, {})
|
||||
# Covered when some existing index STARTS with exactly these columns in
|
||||
# this order: a longer index with the right prefix serves the same
|
||||
# queries, so adding a duplicate would only cost write time.
|
||||
covered = any(idx['cols'][:len(col_list)] == col_list
|
||||
for idx in tbl_idx.values())
|
||||
if covered:
|
||||
continue
|
||||
|
||||
missing.append((table, name, cols, prio, why))
|
||||
finding(prio, 'indexes',
|
||||
f'{table}.{name} ({cols}) is missing',
|
||||
f'{why}\nCREATE INDEX {name} ON {table} ({cols});')
|
||||
|
||||
if not missing:
|
||||
ok('every recommended index is present')
|
||||
return missing
|
||||
|
||||
|
||||
def check_redundant_indexes(existing, fk_names, fk_cols):
|
||||
header('Redundant indexes')
|
||||
found = False
|
||||
for table, idxs in sorted(existing.items()):
|
||||
names = sorted(idxs.keys())
|
||||
for a in names:
|
||||
if a == 'PRIMARY':
|
||||
continue
|
||||
for b in names:
|
||||
if a == b or b == 'PRIMARY':
|
||||
continue
|
||||
ca, cb = idxs[a]['cols'], idxs[b]['cols']
|
||||
# a is redundant when b starts with all of a's columns and b is
|
||||
# strictly longer.
|
||||
if len(ca) >= len(cb) or cb[:len(ca)] != ca:
|
||||
continue
|
||||
if idxs[a]['unique']:
|
||||
continue # a unique index is a constraint, not a hint
|
||||
|
||||
protected = (a in fk_names.get(table, set())
|
||||
or (len(ca) == 1
|
||||
and ca[0] in fk_cols.get(table, set())))
|
||||
if protected:
|
||||
finding('info', 'indexes',
|
||||
f'{table}.{a} ({", ".join(ca)}) is a prefix of '
|
||||
f'{b} ({", ".join(cb)}) but backs a FOREIGN KEY -- '
|
||||
f'leave it alone (dropping it fails, errno 150).')
|
||||
else:
|
||||
found = True
|
||||
finding('low', 'indexes',
|
||||
f'{table}.{a} ({", ".join(ca)}) is fully covered by '
|
||||
f'{b} ({", ".join(cb)}) -- it costs write time and '
|
||||
f'buffer-pool space for nothing.',
|
||||
'-- confirm against your slow log first, then:\n'
|
||||
f'DROP INDEX {a} ON {table};')
|
||||
break
|
||||
if not found:
|
||||
ok('no droppable duplicate indexes')
|
||||
|
||||
|
||||
def check_unindexed_fks(conn, schema, existing):
|
||||
header('Foreign keys without a usable index')
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = :s AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
"""), {'s': schema}).fetchall()
|
||||
bad = []
|
||||
for tbl, col in rows:
|
||||
idxs = existing.get(tbl, {})
|
||||
if not any(i['cols'] and i['cols'][0] == col for i in idxs.values()):
|
||||
bad.append((tbl, col))
|
||||
if bad:
|
||||
for tbl, col in bad:
|
||||
finding('high', 'indexes',
|
||||
f'{tbl}.{col} is a foreign key with no leading index -- '
|
||||
f'every DELETE on the parent table scans this one.',
|
||||
f'CREATE INDEX ix_{tbl}_{col} ON {tbl} ({col});')
|
||||
else:
|
||||
ok('every foreign key column has a leading index')
|
||||
|
||||
|
||||
def check_explain(conn):
|
||||
header('EXPLAIN on representative hot queries')
|
||||
for label, sql, params in EXPLAIN_QUERIES:
|
||||
try:
|
||||
rows = conn.execute(text('EXPLAIN ' + sql), params).mappings().all()
|
||||
except Exception as exc:
|
||||
print(f' {_c(C.DIM, "skip ")} {label}: '
|
||||
f'{str(exc).splitlines()[0][:90]}')
|
||||
continue
|
||||
|
||||
problems = []
|
||||
for r in rows:
|
||||
typ = (r.get('type') or '').upper()
|
||||
extra = r.get('Extra') or ''
|
||||
tbl = r.get('table') or '?'
|
||||
nrows = r.get('rows') or 0
|
||||
if typ == 'ALL':
|
||||
problems.append(f'full scan of {tbl} (~{nrows} rows)')
|
||||
if 'Using filesort' in extra:
|
||||
problems.append(f'filesort on {tbl}')
|
||||
if 'Using temporary' in extra:
|
||||
problems.append(f'temp table on {tbl}')
|
||||
|
||||
if problems:
|
||||
finding('medium', 'query-plan',
|
||||
f'{label}: ' + '; '.join(sorted(set(problems))), sql)
|
||||
else:
|
||||
ok(label)
|
||||
|
||||
|
||||
# -- Mutating actions (opt-in only) ------------------------------------------
|
||||
|
||||
def apply_indexes(conn, schema, missing):
|
||||
header('Applying missing indexes')
|
||||
if not missing:
|
||||
ok('nothing to apply')
|
||||
return
|
||||
for table, name, cols, prio, why in missing:
|
||||
# Re-check rather than trusting the earlier pass: CREATE INDEX IF NOT
|
||||
# EXISTS is unavailable before MySQL 8.0.12 (CLAUDE.md rule 16).
|
||||
if index_exists(conn, schema, table, name):
|
||||
print(f' {_c(C.DIM, "skip ")} {name} already exists')
|
||||
continue
|
||||
sql = f'CREATE INDEX {name} ON {table} ({cols})'
|
||||
print(f' {_c(C.BOLD, "run ")} {sql}')
|
||||
conn.execute(text(sql))
|
||||
print(f' {_c(C.OK, "done ")} {name}')
|
||||
|
||||
|
||||
def run_analyze(conn, tables):
|
||||
header('ANALYZE TABLE (refresh optimizer statistics)')
|
||||
for t in tables:
|
||||
conn.execute(text(f'ANALYZE TABLE `{t["name"]}`'))
|
||||
print(f' {_c(C.OK, "done ")} {t["name"]}')
|
||||
|
||||
|
||||
def run_optimize(conn, tables):
|
||||
header('OPTIMIZE TABLE (rebuild -- locks each table)')
|
||||
for t in tables:
|
||||
before = t['data'] + t['index']
|
||||
conn.execute(text(f'OPTIMIZE TABLE `{t["name"]}`'))
|
||||
print(f' {_c(C.OK, "done ")} {t["name"]} (was {human_bytes(before)})')
|
||||
|
||||
|
||||
# -- Alembic migration emitter -----------------------------------------------
|
||||
|
||||
MIGRATION_TEMPLATE = '''"""Performance indexes -- generated by scripts/db_health.py
|
||||
|
||||
Adds the indexes the health check found missing. Every statement is guarded by
|
||||
an INFORMATION_SCHEMA lookup, so this migration is safe to re-run (CLAUDE.md
|
||||
rule 16: CREATE INDEX IF NOT EXISTS needs MySQL >= 8.0.12).
|
||||
|
||||
Generated: {generated}
|
||||
|
||||
Revision ID: {revision}
|
||||
Revises: {down_revision}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision ids must be <= 32 chars -- alembic_version.version_num is VARCHAR(32).
|
||||
revision = '{revision}'
|
||||
down_revision = '{down_revision}'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
INDEXES = [
|
||||
{index_rows}
|
||||
]
|
||||
|
||||
|
||||
def _index_exists(conn, table, name):
|
||||
return conn.execute(sa.text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND INDEX_NAME = :i
|
||||
"""), {{'t': table, 'i': name}}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
for table, name, cols in INDEXES:
|
||||
if not _index_exists(conn, table, name):
|
||||
op.execute(sa.text(f'CREATE INDEX {{name}} ON {{table}} ({{cols}})'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
for table, name, cols in INDEXES:
|
||||
if _index_exists(conn, table, name):
|
||||
op.execute(sa.text(f'DROP INDEX {{name}} ON {{table}}'))
|
||||
'''
|
||||
|
||||
|
||||
def emit_migration(path, missing, revision, down_revision):
|
||||
rows = '\n'.join(
|
||||
f' ({table!r}, {name!r}, {cols!r}),'
|
||||
for table, name, cols, prio, why in missing
|
||||
) or ' # nothing was missing at generation time'
|
||||
body = MIGRATION_TEMPLATE.format(
|
||||
generated=datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
revision=revision,
|
||||
down_revision=down_revision,
|
||||
index_rows=rows,
|
||||
)
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
print(f'\n {_c(C.OK, "wrote")} {path}')
|
||||
print(_c(C.DIM, ' Review it, confirm down_revision is the real head '
|
||||
'(flask db heads), then run flask db upgrade.'))
|
||||
|
||||
|
||||
def current_head():
|
||||
"""Best-effort read of the current Alembic head from the versions dir."""
|
||||
vdir = os.path.join(os.path.dirname(__file__), '..', 'migrations', 'versions')
|
||||
revs, downs = set(), set()
|
||||
try:
|
||||
for fn in os.listdir(vdir):
|
||||
if not fn.endswith('.py'):
|
||||
continue
|
||||
with open(os.path.join(vdir, fn), encoding='utf-8') as fh:
|
||||
src = fh.read()
|
||||
m = re.search(r"^revision\s*=\s*['\"]([^'\"]+)", src, re.M)
|
||||
d = re.search(r"^down_revision\s*=\s*['\"]([^'\"]+)", src, re.M)
|
||||
if m:
|
||||
revs.add(m.group(1))
|
||||
if d:
|
||||
downs.add(d.group(1))
|
||||
except OSError:
|
||||
return '<REPLACE_WITH_CURRENT_HEAD>'
|
||||
heads = revs - downs
|
||||
return sorted(heads)[0] if len(heads) == 1 else '<REPLACE_WITH_CURRENT_HEAD>'
|
||||
|
||||
|
||||
# -- Main --------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(
|
||||
description='MySQL health check and index optimizer for JQC. '
|
||||
'Read-only unless an --apply/--analyze/--optimize flag is '
|
||||
'given.')
|
||||
p.add_argument('--json', metavar='PATH', help='write the full report as JSON')
|
||||
p.add_argument('--no-explain', action='store_true',
|
||||
help='skip the EXPLAIN pass')
|
||||
p.add_argument('--apply-indexes', action='store_true',
|
||||
help='CREATE the missing recommended indexes (idempotent)')
|
||||
p.add_argument('--analyze', action='store_true',
|
||||
help='ANALYZE TABLE on every table (safe, non-locking)')
|
||||
p.add_argument('--optimize', action='store_true',
|
||||
help='OPTIMIZE TABLE on every table -- LOCKS each table; '
|
||||
'needs --yes')
|
||||
p.add_argument('--emit-migration', metavar='PATH',
|
||||
help='write an Alembic migration for the missing indexes')
|
||||
p.add_argument('--revision', default='phaseNN_perf_indexes',
|
||||
help='revision id for --emit-migration (max 32 chars)')
|
||||
p.add_argument('--yes', action='store_true',
|
||||
help='confirm locking operations')
|
||||
p.add_argument('--fail-on', choices=['none', 'low', 'medium', 'high'],
|
||||
default='none',
|
||||
help='exit 1 when a finding of this severity or above exists')
|
||||
args = p.parse_args()
|
||||
|
||||
if args.optimize and not args.yes:
|
||||
print('--optimize rebuilds every table and holds a lock for the '
|
||||
'duration. Re-run with --yes inside a maintenance window.')
|
||||
return 2
|
||||
if len(args.revision) > 32:
|
||||
print('--revision must be <= 32 characters (alembic_version.version_num '
|
||||
'is VARCHAR(32)).')
|
||||
return 2
|
||||
|
||||
app = create_app(os.getenv('FLASK_ENV') or 'production')
|
||||
|
||||
with app.app_context():
|
||||
engine_opts = check_engine_options(app)
|
||||
|
||||
try:
|
||||
conn = db.engine.connect()
|
||||
except Exception as exc:
|
||||
print(_c(C.ERR, f'\nCould not connect to the database: {exc}'))
|
||||
return 2
|
||||
|
||||
with conn:
|
||||
if not is_mysql(conn):
|
||||
print(_c(C.ERR,
|
||||
f'\nThis script targets MySQL/MariaDB; the configured '
|
||||
f'database is "{conn.dialect.name}". Nothing to do.'))
|
||||
return 2
|
||||
|
||||
schema = db_name(conn)
|
||||
print(_c(C.BOLD, f'\nJQC database health check -- schema "{schema}" '
|
||||
f'@ {datetime.now():%Y-%m-%d %H:%M}'))
|
||||
|
||||
tables = fetch_tables(conn, schema)
|
||||
existing = fetch_indexes(conn, schema)
|
||||
fk_names, fk_cols = fetch_fk_protection(conn, schema)
|
||||
|
||||
server = check_server(conn, engine_opts, tables)
|
||||
check_schema_hygiene(conn, schema, tables)
|
||||
check_table_footprint(tables)
|
||||
missing = check_missing_indexes(conn, schema, existing)
|
||||
check_redundant_indexes(existing, fk_names, fk_cols)
|
||||
check_unindexed_fks(conn, schema, existing)
|
||||
if not args.no_explain:
|
||||
check_explain(conn)
|
||||
|
||||
# -- opt-in mutations ---------------------------------------------
|
||||
if args.apply_indexes:
|
||||
with conn.begin():
|
||||
apply_indexes(conn, schema, missing)
|
||||
if args.analyze:
|
||||
run_analyze(conn, tables)
|
||||
if args.optimize:
|
||||
run_optimize(conn, tables)
|
||||
|
||||
if args.emit_migration:
|
||||
emit_migration(args.emit_migration, missing,
|
||||
args.revision, current_head())
|
||||
|
||||
# -- summary ---------------------------------------------------------
|
||||
header('Summary')
|
||||
counts = {'high': 0, 'medium': 0, 'low': 0, 'info': 0}
|
||||
for f in FINDINGS:
|
||||
counts[f['severity']] += 1
|
||||
print(f' {_c(C.ERR, "high")} {counts["high"]} '
|
||||
f'{_c(C.WARN, "medium")} {counts["medium"]} '
|
||||
f'{_c(C.DIM, "low")} {counts["low"]} '
|
||||
f'{_c(C.DIM, "info")} {counts["info"]}')
|
||||
if not (args.apply_indexes or args.analyze or args.optimize):
|
||||
print(_c(C.DIM, ' (read-only run -- nothing was modified)'))
|
||||
|
||||
if args.json:
|
||||
report = {
|
||||
'generated_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'schema': schema,
|
||||
'server': server,
|
||||
'engine_options': {k: str(v) for k, v in (engine_opts or {}).items()},
|
||||
'tables': tables,
|
||||
'missing_indexes': [
|
||||
{'table': t, 'name': n, 'columns': c,
|
||||
'priority': pr, 'why': w}
|
||||
for t, n, c, pr, w in missing
|
||||
],
|
||||
'findings': FINDINGS,
|
||||
'counts': counts,
|
||||
}
|
||||
with open(args.json, 'w', encoding='utf-8') as fh:
|
||||
json.dump(report, fh, indent=2, default=str)
|
||||
print(f' report written to {args.json}')
|
||||
|
||||
if args.fail_on != 'none':
|
||||
threshold = SEVERITY_ORDER[args.fail_on]
|
||||
if any(SEVERITY_ORDER[f['severity']] >= threshold for f in FINDINGS):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user