MySQL & MariaDB in Depth Module 15 – Applications, managed MySQL and Vitess
MySQL connections: drivers, pools and proxies
Connect to MySQL from Python, Node.js and Java with verified TLS and parameters; size a connection pool with Little's law; route reads through MySQL Router.
What you will learn
- Connect to MySQL from Python, Node.js and Java with parameterised SQL and verified TLS
- Size a connection pool with Little's law from the server's own max_connections
- Route reads to a replica with MySQL Router without breaking read-your-writes
- Choose between MySQL Router and ProxySQL from the shape of the application
Before you start
What you’ll learn · Before you start
What you will learn
- Connect to MySQL from Python, Node.js and Java with parameterised SQL and verified TLS
- Size a connection pool with Little's law from the server's own max_connections
- Route reads to a replica with MySQL Router without breaking read-your-writes
- Choose between MySQL Router and ProxySQL from the shape of the application
Before you start
On this page
A MySQL server runs each connection in its own thread and takes 151 client connections at once by default, keeping one further slot for an administrator. An application that opens a new connection per request, with no pool and no timeout, burns through those slots in seconds and leaves the next arrivals waiting.
This page connects to a throwaway MySQL 8.4.11 server from Python 3.14, Node.js 24 and
Java 25 with parameterised SQL and TLS verified against the server’s own CA; sizes a
connection pool: A small set of open MySQL connections an application borrows from and releases to for each request, so a new TCP+TLS handshake does not happen per request. Its size is set by Little's law and the server's max_connections. with Little’s law from the server’s
own max_connections; sends reads through MySQL Router (STATIC routing on
127.0.0.1); and leaves writes on the source. The lesson closes with two important
interview questions on drivers, pools and proxies.
Three drivers, one TLS story
A driver gives an application a parameterised client of the MySQL protocol, with
transparent TLS when the server offers it. The three drivers in this lesson all take
placeholders for values and verify a certificate the same way, and differ in how they
pool connections: Connector/J ships with a built-in pool, mysql2 adds a tiny promise pool,
and PyMySQL leaves pooling to the application. All three drivers read the password
from the environment, so it never appears on a command line, and all three accept the
server’s auto-generated CA for the TLS handshake. Connector/J would also take the
user and password inside its JDBC URL, but its manual asks for every character a URL
reserves — /, :, @, &, #, ?, a space — to be percent-encoded anywhere in
that URL, so a password is safer as a property than as URL text.
connect_session.py
"""Three drivers, one lab server, verified TLS. Python, Node.js and Java each open a
connection to a throwaway MySQL 8.4.11 server on 127.0.0.1, verify the server
certificate against the CA the server generated, run the same parameterised SELECT and
INSERT, and print what they got. The PyMySQL program also tries verifying the server's
host name (VERIFY_IDENTITY) and shows that MySQL's auto-generated certificate, whose
SAN does not list 127.0.0.1, is refused: production uses a certificate that names the
host and the check then passes."""
import subprocess
import sys
from pathlib import Path
from mysql_session import Server, toolchain
INSTALLS = Path(sys.executable).resolve().parents[3]
KEYTOOL = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin" / "keytool"
with Server() as my:
my.up()
# A tiny schema the three programs share: one user to look up, one target table for
# the INSERT. Nothing else of the server is involved.
my.sql("CREATE DATABASE shop")
my.sql("CREATE TABLE shop.app_user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(40) NOT NULL)")
my.sql("INSERT INTO shop.app_user (name) VALUES ('asha'), ('ravi'), ('kiran')")
my.sql("CREATE TABLE shop.order_item (id INT PRIMARY KEY AUTO_INCREMENT, order_id INT, sku VARCHAR(10), qty INT)",
flags=("-t", "--skip-comments"))
# Connector/J needs a Java trust store. The session builds one from the server's own
# ca.pem with keytool before the Java program runs.
data = my.base / "data"
truststore = my.base / "truststore.jks"
print("$ keytool -importcert -alias mysql-ca -file data/ca.pem -keystore truststore.jks "
"-storepass changeit -noprompt")
subprocess.run(
[str(KEYTOOL), "-importcert", "-alias", "mysql-ca", "-file", str(data / "ca.pem"),
"-keystore", str(truststore), "-storepass", "changeit", "-noprompt"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
print("(certificate added to a new keystore)\n")
# One value changes between runs: the TLS cipher name differs across the three
# drivers' TLS libraries, so the session masks it. Everything else is stable --
# each program inserts one row, in this order, so the ids read 1, 2 and 3.
mask = ((r"TLS cipher negotiated: TLS_[A-Z0-9_]+", "TLS cipher negotiated: <TLS cipher>"),)
my.app("python3", "py_connect.py", mask=mask)
my.app("node", "node_connect.mjs", mask=mask)
my.app("java", "JavaConnect.java", mask=mask) Output
$ mysql -t -e "CREATE DATABASE shop"
$ mysql -t -e "CREATE TABLE shop.app_user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(40) NOT NULL)"
$ mysql -t -e "INSERT INTO shop.app_user (name) VALUES ('asha'), ('ravi'), ('kiran')"
$ mysql -t --skip-comments -e "CREATE TABLE shop.order_item (id INT PRIMARY KEY AUTO_INCREMENT, order_id INT, sku VARCHAR(10), qty INT)"
$ keytool -importcert -alias mysql-ca -file data/ca.pem -keystore truststore.jks -storepass changeit -noprompt
(certificate added to a new keystore)
$ python3 py_connect.py
server version: 8.4.11
TLS cipher negotiated: <TLS cipher>
parameterised SELECT: (1, 'asha')
parameterised INSERT ok; last id = 1
pymysql version: 1.2.3
VERIFY_IDENTITY: refused - IP address mismatch
$ node node_connect.mjs
server version: 8.4.11
TLS cipher negotiated: <TLS cipher>
parameterised SELECT: { id: 1, name: 'asha' }
parameterised INSERT ok; last id = 2
$ java JavaConnect.java
server version: 8.4.11
TLS cipher negotiated: <TLS cipher>
parameterised SELECT: id=1, name=asha
parameterised INSERT ok; last id = 3
Connector/J version: mysql-connector-j-26.7.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 connect_session.py
py_connect.py
"""One parameterised query from PyMySQL 1.2.3 against the lab server, over TLS verified
against the server's CA. The second attempt asks for VERIFY_IDENTITY (default
check_hostname): MySQL's auto-generated server certificate does not name 127.0.0.1 and
PyMySQL refuses it."""
import os
import pymysql
CA = os.environ["LAB_CA"]
def connect(check_hostname):
return pymysql.connect(
host=os.environ["LAB_HOST"], port=int(os.environ["LAB_PORT"]),
user=os.environ["LAB_USER"], password=os.environ["LAB_PASSWORD"],
database="shop",
ssl={"ca": CA, "check_hostname": check_hostname},
)
# VERIFY_CA: trust the server certificate because it chains to the CA we gave the driver.
conn = connect(check_hostname=False)
with conn.cursor() as cur:
cur.execute("SELECT @@version")
print("server version:", cur.fetchone()[0])
cur.execute("SHOW SESSION STATUS LIKE 'Ssl_cipher'")
print("TLS cipher negotiated:", cur.fetchone()[1])
cur.execute("SELECT id, name FROM app_user WHERE name = %s", ("asha",))
print("parameterised SELECT:", cur.fetchone())
cur.execute("INSERT INTO order_item (order_id, sku, qty) VALUES (%s, %s, %s)", (1, "T-01", 2))
conn.commit()
print("parameterised INSERT ok; last id =", cur.lastrowid)
conn.close()
print("pymysql version:", pymysql.VERSION_STRING)
# VERIFY_IDENTITY: also check the server's host name. The auto-generated certificate has
# no 127.0.0.1 in its SAN, so verification fails. In production the certificate names the
# host and this check passes.
try:
conn = connect(check_hostname=True)
conn.close()
print("VERIFY_IDENTITY: unexpectedly succeeded")
except Exception as e:
text = str(e)
reason = "IP address mismatch" if "IP address mismatch" in text else text[:80]
print("VERIFY_IDENTITY: refused -", reason) node_connect.mjs
// One parameterised query from mysql2 3.24.5 against the lab server, over TLS verified
// against the server's CA. The driver reads the CA pem and keeps rejectUnauthorized
// true; checkServerIdentity is overridden because MySQL's auto-generated certificate
// does not name 127.0.0.1 (CA verification stays on).
import { readFileSync } from 'node:fs';
import mysql from 'mysql2/promise';
const conn = await mysql.createConnection({
host: process.env.LAB_HOST,
port: Number(process.env.LAB_PORT),
user: process.env.LAB_USER,
password: process.env.LAB_PASSWORD,
database: 'shop',
ssl: {
ca: readFileSync(process.env.LAB_CA),
rejectUnauthorized: true,
checkServerIdentity: () => undefined,
},
});
const [[v]] = await conn.query('SELECT @@version AS version');
console.log('server version:', v.version);
const [[c]] = await conn.query("SHOW SESSION STATUS LIKE 'Ssl_cipher'");
console.log('TLS cipher negotiated:', c.Value);
const [[row]] = await conn.execute(
'SELECT id, name FROM app_user WHERE name = ?',
['asha'],
);
console.log('parameterised SELECT:', row);
const [res] = await conn.execute(
'INSERT INTO order_item (order_id, sku, qty) VALUES (?, ?, ?)',
[1, 'T-01', 2],
);
console.log('parameterised INSERT ok; last id =', res.insertId);
await conn.end(); JavaConnect.java
// One parameterised query from MySQL Connector/J 26.7.0 against the lab server, over
// TLS verified against the server's CA. The session hands the CA to keytool as a Java
// trust store before this program runs; the driver then verifies the chain
// (sslMode=VERIFY_CA). Java 25's single-file source launcher compiles and runs this
// file in one step: `java JavaConnect.java`.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Properties;
public class JavaConnect {
public static void main(String[] args) throws Exception {
String url = "jdbc:mysql://" + System.getenv("LAB_HOST") + ":"
+ System.getenv("LAB_PORT") + "/shop";
Properties p = new Properties();
p.setProperty("user", System.getenv("LAB_USER"));
p.setProperty("password", System.getenv("LAB_PASSWORD"));
p.setProperty("sslMode", "VERIFY_CA");
p.setProperty("trustCertificateKeyStoreUrl", "file:truststore.jks");
p.setProperty("trustCertificateKeyStorePassword", "changeit");
try (Connection c = DriverManager.getConnection(url, p)) {
try (Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT @@version")) {
while (rs.next()) System.out.println("server version: " + rs.getString(1));
}
try (Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SHOW SESSION STATUS LIKE 'Ssl_cipher'")) {
while (rs.next()) System.out.println("TLS cipher negotiated: " + rs.getString(2));
}
try (PreparedStatement ps = c.prepareStatement(
"SELECT id, name FROM app_user WHERE name = ?")) {
ps.setString(1, "asha");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println("parameterised SELECT: id=" + rs.getInt(1)
+ ", name=" + rs.getString(2));
}
}
}
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO order_item (order_id, sku, qty) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setInt(1, 1);
ps.setString(2, "T-01");
ps.setInt(3, 2);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
System.out.println("parameterised INSERT ok; last id = " + keys.getLong(1));
}
}
}
System.out.println("Connector/J version: "
+ c.getMetaData().getDriverVersion().split(" \\(")[0]);
}
}
} mysql_session.py
"""Throwaway MySQL 8.4.11 and MariaDB servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
MariaDB(version="12.3") (or "11.8") does the same with a MariaDB server: `mariadb-install-db` makes the data
directory, `mariadbd` serves it, and the session runs MariaDB's own programs (`mariadb`, `mariadb-admin`,
`mariadb-dump`, `mariadb-binlog`, `mariadb-backup`), which is how the printed commands name them. They are the
Homebrew macOS builds of MariaDB 12.3.3 and 11.8.9 (MariaDB publishes no macOS build of its own), unpacked in a
toolchains folder above this one (toolchain()). A session may run a MySQL and a MariaDB server side by side.
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder, the MySQL installation and the unpacked toolchains, and they connect to nothing
but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
# MariaDB: "2026-10-20 0:56:56 0 [ERROR] ..." -> "[ERROR] ..."
MARIADB_LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\d +\d{1,2}:\d\d:\d\d \d+ ", re.M)
# The unpacked toolchains the sessions use (toolchain()): the MariaDB builds and the three drivers.
TOOLCHAINS = ("mariadb-12.3.3", "pymysql-1.2.3", "node-mysql2-3.24.5", "mysql-connector-j-26.7.0")
def toolchain(name):
"""The unpacked toolchain <name>, one of TOOLCHAINS. They all come from one folder: the first folder called
toolchains that holds every one of TOOLCHAINS, looked for above the folder the session runs in and above this
file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
seen = set()
for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
for parent in (start, *start.parents):
if parent in seen or parent == home or parent == Path(parent.anchor):
break
seen.add(parent)
scratch = parent / "tmp"
for folder in (parent, *(sorted(scratch.iterdir()) if scratch.is_dir() else ())):
found = folder / "toolchains"
if all((found / each).is_dir() for each in TOOLCHAINS):
return found / name
sys.exit(f"no toolchains folder holds all of {', '.join(TOOLCHAINS)}")
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
SERVER, CLIENT, ADMIN = "mysqld", "mysql", "mysqladmin" # the programs; MariaDB names its own
LOG_RE = LOG_PREFIX
def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.label = label # a stable name for this server in the record ("source", "replica")
self.port_label = port_label # the text that shows in place of this server's port
self.uuid = None
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / self.SERVER).exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
# mariadb-install-db (a shell script) calls chown, which lives in /usr/sbin; a run's PATH may hold only
# python's bin, /usr/bin and /bin, so add the system sbin folders when they are missing. These hold
# standard OS tools, nothing of this computer's own, and the session never prints the PATH.
parts = self.env.get("PATH", "/usr/bin:/bin").split(os.pathsep)
for d in ("/usr/sbin", "/sbin"):
if d not in parts:
parts.append(d)
self.env["PATH"] = os.pathsep.join(parts)
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / self.SERVER), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
*self.listener_options(), *self.options, *[str(e) for e in extra]]
if show:
shown = [self.SERVER, opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", *self.listener_options(), *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(self.LOG_RE.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def listener_options(self):
"""The socket inside the run folder and, for MySQL, the X Protocol listener switched off."""
return ["--socket=mysql.sock", "--mysqlx=OFF"]
def app(self, program, script, *args, status=0, env=None, port=None, mask=(), note=None, timeout=100, show=True,
during=None):
"""Runs an application program of the examples folder against this server and prints it as a shell would.
program: "python3" (PyMySQL 1.2.3), "node" (mysql2 3.24.5) or "java" (Connector/J 26.7.0, the single-file
source launcher: `java Demo.java`). The program finds the server in the environment variables LAB_HOST
(127.0.0.1), LAB_PORT (printed as <port>), LAB_USER (root unless env says otherwise), LAB_PASSWORD (the run's
password: a program never prints it, and the record would show <password> if it did) and LAB_CA (the server's
auto-generated ca.pem, for verified TLS); env adds or replaces variables, port points it at another server of
the run (a proxy, a replica). It runs in the run folder, beside the session's files; its output is cleaned like
every program's, with mask=((pattern, replacement), ...) for values that change between runs. during, a
function of no arguments, runs unprinted while the program runs."""
tools = {"python3": None, "node": toolchain("node-mysql2-3.24.5"), "java": toolchain("mysql-connector-j-26.7.0")}
if program not in tools:
sys.exit(f"app() does not run {program}")
source = Path(script)
if not source.exists():
sys.exit(f"{script} is not in the examples folder")
shutil.copy(source, self.base / source.name)
ca = self.base / "data" / "ca.pem"
merged = {**self.env, "LAB_HOST": "127.0.0.1", "LAB_PORT": str(port or self.port), "LAB_USER": "root",
"LAB_PASSWORD": self.password, "LAB_CA": str(ca) if ca.exists() else "", **dict(env or {})}
if program == "python3":
command = [sys.executable, source.name]
merged.update({"PYTHONPATH": str(toolchain("pymysql-1.2.3") / "site"), "PYTHONNOUSERSITE": "1",
"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"})
elif program == "node":
node = INSTALLS / "node" / "24.21.0" / "bin" / "node"
if not node.exists():
sys.exit("Node.js 24.21.0 was not found next to this Python")
# Node resolves import and require from a node_modules folder beside the program (NODE_PATH does not
# reach ES modules), so the run folder gets a link to the installed driver.
link = self.base / "node_modules"
if not link.exists():
link.symlink_to(tools["node"] / "node_modules", target_is_directory=True)
command = [str(node), source.name]
else:
java = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin" / "java"
if not java.exists():
sys.exit("Java 25 (temurin-25.0.4+101.0.LTS) was not found next to this Python")
# No performance-data file and no temporary file outside the run folder (macOS gives Java its own
# temporary folder otherwise), and the home folder is the run folder.
command = [str(java), "-XX:-UsePerfData", f"-Djava.io.tmpdir={self.base / 'tmp'}",
f"-Duser.home={self.base}", "-cp", str(tools["java"] / "mysql-connector-j-26.7.0.jar"),
source.name]
command += [str(a) for a in args]
if note:
print(f"-- {note}")
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=self.base,
env=merged)
try:
if during is not None:
during()
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
sys.exit(f"{script} did not finish in {timeout} s")
except BaseException:
proc.kill()
proc.communicate()
raise
# A program's traceback names the files it ran: the drivers' folder and the language's installation are shown
# as <toolchains> and <installs>, so a record never shows this computer's folders.
raw = output.replace(self.password, "<password>")
raw = raw.replace(str(tools["java"].parent if tools["java"] else toolchain("pymysql-1.2.3").parent),
"<toolchains>").replace(str(INSTALLS), "<installs>")
out = self.clean(raw)
for pattern, replacement in mask:
out = re.sub(pattern, replacement, out)
if show:
_show([program, source.name, *args], out)
if proc.returncode != status:
if not show:
print(out)
sys.exit(f"{script} ended with status {proc.returncode}, not {status}")
return out
def _ping(self):
code, _ = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it. If this server has a label, its random server_uuid is masked to a
stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
so a replicated ALTER USER would lock the session out of the replica)."""
sro = self.query("SELECT @@global.super_read_only") == "1"
if sro:
self.query("SET GLOBAL super_read_only=OFF")
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
if sro:
self.query("SET GLOBAL super_read_only=ON")
if self.label:
self.uuid = self.query("SELECT @@server_uuid")
self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True, clean=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
is text for the record only and MySQL would reject it."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, (self.clean(done.stdout) if clean else done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text) # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(self.LOG_RE.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
that goes into the record."""
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql, clean=False)
if code != 0:
sys.exit(f"the query failed: {self.clean(out)}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = [self.CLIENT, *flags, *(["--force"] if force else []), *([db] if db else [])]
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = [self.CLIENT, *flags, *([db] if db else []), "-e", statement]
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
# ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
def gtid_executed(self, raw=False):
"""This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
return value if raw else self.clean(value)
def track(self, other):
"""Teach this server's record to mask another server's port and server_uuid, so a statement that names the
other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
if other.port:
self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
if other.uuid:
self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
self.mask(re.escape(str(other.base)), ".")
def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
"""Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
not into the run folder.
VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
self.track(source)
opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
f"SOURCE_PASSWORD='{password}'"]
if auto_position:
opts.append("SOURCE_AUTO_POSITION=1")
if ssl:
opts.append("SOURCE_SSL=1")
if verify_ca:
shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
opts.append("SOURCE_SSL_CA='source-ca.pem'")
if delay is not None:
opts.append(f"SOURCE_DELAY={delay}")
opts += [str(e) for e in extra]
stmt = "CHANGE REPLICATION SOURCE TO\n " + ",\n ".join(opts)
if channel:
stmt += f"\n FOR CHANNEL '{channel}'"
stmt += ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if run:
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def start_replica(self, user=None, password=None, channel=None, show=True):
"""START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
parts = ["START REPLICA"]
if user:
parts.append(f"USER='{user}' PASSWORD='{password}'")
if channel:
parts.append(f"FOR CHANNEL '{channel}'")
stmt = " ".join(parts) + ";"
if show:
self.write_file("_start_replica.sql", stmt + "\n", show=False)
self.mysql("_start_replica.sql")
else:
self.query(stmt)
def stop_replica(self, channel=None, show=False):
stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
if show:
self.sql(stmt)
else:
self.query(stmt)
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
for _ in range(timeout * 10):
io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
if io == "ON" and applier == "ON":
return
time.sleep(0.1)
print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has executed every transaction the source had committed when called."""
gset = source.gtid_executed(raw=True)
if not gset:
return
result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")
def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
"""Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
if code != 0:
sys.exit(f"SHOW REPLICA STATUS failed: {out}")
values = {}
for line in out.splitlines():
m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
if m:
values[m.group(1)] = m.group(2)
if note:
print(f"-- {note}")
print(f"{self.CLIENT}> {label}\\G")
for field in fields:
if field in values:
print(f" {field}: {values[field]}")
print()
return values
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class MariaDB(Server):
"""One throwaway MariaDB server (12.3.3 by default, or version="11.8" for 11.8.9), used as Server is.
What differs from MySQL, and how the class handles it: mariadb-install-db makes the data directory, and root has no
password until login() gives it the run's (install-db's own messages name this computer's folders and are not
printed); the programs are mariadbd, mariadb and mariadb-admin (the mysql* names are links that MariaDB calls
deprecated); there is no X Protocol, no super_read_only and no server_uuid; the error log's time stamps have
another shape; and the server is told where its plugins, messages and character sets are, because the Homebrew
build would otherwise look for them under /opt/homebrew. Its socket is mariadb.sock in the data directory (a
relative path: MariaDB refuses a socket path longer than 103 characters), and clients connect over 127.0.0.1."""
SERVER, CLIENT, ADMIN = "mariadbd", "mariadb", "mariadb-admin"
LOG_RE = MARIADB_LOG_PREFIX
def __init__(self, version="12.3", options=(), masks=(), label=None, port_label=None):
super().__init__(options=options, basedir=toolchain("mariadb-12.3.3") / "opt" / f"mariadb@{version}",
masks=masks, label=label, port_label=port_label)
self.version = version
def server_options(self):
b, home = self.base, self.basedir
return ["--no-defaults", f"--basedir={home}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}", f"--plugin-dir={home / 'lib' / 'plugin'}",
f"--lc-messages-dir={home / 'share' / 'mysql'}",
f"--character-sets-dir={home / 'share' / 'mysql' / 'charsets'}"]
def listener_options(self):
return ["--socket=mariadb.sock"]
def initialize(self, show=False):
"""mariadb-install-db: a new data directory whose root@localhost account has no password yet."""
args = ["mariadb-install-db", *self.server_options(), "--auth-root-authentication-method=normal",
"--skip-test-db", f"--user={OS_USER}"]
code, out = self._call(args)
if show:
_show(["mariadb-install-db", "--no-defaults", "--datadir=./data", "--auth-root-authentication-method=normal",
"--skip-test-db"], "")
if code != 0:
print(out)
sys.exit(f"mariadb-install-db ended with status {code}")
def login(self):
"""Gives root the run's password (never printed), with sql_log_bin off for that one statement, as
Server.login() does and for the same reasons; a labelled server masks nothing here (MariaDB has no server_uuid:
its GTIDs name the domain and the server_id instead)."""
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
# ---- MariaDB GTIDs and replication (two or three servers on 127.0.0.1) -------------------------------------------
def gtid_pos(self, which="gtid_binlog_pos"):
"""@@global.gtid_binlog_pos (what this server wrote to its binary log), gtid_slave_pos (what it applied as a
replica) or gtid_current_pos, as one line."""
return self.query(f"SELECT @@global.{which}").replace("\n", "")
def change_master(self, source, user="repl", password="replpass", use_gtid="slave_pos", extra=(), show=True,
connection=None):
"""Builds and runs MariaDB's change-source statement on this replica, pointing at `source` on 127.0.0.1.
MariaDB keeps the statement's legacy name (in the code below; the lessons say source and replica in prose).
use_gtid: slave_pos, current_pos, or None for file-and-position replication (then give MASTER_LOG_FILE and
MASTER_LOG_POS in extra). connection names a multi-source connection."""
self.track(source)
opts = ["MASTER_HOST='127.0.0.1'", f"MASTER_PORT={source.port}", f"MASTER_USER='{user}'",
f"MASTER_PASSWORD='{password}'"]
if use_gtid:
opts.append(f"MASTER_USE_GTID={use_gtid}")
opts += [str(e) for e in extra]
head = "CHANGE MASTER " + (f"'{connection}' " if connection else "") + "TO" # learn-lint-allow inclusive: MariaDB's statement name
stmt = head + "\n " + ",\n ".join(opts) + ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads say Yes in SHOW REPLICA STATUS (performance_schema is off by default
on MariaDB, so its replication tables are not used)."""
stmt = "SHOW REPLICA STATUS" if not channel else f"SHOW REPLICA '{channel}' STATUS"
for _ in range(timeout * 10):
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt], clean=False)
io = re.search(r"(?:Slave|Replica)_IO_Running: (\w+)", out) # learn-lint-allow inclusive: MariaDB's own column names (this line and the next)
sql_ = re.search(r"(?:Slave|Replica)_SQL_Running: (\w+)", out)
if io and sql_ and io.group(1) == "Yes" and sql_.group(1) == "Yes":
return
time.sleep(0.1)
print(self.clean(out))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has applied everything the source had written to its binary log when called
(MASTER_GTID_WAIT on the source's gtid_binlog_pos)."""
pos = source.gtid_pos("gtid_binlog_pos")
if not pos:
return
result = self.query(f"SELECT MASTER_GTID_WAIT('{pos}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (MASTER_GTID_WAIT returned {result})") Each program prints the same four lines: the server version (8.4.11), the TLS
cipher the connection negotiated (masked here, because its name differs across TLS
libraries), one row from a parameterised SELECT, and the auto-generated id of a
parameterised INSERT. The Python attempt at VERIFY_IDENTITY is refused with “IP
address mismatch”: MySQL’s --initialize-insecure certificate names
MySQL_Server_8.4.11_Auto_Generated_Server_Certificate, not 127.0.0.1, so the
driver cannot match the host it connected to. A real deployment issues a certificate
whose Subject Alternative Name includes the server’s host, and VERIFY_IDENTITY then
passes; the drivers’ own pages note the same.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A request begins. The application thread calls execute(sql, params). It has not touched the network yet.
- Borrow one connection. The driver picks an idle connection from the pool. On a cold pool it opens a new one and does the TLS handshake first.
- Send on the wire. The driver sends the SQL and the bound values over the TLS tunnel. The password never leaves the driver.
- Run on a server thread. The server runs the statement in the connection's own thread. One request has one slot of max_connections while it runs.
- Reply and release. The server returns the rows. The driver releases the connection back to the pool, where the next request can borrow it.
The scene follows one request from an application thread, through the pool and the driver, out to the server on a TLS tunnel, and back to the pool. The server runs the statement in the connection’s own thread and sends the rows back; the driver releases the slot, and the next request can borrow it.
Checkpoint
Check: Three drivers, one TLS story
Parameters, not strings
A prepared statement: A SQL text with placeholders (%s in PyMySQL, ? in mysql2 and Connector/J) that the driver sends with the values on each call, so user input stays a value and never becomes SQL. Drivers may prepare the statement on the server once or bind values client-side. is a SQL text with
placeholders (%s in PyMySQL, ? in mysql2 and Connector/J) that the driver sends
with the values on each call. The three drivers prepare it in different places:
mysql2’s execute() prepares the statement on the server and keeps it cached on the
connection, while PyMySQL escapes each value into a literal of its own type (a quoted
string, a bare number) in the SQL it sends, and Connector/J does the same until
useServerPrepStmts=true asks for a server-side prepare. Either way, user input never
becomes SQL. The OWASP SQL
Injection Prevention Cheat Sheet names parameters as the first and best defence
against injection; the same page lists input validation and least privilege as
defences in depth, not replacements for parameters.
The example loads a tiny app_user table with three names, runs the same hostile
value through a find_user() function in two tiny programs, and asserts the fix with
a check script that fails on the weak version and passes on the fix:
inject_session.py
"""Parameters, not strings: the same hostile value put into two tiny apps.
The weak app (inject_bad.py) builds the WHERE clause by appending the name to a SQL
string, so a value with a quote in it closes the string literal early and the rest is
read as SQL. The fix (inject_safe.py) passes the value as a parameter: PyMySQL escapes
it into one quoted string literal, so the server compares it as a VARCHAR whatever its
contents.
The second half of the session is a check: a function imported from each module must
match zero rows for the hostile value and exactly one row for 'asha'. It passes against
inject_safe and fails against inject_bad with status 1 (the OWASP SQL Injection
Prevention Cheat Sheet's defensive framing: catch the weak version with a check that
also proves the fix works)."""
from mysql_session import Server
HOSTILE = "' OR '1'='1"
with Server() as my:
my.up()
my.sql("CREATE DATABASE shop")
my.sql("CREATE TABLE shop.app_user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(40) NOT NULL)")
my.sql("INSERT INTO shop.app_user (name) VALUES ('asha'), ('ravi'), ('kiran')")
# One hostile call against each program.
print(f"-- hostile input: {HOSTILE!r}")
my.app("python3", "inject_bad.py", HOSTILE,
mask=((r"rows returned: 3", "rows returned: 3 <- WRONG: every row came back for a name nobody has"),))
my.app("python3", "inject_safe.py", HOSTILE,
mask=((r"rows returned: 0", "rows returned: 0 <- the parameter stays a value"),))
# The check: PASS on the fix, FAIL on the weak sample.
my.app("python3", "check_find_user.py", "inject_safe", status=0)
my.app("python3", "check_find_user.py", "inject_bad", status=1) Output
$ mysql -t -e "CREATE DATABASE shop"
$ mysql -t -e "CREATE TABLE shop.app_user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(40) NOT NULL)"
$ mysql -t -e "INSERT INTO shop.app_user (name) VALUES ('asha'), ('ravi'), ('kiran')"
-- hostile input: "' OR '1'='1"
$ python3 inject_bad.py "' OR '1'='1"
rows returned: 3 <- WRONG: every row came back for a name nobody has
$ python3 inject_safe.py "' OR '1'='1"
rows returned: 0 <- the parameter stays a value
$ python3 check_find_user.py inject_safe
PASS [inject_safe]: hostile value matched 0 rows and 'asha' matched 1 row
$ python3 check_find_user.py inject_bad
FAIL [inject_bad]: a hostile value matched 3 rows (expected 0)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 inject_session.py
inject_bad.py
"""The weak sample. It builds SQL from a string, so a value containing a quote can close
the quoted literal and change the shape of the query. Fix it by passing the value as a
parameter (see inject_safe.py)."""
import os
import pymysql
def connect():
return pymysql.connect(
host=os.environ["LAB_HOST"], port=int(os.environ["LAB_PORT"]),
user=os.environ["LAB_USER"], password=os.environ["LAB_PASSWORD"],
database="shop",
ssl={"ca": os.environ["LAB_CA"], "check_hostname": False},
)
def find_user(name):
with connect() as conn, conn.cursor() as cur:
sql = f"SELECT id, name FROM app_user WHERE name = '{name}'"
cur.execute(sql)
return cur.fetchall()
if __name__ == "__main__":
import sys
rows = find_user(sys.argv[1])
print(f"rows returned: {len(rows)}") inject_safe.py
"""The fix. The value is passed as a parameter, and PyMySQL escapes it into one quoted string literal, so it
never becomes SQL: a hostile value matches no row rather than changing the query."""
import os
import pymysql
def connect():
return pymysql.connect(
host=os.environ["LAB_HOST"], port=int(os.environ["LAB_PORT"]),
user=os.environ["LAB_USER"], password=os.environ["LAB_PASSWORD"],
database="shop",
ssl={"ca": os.environ["LAB_CA"], "check_hostname": False},
)
def find_user(name):
with connect() as conn, conn.cursor() as cur:
cur.execute("SELECT id, name FROM app_user WHERE name = %s", (name,))
return cur.fetchall()
if __name__ == "__main__":
import sys
rows = find_user(sys.argv[1])
print(f"rows returned: {len(rows)}") check_find_user.py
"""A check that two conditions hold against a find_user(name) function imported from
sys.argv[1]: a value crafted to end a SQL string literal must match no row, and the
plain user name 'asha' must match exactly one. On inject_safe it passes; on inject_bad
it fails with the exit status the pre-commit runner reads."""
import importlib
import sys
mod = importlib.import_module(sys.argv[1])
HOSTILE = "' OR '1'='1"
PLAIN = "asha"
got_hostile = len(mod.find_user(HOSTILE))
got_plain = len(mod.find_user(PLAIN))
problems = []
if got_hostile != 0:
problems.append(f"a hostile value matched {got_hostile} rows (expected 0)")
if got_plain != 1:
problems.append(f"'asha' matched {got_plain} rows (expected 1)")
if problems:
print(f"FAIL [{sys.argv[1]}]: " + "; ".join(problems))
sys.exit(1)
print(f"PASS [{sys.argv[1]}]: hostile value matched 0 rows and 'asha' matched 1 row") The weak program builds the WHERE clause as a Python f-string, so a value with a
quote in it (' OR '1'='1) closes the string literal early and the OR branch matches
every row: three rows come back. The fix passes the value through %s, and mysql2
and Connector/J use ? the same way; the server treats the hostile text as the whole
VARCHAR to compare, no row has that name, and zero rows come back. The check_find_user.py
script asserts both conditions (hostile matches 0 rows; 'asha' matches 1) and uses
the exit status that a pre-commit runner reads: it exits 1 on the weak sample, so a
build gate fails, and 0 on the fix, so the build goes on.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- One input, two paths. A single value, "' OR '1'='1", arrives at two tiny programs that both ask the same question: which user has this name?
- The weak path appends it. The weak program writes "SELECT ... WHERE name = '" + value + "'". The quote inside the value closes the string literal early.
- The server reads a new query. The server receives WHERE name = '' OR '1'='1'. The OR branch matches every row, so every user comes back.
- The fix sends it as a value. The fix passes the value as a parameter. PyMySQL escapes it into one quoted string, so the quote inside it stays text and never ends the literal.
- The server reads a plain VARCHAR. The server compares the column against the whole hostile text as a VARCHAR. No row has that name, so the result is empty.
The figure shows what the server receives in each path: changed SQL in the weak path, a plain VARCHAR value in the fix. Input validation (an allow-list on the shape of a name) is still worth adding on top, because a bound value can still be a legal name that the application has no reason to accept; and least privilege on the account the application connects as caps the blast radius if a different kind of mistake lands.
Catching the weak form with a check
A grep for f"SELECT ... WHERE ... = '{...}'" or for string concatenation around
cursor.execute is a start, but a value hidden behind a helper slips through. A
real check runs the function with a value that would change the shape of the SQL
and asserts the row count stays the same; the same test fails on the weak sample
and passes on the fix, as the OWASP Cheat Sheet asks.
Checkpoint
Check: Parameters, not strings
Sizing a connection pool with Little’s law
A pool holds a small set of open connections and hands one to every request as it
starts. Little’s law gives its size: the connections busy at any moment on average
equal the request rate times the time each request keeps its connection
(busy = rate * T_db). Pools get their size from arithmetic, not habit. Different
workloads need pools of different sizes, and the sum of every application’s pool must
stay well below the server’s max_connections so a routine admin task still has a
slot.
The example reads max_connections and wait_timeout from the lab and prints a
small table of pool sizes at two target utilisations:
pool_math_session.py
"""Pool arithmetic by Little's law, from the server's own max_connections and the
request-rate and time-in-database each workload gets. The program reads the two
server defaults (max_connections, wait_timeout) off the lab and prints a small table
of pool sizes at two target utilisations, plus a sanity check: the sum of every
application's pool must stay well below the server's limit, with headroom for
replication, backups and the DBA."""
from mysql_session import Server
with Server() as my:
my.up()
my.app("python3", "pool_math.py") Output
$ python3 pool_math.py
server: max_connections = 151, wait_timeout = 28800 s
Little's law: busy = rate * T_db / 1000; size = ceil(busy / target_use)
workload rate T_db busy at 70% use at 50% use
rps ms conns size size
fast reads 300 5 1.5 3 3
mixed 150 20 3.0 5 6
slow 40 80 3.2 5 7
total open = instances * pool size + 10 (admin reserve)
instances pool size total <=151
3 10 40 fits
3 20 70 fits
3 30 100 fits
5 10 60 fits
5 20 110 fits
5 30 160 OVER the limit
10 10 110 fits
10 20 210 OVER the limit
10 30 310 OVER the limit
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_math_session.py
pool_math.py
"""Pool arithmetic for a MySQL application. The server defaults (max_connections,
wait_timeout) are read from the lab; the request rate and T_db for each workload are
the lesson's own numbers. Little's law: the connections busy at any moment on average
equal the request rate times the time each request holds its connection."""
import math
import os
import pymysql
conn = pymysql.connect(
host=os.environ["LAB_HOST"], port=int(os.environ["LAB_PORT"]),
user=os.environ["LAB_USER"], password=os.environ["LAB_PASSWORD"],
ssl={"ca": os.environ["LAB_CA"], "check_hostname": False},
)
with conn.cursor() as cur:
cur.execute("SELECT @@global.max_connections, @@global.wait_timeout")
max_c, wait = cur.fetchone()
conn.close()
print(f"server: max_connections = {max_c}, wait_timeout = {wait} s")
print()
# Three workloads, each with its own rate and time-in-database. Picked from the lesson's
# text; a real application would measure its own T_db (median under load) with the
# driver's query timing.
cases = [
("fast reads", 300, 5),
("mixed", 150, 20),
("slow", 40, 80),
]
print("Little's law: busy = rate * T_db / 1000; size = ceil(busy / target_use)")
print()
print(f"{'workload':<12} {'rate':>5} {'T_db':>5} {'busy':>5} "
f"{'at 70% use':>10} {'at 50% use':>10}")
print(f"{'':<12} {'rps':>5} {'ms':>5} {'conns':>5} "
f"{'size':>10} {'size':>10}")
for name, rate, t_db in cases:
busy = rate * t_db / 1000.0
size_70 = math.ceil(busy / 0.70)
size_50 = math.ceil(busy / 0.50)
print(f"{name:<12} {rate:>5} {t_db:>5} {busy:>5.1f} "
f"{size_70:>10} {size_50:>10}")
print()
# Each application instance has its own pool, so the server's limit is shared.
# A quick sanity table: total open connections = instances * pool size + 10 for
# admin tasks (replication, backups, the DBA's own client).
reserve = 10
print(f"total open = instances * pool size + {reserve} (admin reserve)")
print()
print(f"{'instances':>10} {'pool size':>10} {'total':>7} {'<=' + str(max_c):>12}")
for instances in (3, 5, 10):
for size in (10, 20, 30):
total = instances * size + reserve
verdict = "fits" if total <= max_c else "OVER the limit"
print(f"{instances:>10} {size:>10} {total:>7} {verdict:>12}") mysql_session.py
"""Throwaway MySQL 8.4.11 and MariaDB servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
MariaDB(version="12.3") (or "11.8") does the same with a MariaDB server: `mariadb-install-db` makes the data
directory, `mariadbd` serves it, and the session runs MariaDB's own programs (`mariadb`, `mariadb-admin`,
`mariadb-dump`, `mariadb-binlog`, `mariadb-backup`), which is how the printed commands name them. They are the
Homebrew macOS builds of MariaDB 12.3.3 and 11.8.9 (MariaDB publishes no macOS build of its own), unpacked in a
toolchains folder above this one (toolchain()). A session may run a MySQL and a MariaDB server side by side.
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder, the MySQL installation and the unpacked toolchains, and they connect to nothing
but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
# MariaDB: "2026-10-20 0:56:56 0 [ERROR] ..." -> "[ERROR] ..."
MARIADB_LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\d +\d{1,2}:\d\d:\d\d \d+ ", re.M)
# The unpacked toolchains the sessions use (toolchain()): the MariaDB builds and the three drivers.
TOOLCHAINS = ("mariadb-12.3.3", "pymysql-1.2.3", "node-mysql2-3.24.5", "mysql-connector-j-26.7.0")
def toolchain(name):
"""The unpacked toolchain <name>, one of TOOLCHAINS. They all come from one folder: the first folder called
toolchains that holds every one of TOOLCHAINS, looked for above the folder the session runs in and above this
file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
seen = set()
for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
for parent in (start, *start.parents):
if parent in seen or parent == home or parent == Path(parent.anchor):
break
seen.add(parent)
scratch = parent / "tmp"
for folder in (parent, *(sorted(scratch.iterdir()) if scratch.is_dir() else ())):
found = folder / "toolchains"
if all((found / each).is_dir() for each in TOOLCHAINS):
return found / name
sys.exit(f"no toolchains folder holds all of {', '.join(TOOLCHAINS)}")
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
SERVER, CLIENT, ADMIN = "mysqld", "mysql", "mysqladmin" # the programs; MariaDB names its own
LOG_RE = LOG_PREFIX
def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.label = label # a stable name for this server in the record ("source", "replica")
self.port_label = port_label # the text that shows in place of this server's port
self.uuid = None
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / self.SERVER).exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
# mariadb-install-db (a shell script) calls chown, which lives in /usr/sbin; a run's PATH may hold only
# python's bin, /usr/bin and /bin, so add the system sbin folders when they are missing. These hold
# standard OS tools, nothing of this computer's own, and the session never prints the PATH.
parts = self.env.get("PATH", "/usr/bin:/bin").split(os.pathsep)
for d in ("/usr/sbin", "/sbin"):
if d not in parts:
parts.append(d)
self.env["PATH"] = os.pathsep.join(parts)
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / self.SERVER), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
*self.listener_options(), *self.options, *[str(e) for e in extra]]
if show:
shown = [self.SERVER, opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", *self.listener_options(), *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(self.LOG_RE.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def listener_options(self):
"""The socket inside the run folder and, for MySQL, the X Protocol listener switched off."""
return ["--socket=mysql.sock", "--mysqlx=OFF"]
def app(self, program, script, *args, status=0, env=None, port=None, mask=(), note=None, timeout=100, show=True,
during=None):
"""Runs an application program of the examples folder against this server and prints it as a shell would.
program: "python3" (PyMySQL 1.2.3), "node" (mysql2 3.24.5) or "java" (Connector/J 26.7.0, the single-file
source launcher: `java Demo.java`). The program finds the server in the environment variables LAB_HOST
(127.0.0.1), LAB_PORT (printed as <port>), LAB_USER (root unless env says otherwise), LAB_PASSWORD (the run's
password: a program never prints it, and the record would show <password> if it did) and LAB_CA (the server's
auto-generated ca.pem, for verified TLS); env adds or replaces variables, port points it at another server of
the run (a proxy, a replica). It runs in the run folder, beside the session's files; its output is cleaned like
every program's, with mask=((pattern, replacement), ...) for values that change between runs. during, a
function of no arguments, runs unprinted while the program runs."""
tools = {"python3": None, "node": toolchain("node-mysql2-3.24.5"), "java": toolchain("mysql-connector-j-26.7.0")}
if program not in tools:
sys.exit(f"app() does not run {program}")
source = Path(script)
if not source.exists():
sys.exit(f"{script} is not in the examples folder")
shutil.copy(source, self.base / source.name)
ca = self.base / "data" / "ca.pem"
merged = {**self.env, "LAB_HOST": "127.0.0.1", "LAB_PORT": str(port or self.port), "LAB_USER": "root",
"LAB_PASSWORD": self.password, "LAB_CA": str(ca) if ca.exists() else "", **dict(env or {})}
if program == "python3":
command = [sys.executable, source.name]
merged.update({"PYTHONPATH": str(toolchain("pymysql-1.2.3") / "site"), "PYTHONNOUSERSITE": "1",
"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"})
elif program == "node":
node = INSTALLS / "node" / "24.21.0" / "bin" / "node"
if not node.exists():
sys.exit("Node.js 24.21.0 was not found next to this Python")
# Node resolves import and require from a node_modules folder beside the program (NODE_PATH does not
# reach ES modules), so the run folder gets a link to the installed driver.
link = self.base / "node_modules"
if not link.exists():
link.symlink_to(tools["node"] / "node_modules", target_is_directory=True)
command = [str(node), source.name]
else:
java = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin" / "java"
if not java.exists():
sys.exit("Java 25 (temurin-25.0.4+101.0.LTS) was not found next to this Python")
# No performance-data file and no temporary file outside the run folder (macOS gives Java its own
# temporary folder otherwise), and the home folder is the run folder.
command = [str(java), "-XX:-UsePerfData", f"-Djava.io.tmpdir={self.base / 'tmp'}",
f"-Duser.home={self.base}", "-cp", str(tools["java"] / "mysql-connector-j-26.7.0.jar"),
source.name]
command += [str(a) for a in args]
if note:
print(f"-- {note}")
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=self.base,
env=merged)
try:
if during is not None:
during()
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
sys.exit(f"{script} did not finish in {timeout} s")
except BaseException:
proc.kill()
proc.communicate()
raise
# A program's traceback names the files it ran: the drivers' folder and the language's installation are shown
# as <toolchains> and <installs>, so a record never shows this computer's folders.
raw = output.replace(self.password, "<password>")
raw = raw.replace(str(tools["java"].parent if tools["java"] else toolchain("pymysql-1.2.3").parent),
"<toolchains>").replace(str(INSTALLS), "<installs>")
out = self.clean(raw)
for pattern, replacement in mask:
out = re.sub(pattern, replacement, out)
if show:
_show([program, source.name, *args], out)
if proc.returncode != status:
if not show:
print(out)
sys.exit(f"{script} ended with status {proc.returncode}, not {status}")
return out
def _ping(self):
code, _ = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it. If this server has a label, its random server_uuid is masked to a
stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
so a replicated ALTER USER would lock the session out of the replica)."""
sro = self.query("SELECT @@global.super_read_only") == "1"
if sro:
self.query("SET GLOBAL super_read_only=OFF")
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
if sro:
self.query("SET GLOBAL super_read_only=ON")
if self.label:
self.uuid = self.query("SELECT @@server_uuid")
self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True, clean=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
is text for the record only and MySQL would reject it."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, (self.clean(done.stdout) if clean else done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text) # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(self.LOG_RE.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
that goes into the record."""
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql, clean=False)
if code != 0:
sys.exit(f"the query failed: {self.clean(out)}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = [self.CLIENT, *flags, *(["--force"] if force else []), *([db] if db else [])]
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = [self.CLIENT, *flags, *([db] if db else []), "-e", statement]
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
# ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
def gtid_executed(self, raw=False):
"""This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
return value if raw else self.clean(value)
def track(self, other):
"""Teach this server's record to mask another server's port and server_uuid, so a statement that names the
other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
if other.port:
self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
if other.uuid:
self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
self.mask(re.escape(str(other.base)), ".")
def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
"""Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
not into the run folder.
VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
self.track(source)
opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
f"SOURCE_PASSWORD='{password}'"]
if auto_position:
opts.append("SOURCE_AUTO_POSITION=1")
if ssl:
opts.append("SOURCE_SSL=1")
if verify_ca:
shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
opts.append("SOURCE_SSL_CA='source-ca.pem'")
if delay is not None:
opts.append(f"SOURCE_DELAY={delay}")
opts += [str(e) for e in extra]
stmt = "CHANGE REPLICATION SOURCE TO\n " + ",\n ".join(opts)
if channel:
stmt += f"\n FOR CHANNEL '{channel}'"
stmt += ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if run:
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def start_replica(self, user=None, password=None, channel=None, show=True):
"""START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
parts = ["START REPLICA"]
if user:
parts.append(f"USER='{user}' PASSWORD='{password}'")
if channel:
parts.append(f"FOR CHANNEL '{channel}'")
stmt = " ".join(parts) + ";"
if show:
self.write_file("_start_replica.sql", stmt + "\n", show=False)
self.mysql("_start_replica.sql")
else:
self.query(stmt)
def stop_replica(self, channel=None, show=False):
stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
if show:
self.sql(stmt)
else:
self.query(stmt)
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
for _ in range(timeout * 10):
io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
if io == "ON" and applier == "ON":
return
time.sleep(0.1)
print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has executed every transaction the source had committed when called."""
gset = source.gtid_executed(raw=True)
if not gset:
return
result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")
def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
"""Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
if code != 0:
sys.exit(f"SHOW REPLICA STATUS failed: {out}")
values = {}
for line in out.splitlines():
m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
if m:
values[m.group(1)] = m.group(2)
if note:
print(f"-- {note}")
print(f"{self.CLIENT}> {label}\\G")
for field in fields:
if field in values:
print(f" {field}: {values[field]}")
print()
return values
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class MariaDB(Server):
"""One throwaway MariaDB server (12.3.3 by default, or version="11.8" for 11.8.9), used as Server is.
What differs from MySQL, and how the class handles it: mariadb-install-db makes the data directory, and root has no
password until login() gives it the run's (install-db's own messages name this computer's folders and are not
printed); the programs are mariadbd, mariadb and mariadb-admin (the mysql* names are links that MariaDB calls
deprecated); there is no X Protocol, no super_read_only and no server_uuid; the error log's time stamps have
another shape; and the server is told where its plugins, messages and character sets are, because the Homebrew
build would otherwise look for them under /opt/homebrew. Its socket is mariadb.sock in the data directory (a
relative path: MariaDB refuses a socket path longer than 103 characters), and clients connect over 127.0.0.1."""
SERVER, CLIENT, ADMIN = "mariadbd", "mariadb", "mariadb-admin"
LOG_RE = MARIADB_LOG_PREFIX
def __init__(self, version="12.3", options=(), masks=(), label=None, port_label=None):
super().__init__(options=options, basedir=toolchain("mariadb-12.3.3") / "opt" / f"mariadb@{version}",
masks=masks, label=label, port_label=port_label)
self.version = version
def server_options(self):
b, home = self.base, self.basedir
return ["--no-defaults", f"--basedir={home}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}", f"--plugin-dir={home / 'lib' / 'plugin'}",
f"--lc-messages-dir={home / 'share' / 'mysql'}",
f"--character-sets-dir={home / 'share' / 'mysql' / 'charsets'}"]
def listener_options(self):
return ["--socket=mariadb.sock"]
def initialize(self, show=False):
"""mariadb-install-db: a new data directory whose root@localhost account has no password yet."""
args = ["mariadb-install-db", *self.server_options(), "--auth-root-authentication-method=normal",
"--skip-test-db", f"--user={OS_USER}"]
code, out = self._call(args)
if show:
_show(["mariadb-install-db", "--no-defaults", "--datadir=./data", "--auth-root-authentication-method=normal",
"--skip-test-db"], "")
if code != 0:
print(out)
sys.exit(f"mariadb-install-db ended with status {code}")
def login(self):
"""Gives root the run's password (never printed), with sql_log_bin off for that one statement, as
Server.login() does and for the same reasons; a labelled server masks nothing here (MariaDB has no server_uuid:
its GTIDs name the domain and the server_id instead)."""
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
# ---- MariaDB GTIDs and replication (two or three servers on 127.0.0.1) -------------------------------------------
def gtid_pos(self, which="gtid_binlog_pos"):
"""@@global.gtid_binlog_pos (what this server wrote to its binary log), gtid_slave_pos (what it applied as a
replica) or gtid_current_pos, as one line."""
return self.query(f"SELECT @@global.{which}").replace("\n", "")
def change_master(self, source, user="repl", password="replpass", use_gtid="slave_pos", extra=(), show=True,
connection=None):
"""Builds and runs MariaDB's change-source statement on this replica, pointing at `source` on 127.0.0.1.
MariaDB keeps the statement's legacy name (in the code below; the lessons say source and replica in prose).
use_gtid: slave_pos, current_pos, or None for file-and-position replication (then give MASTER_LOG_FILE and
MASTER_LOG_POS in extra). connection names a multi-source connection."""
self.track(source)
opts = ["MASTER_HOST='127.0.0.1'", f"MASTER_PORT={source.port}", f"MASTER_USER='{user}'",
f"MASTER_PASSWORD='{password}'"]
if use_gtid:
opts.append(f"MASTER_USE_GTID={use_gtid}")
opts += [str(e) for e in extra]
head = "CHANGE MASTER " + (f"'{connection}' " if connection else "") + "TO" # learn-lint-allow inclusive: MariaDB's statement name
stmt = head + "\n " + ",\n ".join(opts) + ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads say Yes in SHOW REPLICA STATUS (performance_schema is off by default
on MariaDB, so its replication tables are not used)."""
stmt = "SHOW REPLICA STATUS" if not channel else f"SHOW REPLICA '{channel}' STATUS"
for _ in range(timeout * 10):
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt], clean=False)
io = re.search(r"(?:Slave|Replica)_IO_Running: (\w+)", out) # learn-lint-allow inclusive: MariaDB's own column names (this line and the next)
sql_ = re.search(r"(?:Slave|Replica)_SQL_Running: (\w+)", out)
if io and sql_ and io.group(1) == "Yes" and sql_.group(1) == "Yes":
return
time.sleep(0.1)
print(self.clean(out))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has applied everything the source had written to its binary log when called
(MASTER_GTID_WAIT on the source's gtid_binlog_pos)."""
pos = source.gtid_pos("gtid_binlog_pos")
if not pos:
return
result = self.query(f"SELECT MASTER_GTID_WAIT('{pos}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (MASTER_GTID_WAIT returned {result})") With max_connections = 151 and wait_timeout = 28,800 s, both measured on the lab
server, 300 requests per second at 5 ms in the database need 1.5 busy connections, so
3 at seventy per cent utilisation and 3 at fifty per cent; a mixed workload at 150
requests per second with 20 ms in the database needs 3 busy connections, so 5 at
seventy per cent; and a slow workload at 40 requests per second with 80 ms in the
database needs 3.2 busy, so 5 at seventy per cent. The sanity table at the end adds a
reserve of 10 slots for replication, backups and an operator’s own client: ten
application instances with a pool of 20 each then want 210 of them, far past the 151
the server takes. One slot above max_connections is kept for an account with the
CONNECTION_ADMIN privilege, which is how a DBA still gets in to run SHOW PROCESSLIST on a full server — but that slot is for diagnosis, not for the
application.
At 80 % utilisation a request spends 50 ms in the system on average, 5 times its 10 ms of service: the rest is waiting. Little's law: L = λW = 4 requests in the system.
The numbers as a table
| Utilisation | Mean time in the system |
|---|---|
| 50 % | 20 ms |
| 70 % | 33.3 ms |
| 80 % | 50 ms |
| 90 % | 100 ms |
| 95 % | 200 ms |
| 99 % | 1,000 ms |
The model is an M/M/1 queue: requests arrive at rate λ and the server handles them
at rate µ, giving a utilisation ρ = λ / µ. At the opening settings, 80 arrivals a
second against a service rate of 100, the figure reads 50 ms in the system — five
times the 10 ms the server itself needs — with four requests in there on average. Its
table reads 33.3 ms at seventy per cent utilisation and 200 ms at ninety-five, so
pushing the arrival rate to 95 costs four times the latency of a queue that is only
a little quieter: a server busy ninety-five per cent of the time has almost no slack
to catch up after a bump. Keep each pool slot busy no more than seventy per cent of
the time, and the pool as a whole will fit the kind of variance a real application
sees.
Checkpoint
Check: Sizing a pool by Little's law
Timeouts the application and the server agree on
Pools break when their timeouts drift apart from the server’s. The two settings that
matter most are the server’s wait_timeout (28,800 s by default, measured here) and
the client’s own idle-connection timeout: if the pool keeps a connection longer than
the server, the next request that borrows it meets MySQL server has gone away. Set
the pool’s idle limit shorter than wait_timeout, and ask the pool to test the
connection on borrow (select 1, or the driver’s isValid()) when the application
cannot tolerate a one-off error.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Borrow from the pool. The driver reaches into the pool. If an idle connection is there, the next stage starts at the server. If no connection is free, the request waits until one is or until the pool's own wait expires.
- Open on a miss. With no connection free the driver opens one. TCP connect and the TLS handshake must finish inside connect_timeout, which production sets to a few seconds, not minutes.
- Send the SQL. The driver writes the SQL and the bound values. A slow network triggers the driver's write_timeout, so a client never waits forever on a dead peer.
- Wait for the reply. The server runs the statement and sends rows back. The driver's read_timeout caps how long it waits here; a longer-running statement needs a higher setting on that call.
- Release to the pool. On success the connection is released to the pool, where another request can borrow it right away.
- Idle in the pool. Nothing happens for a while. The pool keeps the connection open up to its own idle limit, which stays under the server's wait_timeout (28,800 s by default).
- Server closes it. If the connection sits past the server's wait_timeout the server shuts it down. The next request that borrows it meets "MySQL server has gone away"; pools must test or recycle.
A fresh connection passes through connect_timeout (TCP plus TLS), the driver’s
write_timeout on the way out, its read_timeout on the way back, and then an idle
window in the pool before the server closes it. Each timeout belongs on its own call
when the driver supports it: a long analytical query raises the read timeout for
that call alone, while a hot path keeps a short one. On the server side,
interactive_timeout applies to clients that negotiated the CLIENT_INTERACTIVE
flag (the mysql client); the drivers in this lesson do not set it, so
wait_timeout is the one that bites them.
One sentence to leave in every pool config
“Idle for longer than N seconds and a connection is closed and re-opened on next
borrow”; pick N short enough that wait_timeout on the server never fires first,
and short enough that a rolling restart drains the pool.
Checkpoint
Check: Timeouts the app and server agree on
Reading from a replica without reading stale rows
A replica is a few milliseconds to a few minutes behind its source, so a read that runs on the replica can miss its own write (the user updates a profile, reloads the page and sees the old copy). read-your-writes: A session guarantee that a read after a client's own commit sees that commit, even when the read lands on a replica. In MySQL the client reads the GTID of its commit on the source and asks WAIT_FOR_EXECUTED_GTID_SET on the replica to wait for it. restores the promise for one request: after a commit on the source, the client reads the GTID of its commit and asks the replica to wait on it before the next read.
Two building blocks make it work in MySQL 8.4. First,
session state tracking: A MySQL feature (session_track_gtids, session_track_system_variables, session_track_schema) that returns changes in session state inside the server's OK_PACKET. OWN_GTID returns the GTID the session's last commit received. can return the GTID
of a commit to the client as a server message when session_track_gtids = OWN_GTID;
a driver that reads those fields hands the GTID to the application without a second
query. Second, every driver can read @@global.gtid_executed
after the commit on the source and send that GTID set to the replica, where
WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout) blocks until the replica has applied
every transaction up to the set (returns 0) or the timeout fires (returns 1).
ryw_session.py
"""Read-your-writes with WAIT_FOR_EXECUTED_GTID_SET across a MySQL 8.4.11 source and
replica. The two servers listen on 127.0.0.1 only; the replica is read_only and starts
with GTID auto-positioning over TLS, verifying the source's CA. The ryw.py program
runs as a user that both servers accept (the account is created on the source once
replication is set up, so the replica receives it with its GTID).
The application pattern is: a client that just wrote on the source stamps its request
with the GTID the write received, and any later reader that must see that write calls
WAIT_FOR_EXECUTED_GTID_SET on the replica before it reads. The read is then
guaranteed, by the server, to see every transaction up to that GTID."""
from mysql_session import Server
SOURCE_OPTS = [
"--server-id=1", "--log-bin", "--gtid-mode=ON", "--enforce-gtid-consistency=ON",
"--log-replica-updates=ON", "--binlog-row-image=FULL",
]
REPLICA_OPTS = SOURCE_OPTS + ["--server-id=2", "--read-only=ON"]
APP_USER = "app"
APP_PASS = "applab-only"
with Server(options=SOURCE_OPTS, label="source", port_label="<source-port>") as a, \
Server(options=["--server-id=2", "--log-bin", "--gtid-mode=ON",
"--enforce-gtid-consistency=ON", "--log-replica-updates=ON",
"--binlog-row-image=FULL", "--read-only=ON"],
label="replica", port_label="<replica-port>") as b:
a.up()
b.up()
a.track(b)
b.track(a)
# One schema on the source; the replica picks it up through replication.
a.sql("CREATE DATABASE shop")
a.sql("CREATE TABLE shop.cart (user_id INT, sku VARCHAR(10), qty INT)")
# Accounts: an application user both servers share once replication catches up, and
# the replication account the replica logs in with.
a.query(f"CREATE USER 'app'@'127.0.0.1' IDENTIFIED BY '{APP_PASS}'")
a.query("GRANT SELECT, INSERT ON shop.* TO 'app'@'127.0.0.1'")
# learn-lint-allow inclusive: the SQL privilege is named REPLICATION SLAVE
a.query("CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL")
# learn-lint-allow inclusive: the SQL privilege is named REPLICATION SLAVE
a.query("GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'")
# Point the replica at the source over TLS and start it. verify_ca writes the
# source's ca.pem into the replica's data directory and asks the replica to verify
# the server certificate's chain against it.
b.change_source(a, user="repl", password="replpass", ssl=True, verify_ca=True, show=False)
b.start_replica(show=False)
b.wait_replica_running()
b.wait_caught_up(a)
# One run against the two servers: write on source, wait on replica, read on replica.
a.app("python3", "ryw.py",
env={
"LAB_USER": APP_USER, "LAB_PASSWORD": APP_PASS,
"REP_HOST": "127.0.0.1", "REP_PORT": str(b.port),
"REP_CA": str(b.base / "data" / "ca.pem"),
}) Output
$ mysql -t -e "CREATE DATABASE shop" $ mysql -t -e "CREATE TABLE shop.cart (user_id INT, sku VARCHAR(10), qty INT)" $ python3 ryw.py committed on source; @@global.gtid_executed = <source-uuid>:1-7 WAIT_FOR_EXECUTED_GTID_SET returned 0 (0 = replica caught up, 1 = timed out) replica read: (7, 'T-01', 3)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ryw_session.py
ryw.py
"""Read-your-writes across a source and a replica. The program commits a write on the
source, reads @@global.gtid_executed to learn the write's GTID, opens a connection to
the replica and calls WAIT_FOR_EXECUTED_GTID_SET on that GTID. The replica blocks until
it has applied the write (returns 0 when it reaches the set, 1 on timeout), and the
read that follows is guaranteed to see the row."""
import os
import pymysql
def connect(host, port, ca):
return pymysql.connect(
host=host, port=int(port),
user=os.environ["LAB_USER"], password=os.environ["LAB_PASSWORD"],
database="shop",
ssl={"ca": ca, "check_hostname": False},
)
# Write on the source and learn the GTID of the commit.
src = connect(os.environ["LAB_HOST"], os.environ["LAB_PORT"], os.environ["LAB_CA"])
with src.cursor() as cur:
cur.execute(
"INSERT INTO cart (user_id, sku, qty) VALUES (%s, %s, %s)",
(7, "T-01", 3),
)
src.commit()
cur.execute("SELECT @@global.gtid_executed")
gtid = cur.fetchone()[0].replace("\n", "")
print("committed on source; @@global.gtid_executed =", gtid)
src.close()
# Open the replica with the same application account and wait on exactly that GTID set.
rep = connect(os.environ["REP_HOST"], os.environ["REP_PORT"], os.environ["REP_CA"])
with rep.cursor() as cur:
cur.execute("SELECT WAIT_FOR_EXECUTED_GTID_SET(%s, %s)", (gtid, 10))
code = cur.fetchone()[0]
cur.execute(
"SELECT user_id, sku, qty FROM cart WHERE user_id = %s AND sku = %s",
(7, "T-01"),
)
row = cur.fetchone()
print(f"WAIT_FOR_EXECUTED_GTID_SET returned {code} (0 = replica caught up, 1 = timed out)")
print("replica read:", row)
rep.close() mysql_session.py
"""Throwaway MySQL 8.4.11 and MariaDB servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
MariaDB(version="12.3") (or "11.8") does the same with a MariaDB server: `mariadb-install-db` makes the data
directory, `mariadbd` serves it, and the session runs MariaDB's own programs (`mariadb`, `mariadb-admin`,
`mariadb-dump`, `mariadb-binlog`, `mariadb-backup`), which is how the printed commands name them. They are the
Homebrew macOS builds of MariaDB 12.3.3 and 11.8.9 (MariaDB publishes no macOS build of its own), unpacked in a
toolchains folder above this one (toolchain()). A session may run a MySQL and a MariaDB server side by side.
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder, the MySQL installation and the unpacked toolchains, and they connect to nothing
but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
# MariaDB: "2026-10-20 0:56:56 0 [ERROR] ..." -> "[ERROR] ..."
MARIADB_LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\d +\d{1,2}:\d\d:\d\d \d+ ", re.M)
# The unpacked toolchains the sessions use (toolchain()): the MariaDB builds and the three drivers.
TOOLCHAINS = ("mariadb-12.3.3", "pymysql-1.2.3", "node-mysql2-3.24.5", "mysql-connector-j-26.7.0")
def toolchain(name):
"""The unpacked toolchain <name>, one of TOOLCHAINS. They all come from one folder: the first folder called
toolchains that holds every one of TOOLCHAINS, looked for above the folder the session runs in and above this
file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
seen = set()
for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
for parent in (start, *start.parents):
if parent in seen or parent == home or parent == Path(parent.anchor):
break
seen.add(parent)
scratch = parent / "tmp"
for folder in (parent, *(sorted(scratch.iterdir()) if scratch.is_dir() else ())):
found = folder / "toolchains"
if all((found / each).is_dir() for each in TOOLCHAINS):
return found / name
sys.exit(f"no toolchains folder holds all of {', '.join(TOOLCHAINS)}")
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
SERVER, CLIENT, ADMIN = "mysqld", "mysql", "mysqladmin" # the programs; MariaDB names its own
LOG_RE = LOG_PREFIX
def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.label = label # a stable name for this server in the record ("source", "replica")
self.port_label = port_label # the text that shows in place of this server's port
self.uuid = None
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / self.SERVER).exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
# mariadb-install-db (a shell script) calls chown, which lives in /usr/sbin; a run's PATH may hold only
# python's bin, /usr/bin and /bin, so add the system sbin folders when they are missing. These hold
# standard OS tools, nothing of this computer's own, and the session never prints the PATH.
parts = self.env.get("PATH", "/usr/bin:/bin").split(os.pathsep)
for d in ("/usr/sbin", "/sbin"):
if d not in parts:
parts.append(d)
self.env["PATH"] = os.pathsep.join(parts)
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / self.SERVER), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
*self.listener_options(), *self.options, *[str(e) for e in extra]]
if show:
shown = [self.SERVER, opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", *self.listener_options(), *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(self.LOG_RE.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def listener_options(self):
"""The socket inside the run folder and, for MySQL, the X Protocol listener switched off."""
return ["--socket=mysql.sock", "--mysqlx=OFF"]
def app(self, program, script, *args, status=0, env=None, port=None, mask=(), note=None, timeout=100, show=True,
during=None):
"""Runs an application program of the examples folder against this server and prints it as a shell would.
program: "python3" (PyMySQL 1.2.3), "node" (mysql2 3.24.5) or "java" (Connector/J 26.7.0, the single-file
source launcher: `java Demo.java`). The program finds the server in the environment variables LAB_HOST
(127.0.0.1), LAB_PORT (printed as <port>), LAB_USER (root unless env says otherwise), LAB_PASSWORD (the run's
password: a program never prints it, and the record would show <password> if it did) and LAB_CA (the server's
auto-generated ca.pem, for verified TLS); env adds or replaces variables, port points it at another server of
the run (a proxy, a replica). It runs in the run folder, beside the session's files; its output is cleaned like
every program's, with mask=((pattern, replacement), ...) for values that change between runs. during, a
function of no arguments, runs unprinted while the program runs."""
tools = {"python3": None, "node": toolchain("node-mysql2-3.24.5"), "java": toolchain("mysql-connector-j-26.7.0")}
if program not in tools:
sys.exit(f"app() does not run {program}")
source = Path(script)
if not source.exists():
sys.exit(f"{script} is not in the examples folder")
shutil.copy(source, self.base / source.name)
ca = self.base / "data" / "ca.pem"
merged = {**self.env, "LAB_HOST": "127.0.0.1", "LAB_PORT": str(port or self.port), "LAB_USER": "root",
"LAB_PASSWORD": self.password, "LAB_CA": str(ca) if ca.exists() else "", **dict(env or {})}
if program == "python3":
command = [sys.executable, source.name]
merged.update({"PYTHONPATH": str(toolchain("pymysql-1.2.3") / "site"), "PYTHONNOUSERSITE": "1",
"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"})
elif program == "node":
node = INSTALLS / "node" / "24.21.0" / "bin" / "node"
if not node.exists():
sys.exit("Node.js 24.21.0 was not found next to this Python")
# Node resolves import and require from a node_modules folder beside the program (NODE_PATH does not
# reach ES modules), so the run folder gets a link to the installed driver.
link = self.base / "node_modules"
if not link.exists():
link.symlink_to(tools["node"] / "node_modules", target_is_directory=True)
command = [str(node), source.name]
else:
java = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin" / "java"
if not java.exists():
sys.exit("Java 25 (temurin-25.0.4+101.0.LTS) was not found next to this Python")
# No performance-data file and no temporary file outside the run folder (macOS gives Java its own
# temporary folder otherwise), and the home folder is the run folder.
command = [str(java), "-XX:-UsePerfData", f"-Djava.io.tmpdir={self.base / 'tmp'}",
f"-Duser.home={self.base}", "-cp", str(tools["java"] / "mysql-connector-j-26.7.0.jar"),
source.name]
command += [str(a) for a in args]
if note:
print(f"-- {note}")
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=self.base,
env=merged)
try:
if during is not None:
during()
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
sys.exit(f"{script} did not finish in {timeout} s")
except BaseException:
proc.kill()
proc.communicate()
raise
# A program's traceback names the files it ran: the drivers' folder and the language's installation are shown
# as <toolchains> and <installs>, so a record never shows this computer's folders.
raw = output.replace(self.password, "<password>")
raw = raw.replace(str(tools["java"].parent if tools["java"] else toolchain("pymysql-1.2.3").parent),
"<toolchains>").replace(str(INSTALLS), "<installs>")
out = self.clean(raw)
for pattern, replacement in mask:
out = re.sub(pattern, replacement, out)
if show:
_show([program, source.name, *args], out)
if proc.returncode != status:
if not show:
print(out)
sys.exit(f"{script} ended with status {proc.returncode}, not {status}")
return out
def _ping(self):
code, _ = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it. If this server has a label, its random server_uuid is masked to a
stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
so a replicated ALTER USER would lock the session out of the replica)."""
sro = self.query("SELECT @@global.super_read_only") == "1"
if sro:
self.query("SET GLOBAL super_read_only=OFF")
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
if sro:
self.query("SET GLOBAL super_read_only=ON")
if self.label:
self.uuid = self.query("SELECT @@server_uuid")
self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True, clean=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
is text for the record only and MySQL would reject it."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, (self.clean(done.stdout) if clean else done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text) # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(self.LOG_RE.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
that goes into the record."""
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql, clean=False)
if code != 0:
sys.exit(f"the query failed: {self.clean(out)}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = [self.CLIENT, *flags, *(["--force"] if force else []), *([db] if db else [])]
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = [self.CLIENT, *flags, *([db] if db else []), "-e", statement]
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
# ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
def gtid_executed(self, raw=False):
"""This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
return value if raw else self.clean(value)
def track(self, other):
"""Teach this server's record to mask another server's port and server_uuid, so a statement that names the
other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
if other.port:
self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
if other.uuid:
self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
self.mask(re.escape(str(other.base)), ".")
def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
"""Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
not into the run folder.
VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
self.track(source)
opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
f"SOURCE_PASSWORD='{password}'"]
if auto_position:
opts.append("SOURCE_AUTO_POSITION=1")
if ssl:
opts.append("SOURCE_SSL=1")
if verify_ca:
shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
opts.append("SOURCE_SSL_CA='source-ca.pem'")
if delay is not None:
opts.append(f"SOURCE_DELAY={delay}")
opts += [str(e) for e in extra]
stmt = "CHANGE REPLICATION SOURCE TO\n " + ",\n ".join(opts)
if channel:
stmt += f"\n FOR CHANNEL '{channel}'"
stmt += ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if run:
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def start_replica(self, user=None, password=None, channel=None, show=True):
"""START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
parts = ["START REPLICA"]
if user:
parts.append(f"USER='{user}' PASSWORD='{password}'")
if channel:
parts.append(f"FOR CHANNEL '{channel}'")
stmt = " ".join(parts) + ";"
if show:
self.write_file("_start_replica.sql", stmt + "\n", show=False)
self.mysql("_start_replica.sql")
else:
self.query(stmt)
def stop_replica(self, channel=None, show=False):
stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
if show:
self.sql(stmt)
else:
self.query(stmt)
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
for _ in range(timeout * 10):
io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
if io == "ON" and applier == "ON":
return
time.sleep(0.1)
print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has executed every transaction the source had committed when called."""
gset = source.gtid_executed(raw=True)
if not gset:
return
result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")
def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
"""Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
if code != 0:
sys.exit(f"SHOW REPLICA STATUS failed: {out}")
values = {}
for line in out.splitlines():
m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
if m:
values[m.group(1)] = m.group(2)
if note:
print(f"-- {note}")
print(f"{self.CLIENT}> {label}\\G")
for field in fields:
if field in values:
print(f" {field}: {values[field]}")
print()
return values
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class MariaDB(Server):
"""One throwaway MariaDB server (12.3.3 by default, or version="11.8" for 11.8.9), used as Server is.
What differs from MySQL, and how the class handles it: mariadb-install-db makes the data directory, and root has no
password until login() gives it the run's (install-db's own messages name this computer's folders and are not
printed); the programs are mariadbd, mariadb and mariadb-admin (the mysql* names are links that MariaDB calls
deprecated); there is no X Protocol, no super_read_only and no server_uuid; the error log's time stamps have
another shape; and the server is told where its plugins, messages and character sets are, because the Homebrew
build would otherwise look for them under /opt/homebrew. Its socket is mariadb.sock in the data directory (a
relative path: MariaDB refuses a socket path longer than 103 characters), and clients connect over 127.0.0.1."""
SERVER, CLIENT, ADMIN = "mariadbd", "mariadb", "mariadb-admin"
LOG_RE = MARIADB_LOG_PREFIX
def __init__(self, version="12.3", options=(), masks=(), label=None, port_label=None):
super().__init__(options=options, basedir=toolchain("mariadb-12.3.3") / "opt" / f"mariadb@{version}",
masks=masks, label=label, port_label=port_label)
self.version = version
def server_options(self):
b, home = self.base, self.basedir
return ["--no-defaults", f"--basedir={home}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}", f"--plugin-dir={home / 'lib' / 'plugin'}",
f"--lc-messages-dir={home / 'share' / 'mysql'}",
f"--character-sets-dir={home / 'share' / 'mysql' / 'charsets'}"]
def listener_options(self):
return ["--socket=mariadb.sock"]
def initialize(self, show=False):
"""mariadb-install-db: a new data directory whose root@localhost account has no password yet."""
args = ["mariadb-install-db", *self.server_options(), "--auth-root-authentication-method=normal",
"--skip-test-db", f"--user={OS_USER}"]
code, out = self._call(args)
if show:
_show(["mariadb-install-db", "--no-defaults", "--datadir=./data", "--auth-root-authentication-method=normal",
"--skip-test-db"], "")
if code != 0:
print(out)
sys.exit(f"mariadb-install-db ended with status {code}")
def login(self):
"""Gives root the run's password (never printed), with sql_log_bin off for that one statement, as
Server.login() does and for the same reasons; a labelled server masks nothing here (MariaDB has no server_uuid:
its GTIDs name the domain and the server_id instead)."""
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
# ---- MariaDB GTIDs and replication (two or three servers on 127.0.0.1) -------------------------------------------
def gtid_pos(self, which="gtid_binlog_pos"):
"""@@global.gtid_binlog_pos (what this server wrote to its binary log), gtid_slave_pos (what it applied as a
replica) or gtid_current_pos, as one line."""
return self.query(f"SELECT @@global.{which}").replace("\n", "")
def change_master(self, source, user="repl", password="replpass", use_gtid="slave_pos", extra=(), show=True,
connection=None):
"""Builds and runs MariaDB's change-source statement on this replica, pointing at `source` on 127.0.0.1.
MariaDB keeps the statement's legacy name (in the code below; the lessons say source and replica in prose).
use_gtid: slave_pos, current_pos, or None for file-and-position replication (then give MASTER_LOG_FILE and
MASTER_LOG_POS in extra). connection names a multi-source connection."""
self.track(source)
opts = ["MASTER_HOST='127.0.0.1'", f"MASTER_PORT={source.port}", f"MASTER_USER='{user}'",
f"MASTER_PASSWORD='{password}'"]
if use_gtid:
opts.append(f"MASTER_USE_GTID={use_gtid}")
opts += [str(e) for e in extra]
head = "CHANGE MASTER " + (f"'{connection}' " if connection else "") + "TO" # learn-lint-allow inclusive: MariaDB's statement name
stmt = head + "\n " + ",\n ".join(opts) + ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads say Yes in SHOW REPLICA STATUS (performance_schema is off by default
on MariaDB, so its replication tables are not used)."""
stmt = "SHOW REPLICA STATUS" if not channel else f"SHOW REPLICA '{channel}' STATUS"
for _ in range(timeout * 10):
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt], clean=False)
io = re.search(r"(?:Slave|Replica)_IO_Running: (\w+)", out) # learn-lint-allow inclusive: MariaDB's own column names (this line and the next)
sql_ = re.search(r"(?:Slave|Replica)_SQL_Running: (\w+)", out)
if io and sql_ and io.group(1) == "Yes" and sql_.group(1) == "Yes":
return
time.sleep(0.1)
print(self.clean(out))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has applied everything the source had written to its binary log when called
(MASTER_GTID_WAIT on the source's gtid_binlog_pos)."""
pos = source.gtid_pos("gtid_binlog_pos")
if not pos:
return
result = self.query(f"SELECT MASTER_GTID_WAIT('{pos}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (MASTER_GTID_WAIT returned {result})") The session sets up a source and a replica with GTID replication over TLS (the
replica trusts the source’s CA), creates an app account, and runs the ryw.py
program. The program commits a row on the source, reads gtid_executed
(<source-uuid>:1-7 after the session’s own setup statements), opens the replica,
calls WAIT_FOR_EXECUTED_GTID_SET with a ten-second timeout, gets 0 back and
reads the row it just wrote: (7, 'T-01', 3). The pattern survives any amount of
replication lag inside the timeout, costs the replica one function call per
read-after-write, and does not pin every read to the source.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- INSERT on the source. The client commits an INSERT on the source. The source writes the row and gives the transaction its GTID.
- GTID set to the client. After the commit the client reads @@global.gtid_executed and keeps the returned set. One range covers every GTID the source has seen so far.
- Ask the replica to wait. The client opens a connection to the replica and calls WAIT_FOR_EXECUTED_GTID_SET on that GTID set, with a short timeout.
- Replica catches up. The replica applies every transaction up to the set. The function returns 0 the moment that is true, or 1 if the timeout fires first.
- SELECT the new row. The client runs the SELECT on the same replica connection. The server guarantees it sees every transaction up to the GTID set, so the row is there.
- The row comes back. The replica returns the row the client wrote. Read-your-writes holds without an added hop through the source.
PyMySQL 1.2.3 does not expose session state tracking, so this program reads
gtid_executed with an explicit SELECT on the source; Connector/J can read the
tracking fields once trackSessionState=true is set (it is off by default), which
saves that round trip. Session tracking is the cleaner choice when the driver
supports it; the SELECT fallback is the portable one.
Checkpoint
Check: Reads from a replica
Router or ProxySQL: pick a proxy by the shape of the problem
Proxies in front of MySQL do two different jobs: route reads and writes to the right
server, and reshape the client’s connections before they reach it. MySQL Router does
the first and leaves the second to the driver; ProxySQL does both, with query rules
and connection multiplexing: An application proxy's trick (ProxySQL) that lets many application connections share a smaller pool of connections to the servers, borrowing a back-end slot only while a statement runs, so the server's max_connections stays comfortable under a fan-out.. Both proxies
run on 127.0.0.1 beside the application and speak the classic MySQL protocol, so
drivers connect to them as if to a server.
The example starts MySQL Router with a STATIC routing configuration (no InnoDB Cluster) in front of two throwaway MySQL servers, with a read-write route that points at server A and a read-only route that round-robins over A and B:
router_session.py
"""MySQL Router 26.7.0 with a STATIC routing configuration in front of two throwaway
MySQL 8.4.11 servers on 127.0.0.1. The read-write route takes the first-available
destination (server A); the read-only route round-robins over A and B. The write stays
on A; the reads land on B then A then B as the router cycles its destinations. The
router stops at the end of the session."""
import socket
import subprocess
import time
from mysql_session import Server, free_port, toolchain
ROUTER = toolchain("mysql-router-26.7.0") / "bin" / "mysqlrouter"
APP_PASS = "applab-only"
with Server(options=["--server-id=1"], label="a", port_label="<a-port>") as a, \
Server(options=["--server-id=2"], label="b", port_label="<b-port>") as b:
a.up()
b.up()
a.track(b)
b.track(a)
rw = free_port()
ro = free_port()
a.mask(rf"(?<![\d.]){rw}(?![\d.])", "<rw-port>")
a.mask(rf"(?<![\d.]){ro}(?![\d.])", "<ro-port>")
folder = a.base / "router"
(folder / "log").mkdir(parents=True)
conf = folder / "router.conf"
conf.write_text(f"""[DEFAULT]
logging_folder={folder / 'log'}
runtime_folder={folder}
data_folder={folder}
[logger]
level=INFO
[routing:primary]
bind_address=127.0.0.1
bind_port={rw}
destinations=127.0.0.1:{a.port}
routing_strategy=first-available
protocol=classic
[routing:replicas]
bind_address=127.0.0.1
bind_port={ro}
destinations=127.0.0.1:{a.port},127.0.0.1:{b.port}
routing_strategy=round-robin
protocol=classic
""")
print("$ cat router.conf")
text = conf.read_text()
text = text.replace(str(folder), ".")
text = text.replace(f"127.0.0.1:{a.port}", "127.0.0.1:<a-port>")
text = text.replace(f"127.0.0.1:{b.port}", "127.0.0.1:<b-port>")
text = text.replace(f"bind_port={rw}", "bind_port=<rw-port>")
text = text.replace(f"bind_port={ro}", "bind_port=<ro-port>")
print(text.rstrip())
print()
print("$ mysqlrouter -c router.conf &")
print()
proc = subprocess.Popen(
[str(ROUTER), "-c", str(conf)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
cwd=folder, env={**a.env, "HOME": str(folder)},
)
try:
# Wait until the router is listening before anything connects to it.
for _ in range(100):
try:
with socket.create_connection(("127.0.0.1", ro), timeout=0.2):
break
except OSError:
time.sleep(0.1)
# One application account on both servers (the router passes the login through
# unchanged, so each destination needs the account).
for s in (a, b):
s.query(f"CREATE USER 'app'@'127.0.0.1' IDENTIFIED BY '{APP_PASS}'")
acct = a.base / "app.cnf"
acct.write_text("[client]\nuser=app\npassword=" + APP_PASS + "\nhost=127.0.0.1\nprotocol=TCP\n")
# One write (through the rw route) and three reads (through the ro route). The
# ro route is round-robin, so the reads cycle through the two destinations.
for label, port in (("rw", rw), ("ro", ro), ("ro", ro), ("ro", ro)):
code, text = a._call([
"mysql", f"--defaults-file={acct}", f"--port={port}", "-N", "-B", "-e",
"SELECT @@server_id",
])
print(f"{label} -> server_id {text.strip()}")
finally:
proc.terminate()
proc.wait(timeout=30) Output
$ cat router.conf [DEFAULT] logging_folder=./log runtime_folder=. data_folder=. [logger] level=INFO [routing:primary] bind_address=127.0.0.1 bind_port=<rw-port> destinations=127.0.0.1:<a-port> routing_strategy=first-available protocol=classic [routing:replicas] bind_address=127.0.0.1 bind_port=<ro-port> destinations=127.0.0.1:<a-port>,127.0.0.1:<b-port> routing_strategy=round-robin protocol=classic $ mysqlrouter -c router.conf & rw -> server_id 1 ro -> server_id 2 ro -> server_id 1 ro -> server_id 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 router_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 and MariaDB servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
MariaDB(version="12.3") (or "11.8") does the same with a MariaDB server: `mariadb-install-db` makes the data
directory, `mariadbd` serves it, and the session runs MariaDB's own programs (`mariadb`, `mariadb-admin`,
`mariadb-dump`, `mariadb-binlog`, `mariadb-backup`), which is how the printed commands name them. They are the
Homebrew macOS builds of MariaDB 12.3.3 and 11.8.9 (MariaDB publishes no macOS build of its own), unpacked in a
toolchains folder above this one (toolchain()). A session may run a MySQL and a MariaDB server side by side.
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder, the MySQL installation and the unpacked toolchains, and they connect to nothing
but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
# MariaDB: "2026-10-20 0:56:56 0 [ERROR] ..." -> "[ERROR] ..."
MARIADB_LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\d +\d{1,2}:\d\d:\d\d \d+ ", re.M)
# The unpacked toolchains the sessions use (toolchain()): the MariaDB builds and the three drivers.
TOOLCHAINS = ("mariadb-12.3.3", "pymysql-1.2.3", "node-mysql2-3.24.5", "mysql-connector-j-26.7.0")
def toolchain(name):
"""The unpacked toolchain <name>, one of TOOLCHAINS. They all come from one folder: the first folder called
toolchains that holds every one of TOOLCHAINS, looked for above the folder the session runs in and above this
file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
seen = set()
for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
for parent in (start, *start.parents):
if parent in seen or parent == home or parent == Path(parent.anchor):
break
seen.add(parent)
scratch = parent / "tmp"
for folder in (parent, *(sorted(scratch.iterdir()) if scratch.is_dir() else ())):
found = folder / "toolchains"
if all((found / each).is_dir() for each in TOOLCHAINS):
return found / name
sys.exit(f"no toolchains folder holds all of {', '.join(TOOLCHAINS)}")
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
SERVER, CLIENT, ADMIN = "mysqld", "mysql", "mysqladmin" # the programs; MariaDB names its own
LOG_RE = LOG_PREFIX
def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.label = label # a stable name for this server in the record ("source", "replica")
self.port_label = port_label # the text that shows in place of this server's port
self.uuid = None
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / self.SERVER).exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
# mariadb-install-db (a shell script) calls chown, which lives in /usr/sbin; a run's PATH may hold only
# python's bin, /usr/bin and /bin, so add the system sbin folders when they are missing. These hold
# standard OS tools, nothing of this computer's own, and the session never prints the PATH.
parts = self.env.get("PATH", "/usr/bin:/bin").split(os.pathsep)
for d in ("/usr/sbin", "/sbin"):
if d not in parts:
parts.append(d)
self.env["PATH"] = os.pathsep.join(parts)
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / self.SERVER), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
*self.listener_options(), *self.options, *[str(e) for e in extra]]
if show:
shown = [self.SERVER, opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", *self.listener_options(), *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(self.LOG_RE.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def listener_options(self):
"""The socket inside the run folder and, for MySQL, the X Protocol listener switched off."""
return ["--socket=mysql.sock", "--mysqlx=OFF"]
def app(self, program, script, *args, status=0, env=None, port=None, mask=(), note=None, timeout=100, show=True,
during=None):
"""Runs an application program of the examples folder against this server and prints it as a shell would.
program: "python3" (PyMySQL 1.2.3), "node" (mysql2 3.24.5) or "java" (Connector/J 26.7.0, the single-file
source launcher: `java Demo.java`). The program finds the server in the environment variables LAB_HOST
(127.0.0.1), LAB_PORT (printed as <port>), LAB_USER (root unless env says otherwise), LAB_PASSWORD (the run's
password: a program never prints it, and the record would show <password> if it did) and LAB_CA (the server's
auto-generated ca.pem, for verified TLS); env adds or replaces variables, port points it at another server of
the run (a proxy, a replica). It runs in the run folder, beside the session's files; its output is cleaned like
every program's, with mask=((pattern, replacement), ...) for values that change between runs. during, a
function of no arguments, runs unprinted while the program runs."""
tools = {"python3": None, "node": toolchain("node-mysql2-3.24.5"), "java": toolchain("mysql-connector-j-26.7.0")}
if program not in tools:
sys.exit(f"app() does not run {program}")
source = Path(script)
if not source.exists():
sys.exit(f"{script} is not in the examples folder")
shutil.copy(source, self.base / source.name)
ca = self.base / "data" / "ca.pem"
merged = {**self.env, "LAB_HOST": "127.0.0.1", "LAB_PORT": str(port or self.port), "LAB_USER": "root",
"LAB_PASSWORD": self.password, "LAB_CA": str(ca) if ca.exists() else "", **dict(env or {})}
if program == "python3":
command = [sys.executable, source.name]
merged.update({"PYTHONPATH": str(toolchain("pymysql-1.2.3") / "site"), "PYTHONNOUSERSITE": "1",
"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"})
elif program == "node":
node = INSTALLS / "node" / "24.21.0" / "bin" / "node"
if not node.exists():
sys.exit("Node.js 24.21.0 was not found next to this Python")
# Node resolves import and require from a node_modules folder beside the program (NODE_PATH does not
# reach ES modules), so the run folder gets a link to the installed driver.
link = self.base / "node_modules"
if not link.exists():
link.symlink_to(tools["node"] / "node_modules", target_is_directory=True)
command = [str(node), source.name]
else:
java = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin" / "java"
if not java.exists():
sys.exit("Java 25 (temurin-25.0.4+101.0.LTS) was not found next to this Python")
# No performance-data file and no temporary file outside the run folder (macOS gives Java its own
# temporary folder otherwise), and the home folder is the run folder.
command = [str(java), "-XX:-UsePerfData", f"-Djava.io.tmpdir={self.base / 'tmp'}",
f"-Duser.home={self.base}", "-cp", str(tools["java"] / "mysql-connector-j-26.7.0.jar"),
source.name]
command += [str(a) for a in args]
if note:
print(f"-- {note}")
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=self.base,
env=merged)
try:
if during is not None:
during()
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
sys.exit(f"{script} did not finish in {timeout} s")
except BaseException:
proc.kill()
proc.communicate()
raise
# A program's traceback names the files it ran: the drivers' folder and the language's installation are shown
# as <toolchains> and <installs>, so a record never shows this computer's folders.
raw = output.replace(self.password, "<password>")
raw = raw.replace(str(tools["java"].parent if tools["java"] else toolchain("pymysql-1.2.3").parent),
"<toolchains>").replace(str(INSTALLS), "<installs>")
out = self.clean(raw)
for pattern, replacement in mask:
out = re.sub(pattern, replacement, out)
if show:
_show([program, source.name, *args], out)
if proc.returncode != status:
if not show:
print(out)
sys.exit(f"{script} ended with status {proc.returncode}, not {status}")
return out
def _ping(self):
code, _ = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it. If this server has a label, its random server_uuid is masked to a
stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
so a replicated ALTER USER would lock the session out of the replica)."""
sro = self.query("SELECT @@global.super_read_only") == "1"
if sro:
self.query("SET GLOBAL super_read_only=OFF")
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
if sro:
self.query("SET GLOBAL super_read_only=ON")
if self.label:
self.uuid = self.query("SELECT @@server_uuid")
self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call([self.ADMIN, f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True, clean=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
is text for the record only and MySQL would reject it."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, (self.clean(done.stdout) if clean else done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text) # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(self.LOG_RE.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
that goes into the record."""
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql, clean=False)
if code != 0:
sys.exit(f"the query failed: {self.clean(out)}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = [self.CLIENT, *flags, *(["--force"] if force else []), *([db] if db else [])]
args = [self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = [self.CLIENT, *flags, *([db] if db else []), "-e", statement]
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
# ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
def gtid_executed(self, raw=False):
"""This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
return value if raw else self.clean(value)
def track(self, other):
"""Teach this server's record to mask another server's port and server_uuid, so a statement that names the
other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
if other.port:
self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
if other.uuid:
self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
self.mask(re.escape(str(other.base)), ".")
def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
"""Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
not into the run folder.
VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
self.track(source)
opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
f"SOURCE_PASSWORD='{password}'"]
if auto_position:
opts.append("SOURCE_AUTO_POSITION=1")
if ssl:
opts.append("SOURCE_SSL=1")
if verify_ca:
shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
opts.append("SOURCE_SSL_CA='source-ca.pem'")
if delay is not None:
opts.append(f"SOURCE_DELAY={delay}")
opts += [str(e) for e in extra]
stmt = "CHANGE REPLICATION SOURCE TO\n " + ",\n ".join(opts)
if channel:
stmt += f"\n FOR CHANNEL '{channel}'"
stmt += ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if run:
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def start_replica(self, user=None, password=None, channel=None, show=True):
"""START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
parts = ["START REPLICA"]
if user:
parts.append(f"USER='{user}' PASSWORD='{password}'")
if channel:
parts.append(f"FOR CHANNEL '{channel}'")
stmt = " ".join(parts) + ";"
if show:
self.write_file("_start_replica.sql", stmt + "\n", show=False)
self.mysql("_start_replica.sql")
else:
self.query(stmt)
def stop_replica(self, channel=None, show=False):
stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
if show:
self.sql(stmt)
else:
self.query(stmt)
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
for _ in range(timeout * 10):
io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
if io == "ON" and applier == "ON":
return
time.sleep(0.1)
print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has executed every transaction the source had committed when called."""
gset = source.gtid_executed(raw=True)
if not gset:
return
result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")
def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
"""Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
if code != 0:
sys.exit(f"SHOW REPLICA STATUS failed: {out}")
values = {}
for line in out.splitlines():
m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
if m:
values[m.group(1)] = m.group(2)
if note:
print(f"-- {note}")
print(f"{self.CLIENT}> {label}\\G")
for field in fields:
if field in values:
print(f" {field}: {values[field]}")
print()
return values
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class MariaDB(Server):
"""One throwaway MariaDB server (12.3.3 by default, or version="11.8" for 11.8.9), used as Server is.
What differs from MySQL, and how the class handles it: mariadb-install-db makes the data directory, and root has no
password until login() gives it the run's (install-db's own messages name this computer's folders and are not
printed); the programs are mariadbd, mariadb and mariadb-admin (the mysql* names are links that MariaDB calls
deprecated); there is no X Protocol, no super_read_only and no server_uuid; the error log's time stamps have
another shape; and the server is told where its plugins, messages and character sets are, because the Homebrew
build would otherwise look for them under /opt/homebrew. Its socket is mariadb.sock in the data directory (a
relative path: MariaDB refuses a socket path longer than 103 characters), and clients connect over 127.0.0.1."""
SERVER, CLIENT, ADMIN = "mariadbd", "mariadb", "mariadb-admin"
LOG_RE = MARIADB_LOG_PREFIX
def __init__(self, version="12.3", options=(), masks=(), label=None, port_label=None):
super().__init__(options=options, basedir=toolchain("mariadb-12.3.3") / "opt" / f"mariadb@{version}",
masks=masks, label=label, port_label=port_label)
self.version = version
def server_options(self):
b, home = self.base, self.basedir
return ["--no-defaults", f"--basedir={home}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}", f"--plugin-dir={home / 'lib' / 'plugin'}",
f"--lc-messages-dir={home / 'share' / 'mysql'}",
f"--character-sets-dir={home / 'share' / 'mysql' / 'charsets'}"]
def listener_options(self):
return ["--socket=mariadb.sock"]
def initialize(self, show=False):
"""mariadb-install-db: a new data directory whose root@localhost account has no password yet."""
args = ["mariadb-install-db", *self.server_options(), "--auth-root-authentication-method=normal",
"--skip-test-db", f"--user={OS_USER}"]
code, out = self._call(args)
if show:
_show(["mariadb-install-db", "--no-defaults", "--datadir=./data", "--auth-root-authentication-method=normal",
"--skip-test-db"], "")
if code != 0:
print(out)
sys.exit(f"mariadb-install-db ended with status {code}")
def login(self):
"""Gives root the run's password (never printed), with sql_log_bin off for that one statement, as
Server.login() does and for the same reasons; a labelled server masks nothing here (MariaDB has no server_uuid:
its GTIDs name the domain and the server_id instead)."""
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
# ---- MariaDB GTIDs and replication (two or three servers on 127.0.0.1) -------------------------------------------
def gtid_pos(self, which="gtid_binlog_pos"):
"""@@global.gtid_binlog_pos (what this server wrote to its binary log), gtid_slave_pos (what it applied as a
replica) or gtid_current_pos, as one line."""
return self.query(f"SELECT @@global.{which}").replace("\n", "")
def change_master(self, source, user="repl", password="replpass", use_gtid="slave_pos", extra=(), show=True,
connection=None):
"""Builds and runs MariaDB's change-source statement on this replica, pointing at `source` on 127.0.0.1.
MariaDB keeps the statement's legacy name (in the code below; the lessons say source and replica in prose).
use_gtid: slave_pos, current_pos, or None for file-and-position replication (then give MASTER_LOG_FILE and
MASTER_LOG_POS in extra). connection names a multi-source connection."""
self.track(source)
opts = ["MASTER_HOST='127.0.0.1'", f"MASTER_PORT={source.port}", f"MASTER_USER='{user}'",
f"MASTER_PASSWORD='{password}'"]
if use_gtid:
opts.append(f"MASTER_USE_GTID={use_gtid}")
opts += [str(e) for e in extra]
head = "CHANGE MASTER " + (f"'{connection}' " if connection else "") + "TO" # learn-lint-allow inclusive: MariaDB's statement name
stmt = head + "\n " + ",\n ".join(opts) + ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads say Yes in SHOW REPLICA STATUS (performance_schema is off by default
on MariaDB, so its replication tables are not used)."""
stmt = "SHOW REPLICA STATUS" if not channel else f"SHOW REPLICA '{channel}' STATUS"
for _ in range(timeout * 10):
code, out = self._call([self.CLIENT, f"--defaults-file={self.client_cnf}", "-E", "-e", stmt], clean=False)
io = re.search(r"(?:Slave|Replica)_IO_Running: (\w+)", out) # learn-lint-allow inclusive: MariaDB's own column names (this line and the next)
sql_ = re.search(r"(?:Slave|Replica)_SQL_Running: (\w+)", out)
if io and sql_ and io.group(1) == "Yes" and sql_.group(1) == "Yes":
return
time.sleep(0.1)
print(self.clean(out))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has applied everything the source had written to its binary log when called
(MASTER_GTID_WAIT on the source's gtid_binlog_pos)."""
pos = source.gtid_pos("gtid_binlog_pos")
if not pos:
return
result = self.query(f"SELECT MASTER_GTID_WAIT('{pos}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (MASTER_GTID_WAIT returned {result})") The write (rw -> server_id 1) stays on A; the three reads land on B, then A, then
B (ro -> 2, 1, 2) as the router cycles its destinations. In an InnoDB Cluster
deployment the router keeps a metadata cache instead of a destinations list and
refreshes it twice a second by default (its ttl option is 0.5 seconds), so it
routes by the role each server currently has; without a cluster the STATIC list
above is enough for a lab or a small fleet. Writes go to one bind port, reads to
another; the application opens two pools, one to each.
ProxySQL, whose documentation lives at proxysql.com, takes on more than routing.
Both proxies send reads and writes to the right server; ProxySQL also reads the SQL.
Its mysql_query_rules table matches a statement (usually on the query digest, in
rule order) and sends it to a host group, rewrites it, or answers it from a cache
when the rule sets cache_ttl; mysql_replication_hostgroups moves a server
between the writer and reader groups by its own read_only value; and connection
multiplexing lets many
application connections share a smaller pool of server connections, which keeps
max_connections comfortable under a fan-out. The same documentation lists what
turns multiplexing off for a connection: an open transaction, LOCK TABLES,
GET_LOCK(), a statement with a session variable in it, a temporary table. The
trade-off is one more service to run and one more SQL path to reason about.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Proxy | Routing by role | Query rules | Fewer server connections | What drives it |
|---|---|---|---|---|
| MySQL Router | Good routes by role from InnoDB Cluster metadata or a STATIC list | Poor no SQL inspection; the application decides | Mixed one application connection stays mapped to one server connection | Good reads cluster metadata or a static config file |
| ProxySQL | Good routes by query rules and host groups the DBA writes | Good SQL rewrites, a query cache and a firewall list | Good many application connections share a smaller pool to the servers | Mixed extra service to run; config stored in SQLite inside the proxy |
How the answers decide
- “Do you run InnoDB Cluster or Group Replication for the fleet?” Yes, cluster-managed and “Must the proxy inspect, rewrite or cache SQL to meet an application constraint?” No, routing by role is enough and “Do many application pods each hold a pool to one busy MySQL fleet?” No, the sum fits comfortably: adds a caveat to ProxySQL. MySQL Router already fits: cluster metadata drives the routing and nothing extra is asked of the proxy.
- “Must the proxy inspect, rewrite or cache SQL to meet an application constraint?” Yes, SQL-aware rules are required: rules out MySQL Router. MySQL Router does not read SQL, so query rules, SQL rewrites and a query cache need a different proxy.
- “Do many application pods each hold a pool to one busy MySQL fleet?” Yes, we exceed max_connections: rules out MySQL Router. MySQL Router does not multiplex, so a fan-out that overruns max_connections needs connection multiplexing.
Answer the three questions in the table: if a fleet runs under InnoDB Cluster and
the application needs no SQL rewrites, MySQL Router is enough; if query rules,
caching, or many application pods pushing max_connections enter the picture,
ProxySQL earns the extra service.
Key idea
Every connection is a slot of max_connections while it runs; every pool, every
proxy and every timeout is a way to pack more useful work into those slots without
overrunning them.
Checkpoint
Check: MySQL Router or ProxySQL
Practise
The lesson’s text, examples and figures give the numbers to predict with: a pool of
3 at 300 requests per second and 5 ms in the database, a pool of 5 at 150 requests
per second and 20 ms, a sanity table that caps instances by max_connections, and
the WAIT_FOR_EXECUTED_GTID_SET return code that says the replica is caught up.
Try the practice set after the exercise:
Exercise · Easy · Python
Pool size and a fleet that fits
A team is sizing a MySQL connection pool and asks you to turn the numbers in the lesson into two tiny helpers.
Write both functions in pool_math.py.
pool_size(rate_rps, t_db_ms, target_use) returns the smallest positive integer pool size that keeps the busy connections under the target utilisation. The rate is in requests per second, t_db_ms is the mean time each request keeps a connection in milliseconds, and target_use is a fraction between 0 and 1. Follow Little's law: the average busy connections equal rate_rps * t_db_ms / 1000. Divide the busy count by the target utilisation and round up to the next whole connection (never return less than 1, because a pool of 0 would mean no connections at all).
pool_size(300, 5, 0.7)returns3, because300 * 0.005 / 0.7 = 2.14, rounded up.pool_size(150, 20, 0.7)returns5.pool_size(40, 80, 0.5)returns7.
fits_fleet(instances, size_per_app, max_connections, reserve) returns True when the sum of every instance's pool plus a reserve stays within max_connections, and False otherwise. The reserve is the slots the DBA keeps for replication, backups and the operator's own client.
fits_fleet(3, 20, 151, 10)returnsTrue, because3*20 + 10 = 70and70 <= 151.fits_fleet(10, 20, 151, 10)returnsFalse, because10*20 + 10 = 210.fits_fleet(5, 30, 151, 10)returnsFalse, because5*30 + 10 = 160.
Both functions are pure arithmetic; no database connection is involved. The math.ceil function in the standard library is useful for the pool-size rounding.
Starter code · pool_math.py
"""Pool math by Little's law. Replace the placeholder bodies with your own code."""
import math
def pool_size(rate_rps, t_db_ms, target_use):
"""Return the smallest positive integer pool size keeping busy under target_use.
busy = rate_rps * t_db_ms / 1000 (Little's law, in connections).
size = ceil(busy / target_use), with a floor of 1.
"""
# Replace with your calculation.
return 0
def fits_fleet(instances, size_per_app, max_connections, reserve):
"""Return True when instances * size_per_app + reserve <= max_connections."""
# Replace with your check (return True or False).
return None The sample tests · test_pool_math.py
"""Eight public tests, drawn from the lesson's own pool_math.py table and sanity rows.
The last one is the edge case: a fleet that fills every slot exactly still fits.
"""
from pool_math import fits_fleet, pool_size
def test_fast_reads_three_at_seventy_per_cent():
"""300 rps at 5 ms, 70 % utilisation: 1.5 busy -> ceil(1.5/0.7) = 3."""
assert pool_size(300, 5, 0.7) == 3
def test_mixed_five_at_seventy_per_cent():
"""150 rps at 20 ms, 70 %: 3 busy -> ceil(3/0.7) = 5."""
assert pool_size(150, 20, 0.7) == 5
def test_slow_seven_at_fifty_per_cent():
"""40 rps at 80 ms, 50 %: 3.2 busy -> ceil(3.2/0.5) = 7."""
assert pool_size(40, 80, 0.5) == 7
def test_pool_never_goes_below_one():
"""A tiny workload still gets at least one connection, so the function returns 1."""
assert pool_size(1, 1, 0.9) == 1
def test_three_times_twenty_fits_the_lab_server():
"""3 instances at 20 slots each plus 10 reserve = 70 <= 151, so True."""
assert fits_fleet(3, 20, 151, 10) is True
def test_ten_times_twenty_overruns_the_lab_server():
"""10 instances at 20 slots each plus 10 reserve = 210, which exceeds 151, so False."""
assert fits_fleet(10, 20, 151, 10) is False
def test_the_reserve_tips_five_times_thirty_over():
"""5 instances at 30 slots each plus 10 reserve = 160 > 151, so False."""
assert fits_fleet(5, 30, 151, 10) is False
def test_a_fleet_that_fills_every_slot_still_fits():
"""7 instances at 20 slots each plus 11 reserve = 151, exactly the limit, so True."""
assert fits_fleet(7, 20, 151, 11) is True A hint
Compute busy = rate_rps * t_db_ms / 1000.0 first, divide by target_use, and round up with math.ceil. Use max(1, ...) to keep the pool at least one connection. fits_fleet is one <= check against max_connections.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): what does a parameterised query do that building the SQL as a string does not?
A parameterised query (a prepared statement, or a driver’s client-side binding)
sends the SQL and each value apart. The SQL carries placeholders — %s in PyMySQL,
? in mysql2 and Connector/J — and the driver keeps each value out of that SQL
text, either as a typed binding on the wire or escaped into a literal of its own
type. The server treats a bound value as a VARCHAR, INT or whatever its type
is, not as more SQL, so a value that looks like SQL (' OR '1'='1, ; DROP TABLE app_user --) is compared as the whole string and matches no row rather than
rewriting the WHERE clause. The weak alternative builds the SQL by concatenating the
value into a string; a quote inside the value closes the string literal and
everything after it is read as SQL. OWASP’s SQL Injection Prevention Cheat Sheet
places parameterised queries first among the defences it names, with input validation
and least privilege as defences in depth. The check that proves the fix runs the
function with a hostile value and asserts the row count is what it would be for any
other unknown name: zero.
Two cases come next: a pool whose connections keep dying overnight, and the choice between MySQL Router and ProxySQL for a fleet that is outgrowing one node. Work each one out for yourself before you read its first step and the model answer:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Python application's pool keeps opening fresh connections overnight. By morning every connection in it meets "MySQL server has gone away" on the next borrow, and the server's error log shows no crash. What is happening, and which three settings on the pool and the server would you change?
-
Senior to staff
An application is outgrowing one MySQL node. Writes go to a source and reads spill to a two-replica fleet; the DBAs count 400 open connections across 20 pods on a server with max_connections = 151. Which proxy fits in front, and how would you set up the routing?
Warm-up (fresher): how does Little’s law set the size of a MySQL pool, and what limit sets the ceiling?
Little’s law says that the connections busy on average equal the request rate (in
requests per second) times the mean time each request keeps its connection (in
seconds). An application that handles 300 requests a second with 5 ms in the
database needs 1.5 busy connections on average, so a pool of 3 at seventy per cent
utilisation; one at 150 requests per second with 20 ms needs 3 busy, so 5 at
seventy per cent. The ceiling is the server’s max_connections: 151 by default on
MySQL 8.4.11, measured on the lab in this lesson. The sum of every application
instance’s pool, plus a reserve for replication, backups and the DBA’s own client,
must stay below that limit; the lesson’s sanity table shows ten instances with a
pool of 20 already overrun it. Pools much larger than Little’s law asks do not help
throughput and cost the server memory and context switches; pools much smaller
make requests queue inside the application.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does each open MySQL connection hold on the server?
A slot of max_connections for as long as it runs. Pools, proxies and timeouts are ways to fit more useful work into those slots without overrunning them.
-
Why pass values through placeholders such as %s or ? rather than building the SQL string?
The server then treats them as values, whatever they contain. A check that calls the function with a value that would change the shape of the SQL and asserts the row count stays the same fails on string-built SQL and passes on the fix.
-
How does Little's law size a MySQL connection pool?
Multiply the arrival rate by how long one request keeps its connection: 300 requests a second at 5 ms is 1.5 connections busy on average. Keep the sum of every pool well below max_connections, with a reserve for replication, backups and an operator.
-
How should a pool's timeouts relate to the server's?
Keep the pool's idle limit shorter than the server's wait_timeout, test a connection on borrow when even one error is unacceptable, and set each driver's I/O timeouts per call rather than per pool.
-
How can an application read its own write from a replica?
Take the GTID of its last commit and run WAIT_FOR_EXECUTED_GTID_SET on the replica before reading. Drivers that support session state tracking get that GTID from the server's reply; others SELECT it.
-
How do MySQL Router and ProxySQL differ?
Router routes by server role, from InnoDB Cluster metadata or a static list. ProxySQL adds SQL-aware rules and connection multiplexing, at the price of one more service to run and one more path for SQL.
Key takeaways
- A MySQL connection is a slot of
max_connectionswhile it runs; the three drivers in this lesson all take placeholders and verify TLS, and differ in where they prepare a statement and how they pool. - Pass values through placeholders —
%s,?— and the server treats them as values whatever their contents; a check that runs the function with a hostile value and asserts the row count fails on string-built SQL and passes on the fix. - Size a pool from Little’s law and the measured time each request keeps a
connection; keep the sum of every pool well below
max_connectionsand leave a reserve for admin tasks. - Keep the pool’s idle limit shorter than the server’s
wait_timeout, test the connection on borrow when a one-off error is unacceptable, and set each driver’s I/O timeouts per call, not per pool. - Read-your-writes on a replica uses the GTID of the client’s last commit and
WAIT_FOR_EXECUTED_GTID_SETon the replica; sessions whose driver supports session state tracking read the GTID from the server’s reply, othersSELECTit. - MySQL Router routes by server role from InnoDB Cluster metadata or a STATIC list; ProxySQL adds SQL-aware rules and connection multiplexing, at the cost of one more service and one more SQL path.
Terms in this lesson
- connection multiplexing
- An application proxy's trick (ProxySQL) that lets many application connections share a smaller pool of connections to the servers, borrowing a back-end slot only while a statement runs, so the server's max_connections stays comfortable under a fan-out.
- connection pool
- A small set of open MySQL connections an application borrows from and releases to for each request, so a new TCP+TLS handshake does not happen per request. Its size is set by Little's law and the server's max_connections.
- prepared statement
- A SQL text with placeholders (%s in PyMySQL, ? in mysql2 and Connector/J) that the driver sends with the values on each call, so user input stays a value and never becomes SQL. Drivers may prepare the statement on the server once or bind values client-side.
- read-your-writes
- A session guarantee that a read after a client's own commit sees that commit, even when the read lands on a replica. In MySQL the client reads the GTID of its commit on the source and asks WAIT_FOR_EXECUTED_GTID_SET on the replica to wait for it.
- session state tracking
- A MySQL feature (session_track_gtids, session_track_system_variables, session_track_schema) that returns changes in session state inside the server's OK_PACKET. OWN_GTID returns the GTID the session's last commit received.
References
- MySQL 8.4 Reference Manual: 7.1.12 Connection Management (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.1.18 Server Tracking of Client Session State (Oracle Corporation)
- MySQL 8.4 Reference Manual: Functions Used with GTIDs (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.1.8 Server System Variables (Oracle Corporation)
- MySQL 8.4 Reference Manual: Too many connections (Oracle Corporation)
- MySQL 8.4 Reference Manual: Communication Errors and Aborted Connections (Oracle Corporation)
- PyMySQL documentation: Connection object (PyMySQL contributors)
- MySQL Connector/J Developer Guide: Configuration Properties (Oracle Corporation)
- MySQL Connector/J Developer Guide: Connection URL Syntax (Oracle Corporation)
- MySQL Connector/J Developer Guide: Client Session State Tracker (Oracle Corporation)
- MySQL2 documentation: Prepared Statements (node-mysql2 contributors)
- MySQL Router 26.7: Configuration File Options (Oracle Corporation)
- ProxySQL Documentation: Multiplexing (ProxySQL)
- ProxySQL Documentation: configuring ProxySQL for MySQL (ProxySQL)
- OWASP SQL Injection Prevention Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress