#!/usr/bin/env python3 """mysqlx operational behavioural validation. Exercises two specific behaviours from issue #5678: 1. SIGTERM mid-traffic: open N X-Protocol clients running steady queries, send SIGTERM to proxysql, verify each client sees a clean Mysqlx::Error frame with code 1053 instead of a TCP RST. 2. LOAD MYSQLX ROUTES TO RUNTIME mid-traffic: open N clients on a route, drop the route from admin, reload, verify in-flight sessions continue while new connections get refused. Requires `mysql-connector-python` (`pip install mysql-connector-python`) and a running ProxySQL with the mysqlx plugin loaded. This is NOT a TAP unit test. It runs against live infrastructure. See test/scripts/mysqlx/README.md for the full setup recipe. """ import argparse import os import signal import subprocess import sys import threading import time from typing import List try: import mysqlx except ImportError: sys.stderr.write( "ERROR: mysql-connector-python not installed. " "Run: pip install mysql-connector-python\n" ) sys.exit(1) def parse_args(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--proxysql-host", default="127.0.0.1") p.add_argument("--proxysql-port", type=int, default=6603, help="X-Protocol listener port on ProxySQL") p.add_argument("--admin-host", default="127.0.0.1") p.add_argument("--admin-port", type=int, default=6032, help="ProxySQL admin port") p.add_argument("--admin-user", default="admin") p.add_argument("--admin-pass", default="admin") p.add_argument("--user", required=True, help="X-Protocol test user") p.add_argument("--password", required=True) p.add_argument("--clients", type=int, default=5, help="Concurrent client count") p.add_argument("--proxysql-pid-file", default="/var/run/proxysql.pid", help="Where to find the proxysql pid (for kill -TERM)") p.add_argument("--scenario", choices=["sigterm", "reload", "all"], default="all") p.add_argument("--route-name", default="r1", help="Route name to drop in the reload scenario") return p.parse_args() def open_session(args): return mysqlx.get_session({ "host": args.proxysql_host, "port": args.proxysql_port, "user": args.user, "password": args.password, "ssl-mode": "DISABLED", }) def steady_traffic_thread(args, stop_event, results: List[dict], idx: int): """Run a query loop until stop_event fires; record outcome.""" record = {"idx": idx, "queries": 0, "error": None, "error_class": None, "error_code": None} sess = None try: sess = open_session(args) while not stop_event.is_set(): sess.sql("SELECT 1").execute().fetch_all() record["queries"] += 1 time.sleep(0.01) except Exception as e: record["error"] = str(e) record["error_class"] = type(e).__name__ # mysql-connector-python raises mysqlx.errors.OperationalError # for both "TCP RST" and "Mysqlx::Error frame received"; the # error code distinguishes them. 1053 = clean shutdown notify; # anything else (including no .errno attribute) means TCP RST. record["error_code"] = getattr(e, "errno", None) finally: try: if sess is not None: sess.close() except Exception: pass results.append(record) def find_proxysql_pid(args) -> int: if os.path.isfile(args.proxysql_pid_file): with open(args.proxysql_pid_file) as fh: return int(fh.read().strip()) out = subprocess.check_output(["pidof", "proxysql"]).decode().strip() if not out: raise RuntimeError("proxysql process not found") return int(out.split()[0]) def scenario_sigterm(args): print("=== Scenario 1: SIGTERM mid-traffic ===") stop = threading.Event() results: List[dict] = [] threads = [ threading.Thread(target=steady_traffic_thread, args=(args, stop, results, i)) for i in range(args.clients) ] for t in threads: t.start() time.sleep(2) # let clients establish steady traffic pid = find_proxysql_pid(args) print(f"Sending SIGTERM to proxysql (pid {pid})...") os.kill(pid, signal.SIGTERM) for t in threads: t.join(timeout=10) stop.set() print(f"Collected {len(results)} client outcomes:") clean_close = 0 tcp_rst = 0 other = 0 for r in results: if r["error_code"] == 1053: clean_close += 1 elif r["error"] is not None: tcp_rst += 1 else: other += 1 print(f" client {r['idx']}: queries={r['queries']} " f"err={r['error_class']} code={r['error_code']}") print(f"\nResult: {clean_close} clean (1053), {tcp_rst} non-1053 errors, " f"{other} no-error") if clean_close == args.clients: print("PASS: every client received a clean shutdown notification") return 0 else: print("FAIL: at least one client did not see Mysqlx::Error 1053") return 1 def scenario_reload(args): print(f"=== Scenario 2: drop+reload route '{args.route_name}' " f"mid-traffic ===") stop = threading.Event() results: List[dict] = [] threads = [ threading.Thread(target=steady_traffic_thread, args=(args, stop, results, i)) for i in range(args.clients) ] for t in threads: t.start() time.sleep(2) print(f"Dropping route '{args.route_name}' from admin and reloading...") try: import mysql.connector # admin port speaks classic protocol adm = mysql.connector.connect( host=args.admin_host, port=args.admin_port, user=args.admin_user, password=args.admin_pass, ssl_disabled=True, ) cur = adm.cursor() cur.execute(f"DELETE FROM mysqlx_routes WHERE name='{args.route_name}'") cur.execute("LOAD MYSQLX ROUTES TO RUNTIME") adm.commit() adm.close() except Exception as e: print(f"Admin drop+reload failed: {e}") stop.set() for t in threads: t.join(timeout=5) return 1 # New connection to the route should be refused. print("Attempting new connection to dropped route...") new_conn_refused = False try: s = open_session(args) s.close() print(" unexpected: new connection succeeded") except Exception as e: new_conn_refused = True print(f" expected: new connection refused: {type(e).__name__}: {e}") # Existing clients should keep running for a few more seconds. time.sleep(5) stop.set() for t in threads: t.join(timeout=5) survivors = sum(1 for r in results if r["error"] is None and r["queries"] > 100) print(f"\nResult: {survivors}/{args.clients} clients survived the reload, " f"new connection refused: {new_conn_refused}") if survivors == args.clients and new_conn_refused: print("PASS") return 0 print("FAIL") return 1 def main(): args = parse_args() rc = 0 if args.scenario in ("sigterm", "all"): rc |= scenario_sigterm(args) if args.scenario in ("reload", "all"): rc |= scenario_reload(args) sys.exit(rc) if __name__ == "__main__": main()