mirror of https://github.com/sysown/proxysql
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
230 lines
7.4 KiB
230 lines
7.4 KiB
#!/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()
|