Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with MySQL 8.4.11, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

One row, three drivers, verified TLS

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.

Interactive · step by step One request through the driver and the pool Step through one SELECT from the application thread to the server and back; the connection returns to the pool between requests.
One request through the driver and the pool, step 5 of 5. Showing App thread (has rows), Driver + pool (back in pool), MySQL server (reads request), Server thread (idle). In focus: Driver + pool.executeTLSApp threadhas rowsDriver + poolback in poolMySQL serverreads requestServer threadidle

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A request begins. The application thread calls execute(sql, params). It has not touched the network yet.
  2. 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.
  3. Send on the wire. The driver sends the SQL and the bound values over the TLS tunnel. The password never leaves the driver.
  4. 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.
  5. 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

  1. Question 1 of 2 The Python attempt at VERIFY_IDENTITY against the lab server was refused with "IP address mismatch". What is the server about?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The server's auto-generated certificate does not list 127.0.0.1 in its SAN, so the host-name check fails although the CA is trusted

    MySQL's --initialize-insecure certificate is good enough to encrypt a lab connection; a production certificate names the host, and VERIFY_IDENTITY then adds the SAN check that is missing here.

  2. Question 2 of 2 After the server returns the rows, what does the driver do with the connection?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Releases it to the pool, where the next request can borrow it

    The pool holds the connection open for the next request; the server thread goes idle until the next statement arrives on it.

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:

A hostile value, two paths, one check

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.

Interactive · step by step The same input, through two paths Watch the hostile value flow through string concatenation and through a parameter, and see what the server receives at the end.
The same input, through two paths, step 5 of 5. Showing Hostile value (quote + OR + 1=1), String concat (quote ends literal), Parameter (stays bound), Changed query (every row back), VARCHAR value (zero rows). In focus: VARCHAR value.Hostile valuequote + OR + 1=1String concatquote ends literalParameterstays boundChanged queryevery row backVARCHAR valuezero rows

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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?
  2. The weak path appends it. The weak program writes "SELECT ... WHERE name = '" + value + "'". The quote inside the value closes the string literal early.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Running inject_safe.py with the value "' OR '1'='1" returned how many rows?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0

    The parameter is sent to the server as a VARCHAR value, not as SQL, so the comparison runs against the whole hostile text and nothing matches.

  2. Question 2 of 2 You are adding a check to the project's build. Which shape fails on string-built SQL and passes on parameterised SQL?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Call find_user with the hostile value and assert len == 0; also call it with a known name and assert len == 1

    The point is to run the real function with a hostile value and compare row counts; the check follows the OWASP defence-with-proof pattern.

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 sizes by Little's law, on the server's own defaults

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.

Interactive · change it and see Why the last ten per cent of the pool costs so much Requests arrive at random at an average rate and one server handles them one at a time. Raise the arrival rate towards the service rate and watch the time in the system.
Mean time in the system by utilisation: mean time in the system (ms) against utilisation ρ = λ/μ (%).020406080100020406080100utilisation ρ = λ/μ (%)mean time in the system (ms)time in systemservice alone 10 msnow 80 %

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.

Mean time in the system (W) 50 ms
Utilisation (ρ) 80 %
Mean wait before service 40 ms
Mean number in the system (L = λW) 4
The numbers as a table
Mean time in the system with μ = 100 a second
UtilisationMean 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

  1. Question 1 of 2 By Little's law, how many busy connections does 150 requests per second at 20 ms in the database need, on average?

    Type a number.

    A hint

    150 * 0.020 =

    Show the answer: Question 1 of 2

    Answer: 3 connections (anything from 2.9 to 3.1 counts)

    150 requests a second times 0.020 seconds each gives 3 busy connections, which is the "busy" column in pool_math.py's middle row.

  2. Question 2 of 2 With max_connections = 151, ten instances each with a pool of 20 (plus a reserve of 10 for admin) would open how many slots and does it fit?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 210; it overruns the server's limit

    Pools pay their size per instance, so the sum has to fit under max_connections with an admin reserve left over.

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.

Interactive · stage by stage Where each timeout bites Follow a connection through its life, from the first open to the server's wait_timeout, and note which timeout fires at each stage.
Where each timeout bites, step 7 of 7. Showing Borrow from pool (idle slot or wait), Open on a miss (opened + pooled), Send a query, Wait for a reply (rows or timed out), Release to pool (back in pool), Idle in the pool, Server closes it (shut down). In focus: Server closes it.on a missno borrowBorrow from poolidle slot or waitOpen on a missopened + pooledSend a querywrite_timeoutWait for a replyrows or timed outRelease to poolback in poolIdle in the poolpool idle limitServer closes itshut down

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Release to the pool. On success the connection is released to the pool, where another request can borrow it right away.
  6. 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).
  7. 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

  1. Question 1 of 2 The pool's idle limit is 1 hour; the server's wait_timeout is 28,800 seconds (8 hours). What does a mismatch like this cause, or not?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No problem on this side: the pool closes an idle connection first, so the server never shuts one down under the pool

    Keeping the pool's idle limit under the server's wait_timeout stops "server has gone away" errors by making the pool close an idle connection before the server does.

  2. Question 2 of 2 Which timeout fires if a driver sends no bytes on a borrowed connection for more than the server's limit?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: wait_timeout: the server closes an idle connection, and the next borrow meets "MySQL server has gone away"

    For a non-interactive client, wait_timeout is the one that bites; the pool's own idle limit must stay below it so the client never meets the "gone away" error.

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).

Write on source, wait on replica, read your own row

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.

Interactive · message by message Read your writes with a GTID wait Follow one request: a write on the source, the GTID of that commit, and a read on the replica that waits on it.
Read your writes with a GTID wait, step 6 of 6. Messages so far: Client to Source: INSERT ...; Source to Client: gtid_executed; Client to Replica: WAIT_FOR_GTID; Replica to Client: 0 ok; Client to Replica: SELECT; Replica to Client: row.ClientSourceReplicaINSERT ...gtid_executedWAIT_FOR_GTID0 okSELECTrow

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. INSERT on the source. The client commits an INSERT on the source. The source writes the row and gives the transaction its GTID.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 In ryw.py, WAIT_FOR_EXECUTED_GTID_SET returned 0. What has the server promised next?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every transaction up to the GTID set has been applied on this replica, so a read on this connection sees them

    0 means the replica has applied every transaction up to the set at some point before the function returned; the next read on that connection is guaranteed to see it.

  2. Question 2 of 2 A client just committed a profile change on the source and is about to render the profile page. Which approach keeps the replica traffic and still returns the fresh row?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Read gtid_executed after the commit, open a connection to the replica, call WAIT_FOR_EXECUTED_GTID_SET on that set with a short timeout, then SELECT

    WAIT_FOR_EXECUTED_GTID_SET turns one read into a wait on the GTID of the commit; the replica returns the fresh row and the source keeps its load profile.

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:

MySQL Router 26.7.0 with STATIC routing on 127.0.0.1

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.

Interactive · answer and compare Which proxy fits your fleet? Answer three questions about your fleet and your traffic; the table shows what each proxy fits and where it rules the other one out.
Do you run InnoDB Cluster or Group Replication for the fleet?
Must the proxy inspect, rewrite or cache SQL to meet an application constraint?
Do many application pods each hold a pool to one busy MySQL fleet?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

MySQL Router and ProxySQL on four jobs an application proxy might take on
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

  1. Question 1 of 2 The lesson's Router session showed "rw -> server_id 1" and "ro -> 2, 1, 2". Which routing is this an example of?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: STATIC routing: a hand-written list of destinations with round-robin for the read-only route and first-available for the primary

    STATIC routing is enough for a lab or a small fleet; InnoDB Cluster metadata is what the router reads in a production deployment, but the shape of the routing is the same.

  2. Question 2 of 2 A fleet runs under InnoDB Cluster; apps need no SQL rewrites or caching; the sum of every pool fits under max_connections. Which proxy fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: MySQL Router, because routing by role from cluster metadata is what the application needs and no extra service is called for

    The Tradeoffs table rules ProxySQL out as a caveat here because the application has none of the problems ProxySQL solves beyond what Router already does.

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) returns 3, because 300 * 0.005 / 0.7 = 2.14, rounded up.
  • pool_size(150, 20, 0.7) returns 5.
  • pool_size(40, 80, 0.5) returns 7.

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) returns True, because 3*20 + 10 = 70 and 70 <= 151.
  • fits_fleet(10, 20, 151, 10) returns False, because 10*20 + 10 = 210.
  • fits_fleet(5, 30, 151, 10) returns False, because 5*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.

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.

  1. Question 1 of 12 Which statement about the three drivers in this lesson is correct?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: PyMySQL leaves pooling to the application; mysql2 offers a built-in promise pool; Connector/J ships with a built-in pool

    The three drivers agree on parameterisation and TLS; they differ in how (and whether) they pool connections.

  2. Question 2 of 12 Which placeholder does mysql2 (Node.js) use for a bound value in execute()?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: ?

    Both mysql2 (Node.js) and Connector/J (Java) use ? for a positional parameter; PyMySQL uses %s because its API shape comes from the DB-API 2.0 "format" paramstyle.

  3. Question 3 of 12 Why does passing a value through a placeholder stop SQL injection even when the server has no server-side prepared statement cached?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The driver escapes the value and wraps it in a quoted literal of the right type, so a quote inside the value is sent as text and not SQL

    Either a server-side prepare or client-side binding keeps user input a value; string concatenation makes it SQL, which is the mistake.

  4. Question 4 of 12 Why is "I escape every quote in user input before building the SQL" still unsafe compared with passing values as parameters?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Escapes miss SQL comments, numeric and keyword contexts, and encoding tricks; a parameter does not care which context the value lands in

    The OWASP Cheat Sheet names parameterised queries as the first defence; hand escaping is listed as a last resort when parameters are not available.

  5. Question 5 of 12 By Little's law, how many busy connections does 300 requests per second at 5 ms in the database need, on average?

    Type a number.

    A hint

    300 * 0.005 =

    Show the answer: Question 5 of 12

    Answer: 1.5 connections (anything from 1.4 to 1.6 counts)

    300 * 0.005 = 1.5 busy, matching pool_math.py's first row; dividing by a target utilisation of 0.7 gives a pool of 3.

  6. Question 6 of 12 A plan sets every pool to twice the busy count. Why not leave the pool at the busy count exactly?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Busy is an average; a short burst would queue requests inside the application without headroom

    A pool twice the busy count keeps utilisation around 50 %, which absorbs a 2x burst without a queue forming.

  7. Question 7 of 12 Which timeout should fire first on an idle connection sitting in the pool?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The pool's own idle limit (client-side), so the driver closes the connection before the server does

    Keep the pool's idle limit shorter than wait_timeout; a borrow then never meets a connection the server has already closed.

  8. Question 8 of 12 A long-running analytical query times out at the driver's read_timeout but the server keeps running it. What is the cleanest fix?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Raise read_timeout on that one call, and set a server-side MAX_EXECUTION_TIME hint on the SELECT

    A per-call read_timeout and a MAX_EXECUTION_TIME hint on the SELECT together cap how long any one query may take without touching every other call.

  9. Question 9 of 12 Why does the lesson's ryw.py SELECT @@global.gtid_executed instead of reading it from an OK_PACKET?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: PyMySQL 1.2.3 does not expose session state tracking, so the program reads the value with a SELECT; drivers that do expose it (Connector/J) save a round trip

    session_track_gtids = OWN_GTID lets a tracking-aware driver skip the SELECT; the lesson's PyMySQL does not read tracking fields, so it falls back to the SELECT.

  10. Question 10 of 12 Without read-your-writes, how might a user notice replication lag?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: They save a profile change on the source, reload the page, and the page shows the old value for a moment

    The symptom is a visible stale read right after a write, exactly what the WAIT_FOR_EXECUTED_GTID_SET pattern fixes.

  11. Question 11 of 12 In the Router session's config, which setting made the read-only route cycle over A and B?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: routing_strategy=round-robin on the read-only route, with destinations=A,B

    round-robin on the ro route rotates destinations; first-available on the rw route keeps A as long as it is up.

  12. Question 12 of 12 Twenty application pods each hold a pool of 20 to one MySQL fleet (max_connections = 151). Which proxy change gets the fleet comfortable again?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Front the fleet with ProxySQL and let it multiplex the 400 pod connections onto a smaller back-end pool

    Many application pods pushing max_connections is the case the Tradeoffs table flags for connection multiplexing; ProxySQL does it, Router does not.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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_connections while 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_connections and 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_SET on the replica; sessions whose driver supports session state tracking read the GTID from the server’s reply, others SELECT it.
  • 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.