MySQL & MariaDB in Depth Module 13 – Upgrades: 8.4 LTS to 9.7 LTS and 26.x
MySQL upgrade paths: 8.0 to 8.4, 9.7 and 26.x
The lineage rules MySQL enforces, the pre-upgrade checks that catch problems first, and the recorded 8.4.11 to 9.7.2 to 26.7.0 chain on one directory.
What you will learn
- Identify which direct upgrades between MySQL 8.0, 8.4, 9.7 and 26.7 the manual supports
- Compare in-place upgrades, logical dump and load, cloning and rolling replicas against a service's constraints
- Run the pre-upgrade checks (MySQL Shell upgrade checker and mysqlcheck) and read their report
- Walk through the recorded 8.4.11 to 9.7.2 to 26.7.0 chain and name the error 26.7.0 returns on an 8.4.11 directory
- Plan replicas-first ordering and rollback for a cross-line upgrade
Before you start
What you’ll learn · Before you start
What you will learn
- Identify which direct upgrades between MySQL 8.0, 8.4, 9.7 and 26.7 the manual supports
- Compare in-place upgrades, logical dump and load, cloning and rolling replicas against a service's constraints
- Run the pre-upgrade checks (MySQL Shell upgrade checker and mysqlcheck) and read their report
- Walk through the recorded 8.4.11 to 9.7.2 to 26.7.0 chain and name the error 26.7.0 returns on an 8.4.11 directory
- Plan replicas-first ordering and rollback for a cross-line upgrade
Before you start
On this page
MySQL 8.0 reaches 26.7 one line at a time, through 8.4 LTS and then 9.7 LTS, and the server polices every hop: a
26.7.0 server will not start on an 8.4.11 data directory, and its error MY-014060 says the lineage is wrong. Each
hop is a short sequence rather than one command: a readiness check, a backup labelled for rollback, a slow shutdown,
a binary swap, a start that upgrades the data dictionary, and a smoke query against the running server.
You will read the rules MySQL enforces between its lines, recapped from the lesson on MySQL and MariaDB versions, pick a method against a service’s constraints, run the pre-upgrade checks on a seeded 8.4.11 server, walk through the recorded 8.4.11 to 9.7.2 to 26.7.0 chain on one data directory, and plan replicas-first ordering and rollback for a cross-line move.
Lineages decide which hops are supported
Section 1.3 of the MySQL 26.7 Reference Manual gives every calendar-versioned release a compatibility lineage: A group of MySQL releases inside which an in-place upgrade may move forward: the Innovation releases before an LTS release and the LTS release that closes them. An LTS release may also step into the immediately following lineage, but never skip one. in its release metadata: the Innovation releases before an LTS release, plus the LTS release that closes them. An Innovation release upgrades in place to a later release of its own lineage, up to and including that LTS, and never across a lineage boundary. An LTS release upgrades within its own release line or into the immediately following lineage, Innovation releases included, and never skips a lineage. The last sequentially numbered LTS, 9.7, is the only line that may step directly into the first calendar-versioned lineage, which is anchored by 9.7.0 itself. For the numbered lines before it, Table 3.1 of the 9.7 manual says the same in terms of series: an LTS or bugfix series such as 8.0 moves to the next LTS series or to an Innovation release before it, and no LTS or bugfix series can be skipped. Everything else refuses.
- 8.0
The outgoing 8.0 line bugfix series
A bugfix series, as the 9.7 manual's upgrade-path table calls it, now covered under Oracle Sustaining Support according to MySQL's EOL notices. Its next LTS line is 8.4, and no supported path to 9.7 or 26.7 skips it.
- 8.4
MySQL 8.4 LTS LTS
An LTS line. It upgrades within 8.4.x, to a 9.x Innovation release, or to 9.7 LTS, the next LTS line; a direct jump to 26.7 would skip 9.7 and is refused.
- 9.0 to 9.6
Innovation releases between 8.4 and 9.7 Innovation
Each release adds features and may change behaviour. They upgrade to a later 9.x release up to 9.7 LTS, which closes their series, and never past it in one step.
- 9.7
MySQL 9.7 LTS LTS, bridge
The final sequentially versioned LTS line, and the only line that may upgrade directly into the first calendar-versioned lineage, which 9.7.0 anchors. In-place upgrades move within 9.7.x or to 26.7.
- 26.7
MySQL 26.7 Innovation
The first calendar-versioned release and the opening Innovation release of its lineage. In-place upgrades inside the lineage move only forward, and a 26.7 server refuses to start on a data directory produced by an older line that is not the immediately preceding LTS.
The timeline has 5 events, from 8.0 to 26.7.
The order the manual gives, read as a decision for a server in front of you: 8.0 reaches 26.7 only through 8.4 and 9.7, one line at a time; 8.4 reaches 26.7 only through 9.7; 9.7 reaches 26.7 directly through the bridge; and 26.7 reaches a later 26.x Innovation release inside its own lineage. The lesson on MySQL and MariaDB versions covers the release model itself, so this one takes those rules as given and applies them.
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Start on 8.0. The server runs 8.0.x. Its next LTS line is 8.4, reached in place, by a logical dump and load into a fresh 8.4 server, or through an 8.4 replica; a direct move to 9.7 or 26.7 is not a supported path by any method.
- Hop to 8.4 LTS. Shut down 8.0 with innodb_fast_shutdown = 0, replace the binaries with 8.4 LTS, start the server. The data dictionary and the server upgrade in place; accounts still on mysql_native_password need --mysql-native-password=ON here, on the last line that has the plugin.
- Hop to 9.7 LTS. Shut down 8.4 cleanly and start 9.7. The data dictionary moves from 80300 to 90200 and the server from the 8.4 version number to 9.7's; the row count and GTID state carry across. A service that wants stability can stop here for its LTS window.
- Hop to 26.7. Only a 9.7 server may upgrade directly to the first calendar-versioned release. Shut 9.7 down with a slow shutdown, start 26.7, and the server upgrades through the one supported bridge into the new lineage.
Checkpoint
Check: Lineages and the hops they allow
In-place, logical dump or rolling replicas
Once the hop is known, the method is the next decision. MySQL supports four, and 9.7 Reference Manual 3.2 pairs each with the kind of hop it covers. An in-place upgrade: An upgrade that stops the old server, swaps the binaries and starts the new server on the same data directory, letting the new server upgrade the data dictionary and server metadata without copying rows. stops the old server, swaps the binaries and starts the new server on the same data directory, which is why it is bound to a single compatibility step. A logical upgrade: An upgrade carried out as a logical dump of the old server and a load into a fresh target server: SQL rebuilds the schema and rows on the new version while the old server stays untouched. The manual supports it on the same one-step paths as an in-place upgrade. dumps the schema and rows out of the old server and loads them into a fresh target server at the new version, so the old server stays untouched. The clone plugin: A server plugin, mysql_clone.so, that copies a running donor server's data over the network into a recipient server: CLONE INSTANCE FROM removes the recipient's own schemas, tables and binary logs and writes the donor's data in their place. More in “Set up GTID replication with TLS” is a physical copy from a donor into a recipient, and Table 3.1 lists it only for a move inside one LTS or bugfix series, such as 8.4.0 to 8.4.4. A rolling replica upgrade walks the one-step rule across the servers of a topology: replicas first, then the source. The same table lists in-place, logical and replication methods on every supported hop and none on a hop that skips a line, so the method never changes the path.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Method | Downtime of the service | Scales to large data | Moves to the next line | Easy to roll back |
|---|---|---|---|---|
| In-place binaries What it is: swap mysqld on the same data directory | Mixed one shutdown and one startup; the server upgrade is the slow part | Good no copy; the data directory stays where it is | Good supported on every one-step hop of Table 3.1; never skips a line | Poor no in-place downgrade across lines; keep a labelled backup taken on the old version |
| Logical dump and load What it is: mysqldump or MySQL Shell dump into a fresh target | Mixed changes after the dump point must be stopped or replicated into the new target | Mixed dump and load are parallelised yet still bound by the size of the data | Good supported on the same one-step hops as in-place; the old server stays untouched | Good the dump stands as a labelled snapshot; the old server never changed |
| Clone plugin What it is: physical copy from a donor into a fresh recipient | Good the donor keeps serving reads during the copy; the recipient catches up afterwards | Good physical copy; often faster than a logical load for terabyte-scale data | Poor Table 3.1 lists clone only inside one LTS or bugfix series, such as 8.4.0 to 8.4.4 | Mixed the donor is untouched so a rollback uses the donor; the recipient is thrown away |
| Rolling replication What it is: upgrade replicas first; then switch and upgrade the source | Good reads and writes keep going while servers upgrade one at a time; writes pause only for the switchover | Good no bulk copy; each server upgrades in place on its own data directory | Good replicas run the newer line first; the source follows on the same one-step path | Mixed the first unupgraded replica is the fallback until it is upgraded too; the plan still needs a backup |
How the answers decide
- “What is the hop?” Two or more lines, e.g. 8.0 to 26.7: rules out Clone plugin and adds a caveat to In-place binaries, Logical dump and load and Rolling replication. No method skips a line. Plan one hop per line (8.0 to 8.4, 9.7, then 26.7) and pick a method for each hop.
- “What is the hop?” To the next LTS line, e.g. 8.4 to 9.7: rules out Clone plugin. Clone copies only between releases of one series, so it cannot carry a move to the next LTS line.
- “How long can the service be unavailable for writes?” Essentially none; read and write throughout: rules out In-place binaries and Logical dump and load. Both ask for a shutdown or a frozen window; a rolling replica upgrade or a clone keeps the service answering during the move.
- “How much data is in this server?” Several hundred GB or more and “How long can the service be unavailable for writes?” Minutes to tens of minutes: rules out Logical dump and load. A logical dump and load scales linearly with data size, so it rarely fits a minutes-sized window on hundreds of gigabytes.
Read the table left to right against the service in front of you. A terabyte server moving from 8.0 to 26.7 makes three hops whatever the method, and in-place keeps each hop to one slow shutdown and one start, where a logical dump and load copies every row of the terabyte on each hop. A service that can pause writes only for a switchover upgrades its replicas first and then switches over, the one moment the manual’s replication-upgrade procedure stops client updates on the source. A service with a maintenance window and one hop (8.4 to 9.7) picks the in-place path for its speed.
Checkpoint
Check: Four upgrade methods
Pre-upgrade checks run before any binary is swapped
Before the shutdown, every supported upgrade starts with two checks the manual recommends: mysqlcheck --check-upgrade against the current server, which runs CHECK TABLE ... FOR UPGRADE on every table, and MySQL
Shell’s upgrade checker: MySQL Shell's util.checkForServerUpgrade() utility, which reads a running server against a target version and reports issues (reserved words, obsolete settings, old authentication plugins) that would stop or disturb the upgrade. util.checkForServerUpgrade(), which reads the target
version against the live instance and reports issues from reserved words to obsolete settings to deprecated
authentication plugins. The checker’s report is short on a clean server and long on a crowded one; with JSON output
it can feed an operator’s own tooling.
checker_session.py
"""The pre-upgrade checks the manual asks for before replacing the server binaries.
Two tools, one 8.4.11 instance seeded with three objects for the checks to judge:
1. A table named CUBE with a column named QUALIFY. Both words are reserved in the 8.4 and the 26.7 keyword lists
(Reference Manual, 11.3 "Keywords and Reserved Words"), so on either version they work as names only when
quoted. The 26.7.0 release notes list them, with EXTERNAL and TABLESAMPLE, among the words that were not
properly defined as reserved keywords.
2. An account that still authenticates with mysql_native_password, re-enabled on 8.4 only through the
--mysql-native-password=ON start option (the plugin is disabled by default in 8.4 and removed in 9.0.0,
MySQL 8.4 Reference Manual, 8.4.1.1).
3. A plain sales.orders table, so mysqlcheck has a clean table to show.
Then:
- mysqlcheck --check-upgrade (MySQL 8.4 Reference Manual, "4.5.3 mysqlcheck"): runs CHECK TABLE ... FOR UPGRADE on
every table and prints a one-line status per table.
- MySQL Shell 26.7.0's util.checkForServerUpgrade() with outputFormat = "JSON" (MySQL Shell 26.7, "12.2 Upgrade
Checker Utility"), first with targetVersion = "26.7.0", then with "9.7.0", the first hop of the supported path
from 8.4. Each report prints the serverVersion, the targetVersion, the error/warning/notice totals and only the
checks that found something; the full JSON report is what a deployment script would read.
The driver masks ports and the run folder, and the Shell runs with --no-wizard inside a config home pinned to the
run folder (MYSQLSH_USER_CONFIG_HOME), so every run prints the same output and writes nothing outside its folder.
"""
import json
import re
import subprocess
import sys
from mysql_session import Server, tarball
MYSQLSH = tarball("mysql-shell-26.7.0") / "bin" / "mysqlsh"
SEED = """
-- Two words reserved in both the 8.4 and the 26.7 keyword lists, used as quoted names:
-- CUBE for a table and QUALIFY for a column.
CREATE DATABASE sales;
CREATE TABLE sales.`CUBE` (
id INT PRIMARY KEY,
`QUALIFY` VARCHAR(40)
);
INSERT INTO sales.`CUBE` VALUES (1, 'north'), (2, 'south');
-- A regular shop table so mysqlcheck's output has at least one clean 'OK' row.
CREATE TABLE sales.orders (id INT PRIMARY KEY, total DECIMAL(10,2));
-- An account on mysql_native_password: the plugin is OFF by default on 8.4, so this needs
-- --mysql-native-password=ON on the server start.
CREATE USER 'legacy_app'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY 'oldpass';
GRANT SELECT ON sales.* TO 'legacy_app'@'127.0.0.1';
"""
def run_mysqlsh_check(server, target):
"""Runs util.checkForServerUpgrade(null, {targetVersion, outputFormat: 'JSON'}) through mysqlsh and returns the
parsed JSON. The explicit null for the connectionData argument tells the function to use the current session,
which stops MySQL Shell from parsing the options dict as a connection spec. The config home is pinned to the
run folder, so mysqlsh writes nothing outside it."""
cfg = server.base / "mysqlsh-home"
cfg.mkdir(exist_ok=True)
uri = f"mysql://root:{server.password}@127.0.0.1:{server.port}?ssl-mode=DISABLED"
env = dict(server.env)
env["MYSQLSH_USER_CONFIG_HOME"] = str(cfg)
js = (
'println(JSON.stringify(util.checkForServerUpgrade('
'null, {targetVersion: "' + target + '", outputFormat: "JSON"})))'
)
done = subprocess.run(
[str(MYSQLSH), "--no-wizard", "--log-level=1", "--mysql", "--uri", uri, "--js", "-e", js],
env=env, cwd=server.base, capture_output=True, text=True,
)
if done.returncode != 0:
print(server.clean(done.stdout))
print(server.clean(done.stderr))
sys.exit(f"mysqlsh ended with status {done.returncode}")
# Shell prints the JSON on standard output; standard error carries a password-in-URI warning we ignore.
text = done.stdout
for match in re.finditer(r"\{.*\}", text, re.DOTALL):
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
continue
sys.exit("could not find the Shell JSON report on standard output")
def summarize(report, target):
"""Prints the command a deployment script would run and the short summary of its JSON report: the versions, the
error/warning/notice totals and one line per check that found something, with its problems counted by level."""
print("$ mysqlsh --no-wizard --uri root@127.0.0.1:<port> -- util check-for-server-upgrade"
f" --target-version={target} --output-format=JSON")
# Clean the serverVersion string (it starts with the real version plus a build tag that stays stable here).
print(f"serverVersion: {report['serverVersion'].split(' - ')[0]}")
print(f"targetVersion: {report['targetVersion']}")
print(f"errorCount: {report['errorCount']} warningCount: {report['warningCount']} "
f"noticeCount: {report['noticeCount']}")
print("checks that found something:")
for check in report.get("checksPerformed", []):
problems = check.get("detectedProblems") or []
if not problems:
continue
levels = {}
for p in problems:
levels[p["level"]] = levels.get(p["level"], 0) + 1
counts = ", ".join(f"{k.lower()}s={v}" for k, v in sorted(levels.items()))
print(f" {check['id']}: {counts}")
manual = report.get("manualChecks") or []
if manual:
print(f"manual checks the utility reports: {len(manual)}")
with Server(options=["--mysql-native-password=ON"]) as my:
print("# 1. Start MySQL 8.4.11 with --mysql-native-password=ON (the plugin is OFF by default on 8.4).")
print("# Seed one reserved-word table, one plain table and one native-password account.")
my.up()
my._call(["mysql", f"--defaults-file={my.client_cnf}"], stdin=SEED)
my.mask(re.escape(my.password), "<password>")
my.sql(
"SELECT COUNT(*) AS reserved_tables FROM information_schema.tables "
"WHERE table_schema='sales' AND table_name='CUBE'; "
"SELECT user, host, plugin FROM mysql.user WHERE user='legacy_app'",
note="what the seed left behind on the 8.4.11 server",
)
print("# 2. mysqlcheck --check-upgrade: a one-line status per table against the current server")
print("# (CHECK TABLE ... FOR UPGRADE). Clean tables read 'OK'; a table that will not survive the upgrade")
print("# reads another status.")
my.shell("mysqlcheck", "--defaults-file=client.cnf", "--check-upgrade", "sales",
shown=["mysqlcheck", "--defaults-file=client.cnf", "--check-upgrade", "sales"])
print("# 3. MySQL Shell 26.7.0's util.checkForServerUpgrade() targeting 26.7.0, two hops away through 9.7 LTS.")
print("# The JSON report is long, so the run prints its totals and the checks that found something.")
summarize(run_mysqlsh_check(my, "26.7.0"), "26.7.0")
print()
print("# 4. The same checker targeting 9.7.0, the first hop of the supported path from 8.4.")
summarize(run_mysqlsh_check(my, "9.7.0"), "9.7.0") Output
# 1. Start MySQL 8.4.11 with --mysql-native-password=ON (the plugin is OFF by default on 8.4). # Seed one reserved-word table, one plain table and one native-password account. -- what the seed left behind on the 8.4.11 server $ mysql -t -e "SELECT COUNT(*) AS reserved_tables FROM information_schema.tables WHERE table_schema='sales' AND table_name='CUBE'; SELECT user, host, plugin FROM mysql.user WHERE user='legacy_app'" +-----------------+ | reserved_tables | +-----------------+ | 1 | +-----------------+ +------------+-----------+-----------------------+ | user | host | plugin | +------------+-----------+-----------------------+ | legacy_app | 127.0.0.1 | mysql_native_password | +------------+-----------+-----------------------+ # 2. mysqlcheck --check-upgrade: a one-line status per table against the current server # (CHECK TABLE ... FOR UPGRADE). Clean tables read 'OK'; a table that will not survive the upgrade # reads another status. $ mysqlcheck --defaults-file=client.cnf --check-upgrade sales sales.cube OK sales.orders OK # 3. MySQL Shell 26.7.0's util.checkForServerUpgrade() targeting 26.7.0, two hops away through 9.7 LTS. # The JSON report is long, so the run prints its totals and the checks that found something. $ mysqlsh --no-wizard --uri root@127.0.0.1:<port> -- util check-for-server-upgrade --target-version=26.7.0 --output-format=JSON serverVersion: 8.4.11 targetVersion: 26.7.0 errorCount: 1 warningCount: 21 noticeCount: 0 checks that found something: sysVars: warnings=21 authMethodUsage: errors=1 # 4. The same checker targeting 9.7.0, the first hop of the supported path from 8.4. $ mysqlsh --no-wizard --uri root@127.0.0.1:<port> -- util check-for-server-upgrade --target-version=9.7.0 --output-format=JSON serverVersion: 8.4.11 targetVersion: 9.7.0 errorCount: 1 warningCount: 21 noticeCount: 0 checks that found something: sysVars: warnings=21 authMethodUsage: errors=1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checker_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 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, and tarball(name) finds one.
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 and the pinned MySQL installations, 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)
def tarball(name):
"""The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
<name> in a folder called toolchains, looked for in every folder above the one 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)
candidates = [parent / "toolchains" / name]
if (parent / "tmp").is_dir():
candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
for found in candidates:
if (found / "bin").is_dir():
return found
sys.exit(f"no toolchains folder above this one holds {name}")
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().
"""
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 / "mysqld").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")})
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 / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *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(LOG_PREFIX.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 _ping(self):
code, _ = self._call(["mysqladmin", 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(["mysqladmin", 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(LOG_PREFIX.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 = ["mysql", 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 = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", 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 = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", 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(["mysql", 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"mysql> {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() Step 1 of the recorded run seeds the 8.4.11 server with two things for the checks to judge and one control: a table
`CUBE` with a column `QUALIFY`, two of the four words the
MySQL 26.7.0 release notes say were not
properly defined as reserved keywords (with EXTERNAL and TABLESAMPLE); a legacy_app account on
mysql_native_password, a plugin 8.4 ships disabled, re-enabled here with --mysql-native-password=ON, and removed
in 9.0.0; and a plain sales.orders table.
Step 2 runs mysqlcheck --check-upgrade sales and reads OK for both tables: CHECK TABLE ... FOR UPGRADE looks
for storage-level incompatibilities with the server version, not for names or settings. Step 3 runs the MySQL Shell
26.7.0 checker against the same server, targeting 26.7.0, and prints a short summary of its JSON report:
errorCount: 1 warningCount: 21. The error is in authMethodUsage, which the Shell manual says catches deprecated
or invalid user authentication methods: legacy_app cannot log in on 9.x or 26.x, so it moves to
caching_sha2_password (or is dropped) before the upgrade. The warnings are in sysVars, which reports variables
whose default differs on the target or whose value is deprecated there; the
configuration lesson compares the defaults of fresh 8.4.11, 9.7.2
and 26.7.0 servers and names the larger changes, such as gtid_mode turning ON in 9.7.
Step 4 targets 9.7.0, the first hop of the supported path, and gets the same report from the same two checks, so
both findings belong to the first hop and the plan clears them before 8.4 moves to 9.7. Neither report names the
CUBE table, which fits the keyword lists: the 8.4 manual already marks CUBE, QUALIFY and TABLESAMPLE as
reserved, so on 8.4 such names work only quoted and the move adds no clash. Of the four, only EXTERNAL is new to
this move: the 9.7 manual lists it, with LIBRARY, among the reserved words added since 8.4, so a table, column or
query that uses either word unquoted is the one to fix before 8.4 moves to 9.7.
Key idea
The pre-upgrade check is the cheapest point to fix an account, a setting or a name: the old server still runs and nothing has changed on disk. Walk every error to zero, read every warning, write the kept warnings into the plan, and only then take the backup.
Checkpoint
Check: The pre-upgrade checks
The in-place chain on one data directory
A supported in-place chain is a sequence of clean shutdowns and binary swaps. Before each shutdown the run executes
SET GLOBAL innodb_fast_shutdown = 0, so InnoDB takes a slow shutdown: The shutdown MySQL takes when innodb_fast_shutdown is set to 0, which does a full purge and a change buffer merge before stopping, so the data directory is in a steady state the next server can upgrade.: it runs the
purge to completion and merges the change buffer, and only then stops. The 9.7 manual’s entry for
innodb_fast_shutdown (17.14) advises this before an upgrade or downgrade between major releases, so the data files
are ready if the new version changes their format; section 3.6 itself asks for a server that was shut down properly,
and for a slow shutdown after an unexpected stop. The recorded run below walks one directory through 8.4.11, a
refusal from 26.7.0 and the supported chain 8.4.11 to 9.7.2 to 26.7.0, with a row the chain carries across every hop:
upgrade_chain_session.py
"""The in-place upgrade chain on one data directory: 8.4.11 -> 9.7.2 -> 26.7.0, and the refusal from 26.7.0 against
an 8.4.11 data directory. One server, one data directory, three MySQL versions swapped under it.
Rules the output demonstrates (MySQL 26.7 Reference Manual, 1.3 "MySQL Releases: Innovation and LTS", and 9.7
Reference Manual, 3.2 "Upgrade Paths"):
- An LTS release can upgrade within its own line or into the immediately following compatibility lineage, and only
9.7.x LTS may step directly into the first calendar-versioned lineage, the one 26.7.0 belongs to.
- A direct 8.4.11 to 26.7.0 start therefore fails with MY-014060: the source and target are not in the same
compatibility lineage, and 8.4 is not the immediately preceding LTS for 26.7 (9.7 is).
- Every stop before a swap is a slow shutdown (innodb_fast_shutdown = 0): InnoDB finishes a full purge and a change
buffer merge before it stops, which the 9.7 manual's entry for innodb_fast_shutdown (17.14) advises before an
upgrade or downgrade between major releases.
The mysql_session driver masks ports, basedirs and the run folder and strips log timestamps, so every run prints
the same output.
"""
from pathlib import Path
from mysql_session import Server, tarball
def switch(my, basedir):
"""Point this server at another unpacked MySQL tarball, so the next my.start() runs that version's mysqld on the
same data directory."""
my.basedir = Path(basedir).resolve()
my.bin = my.basedir / "bin"
with Server(redo="8M") as my:
print("# 1. Initialise and start MySQL 8.4.11. Write a database the chain will carry across every upgrade.")
my.up()
my.sql(
"CREATE DATABASE shop; "
"CREATE TABLE shop.orders (id INT PRIMARY KEY, total DECIMAL(10,2)); "
"INSERT INTO shop.orders VALUES (1, 499.00), (2, 1250.50); "
"SELECT VERSION()"
)
print("# 2. Slow-shutdown the 8.4.11 server, then start 26.7.0 against the same data directory.")
print("# 26.7.0 refuses: 8.4 is not the immediately preceding LTS for 26.7, and the two lineages differ.")
my.query("SET GLOBAL innodb_fast_shutdown = 0")
my.stop()
switch(my, tarball("mysql-26.7.0"))
my.start(expect_fail=True)
print("# 3. Switch to 9.7.2 and start on the same directory. 9.7 is the immediately following LTS, so the server")
print("# upgrades the data dictionary (80300 -> 90200) and the server metadata (80411 -> 90702), then runs.")
switch(my, tarball("mysql-9.7.2"))
my.start()
my.sql("SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders")
my.show_log("MY-01(1090|3413|3381)", note="data-dictionary and server upgrade lines")
print("# 4. Slow-shutdown 9.7.2 and start 26.7.0 against the same directory.")
print("# 9.7 LTS is the one bridge into the first calendar lineage, so 26.7.0 upgrades and starts.")
my.query("SET GLOBAL innodb_fast_shutdown = 0")
my.stop()
switch(my, tarball("mysql-26.7.0"))
my.start()
my.sql("SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders")
my.show_log("MY-013381", note="server upgrade from 9.7.2 (90702) to 26.7.0 (260700)") Output
# 1. Initialise and start MySQL 8.4.11. Write a database the chain will carry across every upgrade. $ mysql -t -e "CREATE DATABASE shop; CREATE TABLE shop.orders (id INT PRIMARY KEY, total DECIMAL(10,2)); INSERT INTO shop.orders VALUES (1, 499.00), (2, 1250.50); SELECT VERSION()" +-----------+ | VERSION() | +-----------+ | 8.4.11 | +-----------+ # 2. Slow-shutdown the 8.4.11 server, then start 26.7.0 against the same data directory. # 26.7.0 refuses: 8.4 is not the immediately preceding LTS for 26.7, and the two lineages differ. [ERROR] [MY-014060] [Server] Invalid MySQL server upgrade: Cannot upgrade from 80411 to 260700. The source and target must be in the same compatibility lineage, unless the source is the immediately preceding LTS for the target. [ERROR] [MY-010020] [Server] Data Dictionary initialization failed. [ERROR] [MY-010119] [Server] Aborting (mysqld ended with status 1) # 3. Switch to 9.7.2 and start on the same directory. 9.7 is the immediately following LTS, so the server # upgrades the data dictionary (80300 -> 90200) and the server metadata (80411 -> 90702), then runs. $ mysql -t -e "SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders" +-----------+ | VERSION() | +-----------+ | 9.7.2 | +-----------+ +--------+ | orders | +--------+ | 2 | +--------+ $ grep -E "MY-01(1090|3413|3381)" mysqld.err # data-dictionary and server upgrade lines [System] [MY-011090] [Server] Data dictionary upgrading from version '80300' to '90200'. [System] [MY-013413] [Server] Data dictionary upgrade from version '80300' to '90200' completed. [System] [MY-013381] [Server] Server upgrade from '80411' to '90702' started. [System] [MY-013381] [Server] Server upgrade from '80411' to '90702' completed. # 4. Slow-shutdown 9.7.2 and start 26.7.0 against the same directory. # 9.7 LTS is the one bridge into the first calendar lineage, so 26.7.0 upgrades and starts. $ mysql -t -e "SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders" +-----------+ | VERSION() | +-----------+ | 26.7.0 | +-----------+ +--------+ | orders | +--------+ | 2 | +--------+ $ grep -E MY-013381 mysqld.err # server upgrade from 9.7.2 (90702) to 26.7.0 (260700) [System] [MY-013381] [Server] Server upgrade from '80411' to '90702' started. [System] [MY-013381] [Server] Server upgrade from '80411' to '90702' completed. [System] [MY-013381] [Server] Server upgrade from '90702' to '260700' started. [System] [MY-013381] [Server] Server upgrade from '90702' to '260700' completed.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 upgrade_chain_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 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, and tarball(name) finds one.
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 and the pinned MySQL installations, 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)
def tarball(name):
"""The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
<name> in a folder called toolchains, looked for in every folder above the one 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)
candidates = [parent / "toolchains" / name]
if (parent / "tmp").is_dir():
candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
for found in candidates:
if (found / "bin").is_dir():
return found
sys.exit(f"no toolchains folder above this one holds {name}")
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().
"""
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 / "mysqld").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")})
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 / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *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(LOG_PREFIX.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 _ping(self):
code, _ = self._call(["mysqladmin", 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(["mysqladmin", 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(LOG_PREFIX.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 = ["mysql", 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 = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", 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 = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", 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(["mysql", 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"mysql> {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() Step 1 writes shop.orders with two rows on 8.4.11 and reads VERSION() as 8.4.11. Step 2 slow-shuts down the
server and starts MySQL 26.7.0 against the same directory. The 26.7.0 server refuses with
[ERROR] [MY-014060] [Server] Invalid MySQL server upgrade: Cannot upgrade from 80411 to 260700. The source and target must be in the same compatibility lineage, unless the source is the immediately preceding LTS for the target. Two more error-log lines follow before the server aborts with status 1: MY-010020 (“Data Dictionary
initialization failed”) and MY-010119 (“Aborting”). The data directory is untouched; the next step can still pick
the right binaries.
Step 3 points the server at MySQL 9.7.2 against the same directory. The error log then logs MY-011090 (“Data
dictionary upgrading from version ‘80300’ to ‘90200’”) and MY-013413 for the completed dictionary upgrade, then
MY-013381 for the server upgrade from '80411' to '90702'. The query reports VERSION() as 9.7.2 and
COUNT(*) of shop.orders as 2: the row survived. Step 4 slow-shuts down 9.7.2 and starts 26.7.0, which this
time upgrades (the bridge from 9.7 is the one allowed cross-lineage step): the error log carries MY-013381 for
from '90702' to '260700' started and completed, and the query reports VERSION() as 26.7.0 with the two
rows still present.
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Run the readiness check. On the running old server, run MySQL Shell's util.checkForServerUpgrade against the target version, then mysqlcheck with --check-upgrade. Walk every error and warning down to zero before the next step; a warning you accept is a note in the plan.
- Label a backup. Take a labelled backup of the running old server, either a logical dump or a physical copy such as a local clone, and record where it is. This is the rollback plan; the older server will not start again on the upgraded directory.
- Slow-shutdown the old server. Set innodb_fast_shutdown = 0 and shut down. InnoDB finishes a full purge and a change buffer merge before it stops, so the data files are ready if the new version changes their format.
- Swap the binaries. Point mysqld at the new version. The data directory stays where it is; only the server program changes. A direct cross-lineage swap (8.4 straight to 26.7) refuses to initialise with MY-014060.
- Start the new server. The server upgrades the data dictionary (from the old version's number to the new version's) and the server metadata, logging MY-011090 and MY-013381, then opens the ports. The first start takes longer than later restarts because of this one-off upgrade work.
- Smoke-query the running server. SELECT VERSION() returns the new version string, and a count of a known row sanity-checks that the data survived. For a replica, SHOW REPLICA STATUS confirms the two threads are ON and the applier is caught up.
- Read the error log. Grep for MY-013381 to see the from and to versions the server upgrade walked through; MY-011090 and MY-013413 cover the data dictionary. Keep that transcript with the plan, in case a later question asks exactly which steps the server took.
- Watch the service catch up. Monitor replication lag, replica threads, slow queries and application error rate for a service window after the move. Behaviour changes between lineages show up here first, and the plan names what triggers a rollback.
The whole chain is one short script, so it can be rehearsed on a laptop before the real move. The flow above turns it into the eight steps every single-server move follows, from the readiness check to watching the service catch up.
Checkpoint
Check: The in-place chain
Replicas upgrade first, with the higher-version source variable
MySQL 8.4 Reference Manual 19.5.3 fixes the order for a replication topology: upgrade every replica first, then
switch the write workload to a replica, upgrade the former source, and re-insert it. The rule is a direct
consequence of the compatibility rules, because the manual supports replication from an older source to a newer
replica for version pairs where an upgrade from the source to the replica is supported, but not the other
way around. On 9.7 and 26.7 the replica carries a safeguard variable, replica_allow_higher_version_source, that
is ON by default; set to OFF, it makes the replica refuse a source running any higher version, apart from patch
releases inside one LTS series.
replicas_first_session.py
"""Replicas first: an 8.4.11 source feeds a 9.7.2 replica (one of the standard upgrade orderings).
Rules the output demonstrates (MySQL 8.4 Reference Manual, 19.5.3 "Upgrading or Downgrading a Replication
Topology", and 9.7 Reference Manual, "Replica Server Options and Variables"):
- An older source can feed a newer replica for version pairs that the upgrade rules support (8.4 LTS to 9.7 LTS
is one of them). That is why the standard rolling-upgrade order is replicas first, then the source.
- On the replica, replica_allow_higher_version_source is ON by default; it concerns sources running a HIGHER
version, so it plays no part while the source is older. higher_version_session.py records what OFF does.
The driver masks ports, server UUIDs, timings and basedir paths, and replication status reads only the fields that
are not clocks or counters, so every run prints the same output.
"""
from mysql_session import Server, tarball
SRC = ["--server-id=1", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON"]
REP = ["--server-id=2", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON",
"--read-only=ON", "--super-read-only=ON", "--skip-replica-start=ON"]
with Server(options=SRC, label="source-84", port_label="<src84-port>") as src84, \
Server(options=REP, label="replica", port_label="<replica-port>",
basedir=tarball("mysql-9.7.2")) as replica:
print("# 1. Start an 8.4.11 source and a 9.7.2 replica on 127.0.0.1. GTIDs on and binary log on for both.")
src84.up()
replica.up()
print("# 2. The source makes a replication account; the replica is pointed at it on auto-positioning.")
src84.sql("CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL; "
"GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'", # learn-lint-allow inclusive: MySQL still spells the privilege REPLICATION SLAVE
note="least-privilege replication account on the source")
replica.change_source(src84, user="repl", password="replpass",
auto_position=True, ssl=True, verify_ca=True)
replica.start_replica(user="repl", password="replpass")
replica.wait_replica_running()
print("# 3. The source writes a row; the replica catches up and reads it back from its own read-only session.")
src84.sql("CREATE DATABASE shop; "
"CREATE TABLE shop.orders (id INT PRIMARY KEY, label VARCHAR(40)); "
"INSERT INTO shop.orders VALUES (1, 'first')",
note="one row written to the 8.4.11 source")
replica.wait_caught_up(src84)
replica.sql("SELECT id, label FROM shop.orders ORDER BY id", db="shop",
note="read from the 9.7.2 replica")
replica.replica_status(["Replica_IO_Running", "Replica_SQL_Running",
"Source_SSL_Allowed", "Auto_Position"],
note="the fields the standard upgrade ordering keeps green")
print("# 4. This source is older than the replica, so replica_allow_higher_version_source does not")
print("# apply to this connection. The variable's default is ON.")
replica.sql("SELECT @@global.replica_allow_higher_version_source AS allow_higher",
note="default on the 9.7.2 replica") Output
# 1. Start an 8.4.11 source and a 9.7.2 replica on 127.0.0.1. GTIDs on and binary log on for both. # 2. The source makes a replication account; the replica is pointed at it on auto-positioning. -- least-privilege replication account on the source $ mysql -t -e "CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'" $ mysql -t -vvv --skip-comments < _change_source.sql -------------- CHANGE REPLICATION SOURCE TO SOURCE_HOST='127.0.0.1', SOURCE_PORT=<src84-port>, SOURCE_USER='repl', SOURCE_PASSWORD='replpass', SOURCE_AUTO_POSITION=1, SOURCE_SSL=1, SOURCE_SSL_CA='source-ca.pem' -------------- Query OK, 0 rows affected, 1 warning Bye $ mysql -t -vvv --skip-comments < _start_replica.sql -------------- START REPLICA USER='repl' PASSWORD='replpass' -------------- Query OK, 0 rows affected Bye # 3. The source writes a row; the replica catches up and reads it back from its own read-only session. -- one row written to the 8.4.11 source $ mysql -t -e "CREATE DATABASE shop; CREATE TABLE shop.orders (id INT PRIMARY KEY, label VARCHAR(40)); INSERT INTO shop.orders VALUES (1, 'first')" -- read from the 9.7.2 replica $ mysql -t shop -e "SELECT id, label FROM shop.orders ORDER BY id" +----+-------+ | id | label | +----+-------+ | 1 | first | +----+-------+ -- the fields the standard upgrade ordering keeps green mysql> SHOW REPLICA STATUS\G Replica_IO_Running: Yes Replica_SQL_Running: Yes Source_SSL_Allowed: Yes Auto_Position: 1 # 4. This source is older than the replica, so replica_allow_higher_version_source does not # apply to this connection. The variable's default is ON. -- default on the 9.7.2 replica $ mysql -t -e "SELECT @@global.replica_allow_higher_version_source AS allow_higher" +--------------+ | allow_higher | +--------------+ | 1 | +--------------+
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replicas_first_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 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, and tarball(name) finds one.
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 and the pinned MySQL installations, 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)
def tarball(name):
"""The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
<name> in a folder called toolchains, looked for in every folder above the one 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)
candidates = [parent / "toolchains" / name]
if (parent / "tmp").is_dir():
candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
for found in candidates:
if (found / "bin").is_dir():
return found
sys.exit(f"no toolchains folder above this one holds {name}")
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().
"""
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 / "mysqld").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")})
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 / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *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(LOG_PREFIX.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 _ping(self):
code, _ = self._call(["mysqladmin", 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(["mysqladmin", 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(LOG_PREFIX.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 = ["mysql", 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 = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", 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 = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", 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(["mysql", 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"mysql> {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() Steps 1 to 3 of the recorded run put a 9.7.2 replica behind an 8.4.11 source over TLS with auto-positioning, write
a row to the source and read it back from the replica (the row 1, first). This is the standard rolling-upgrade
order: the replica is one line ahead of the source, so the application can switch to it and the old source can be
upgraded afterwards. Step 4 reads replica_allow_higher_version_source on the replica as 1, the default ON that
the 9.7 manual records. The 8.4 manual does not list the variable at all, and it concerns only a source running a
higher version, so it plays no part while the source is the older server.
Its job comes at the next hop. In the second run a 9.7.2 replica sets the variable to OFF and is pointed at a 26.7.0 server:
higher_version_session.py
"""The safeguard for the next hop: a 9.7.2 replica with replica_allow_higher_version_source = OFF is pointed at a
26.7.0 source, and the replica's connection thread refuses it.
Rule the output demonstrates (MySQL 9.7 Reference Manual, "Replica Server Options and Variables"): OFF makes the
replica reject connections from any source running a higher MySQL version, except that inside one LTS series the
patch level is ignored. The default is ON. Replicating from a newer source to an older replica is not a supported
pairing either way (MySQL 8.4 Reference Manual, 19.5.3), so OFF keeps an operator from building one by mistake.
The driver masks ports, server UUIDs, timings and basedir paths; the session waits on the connection thread's
recorded error (performance_schema), never on a fixed sleep, and prints only fields that are not clocks or
counters, so every run prints the same output.
"""
import sys
import time
from mysql_session import Server, tarball
REP = ["--server-id=2", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON",
"--read-only=ON", "--super-read-only=ON", "--skip-replica-start=ON"]
NEW = ["--server-id=3", "--gtid-mode=ON", "--enforce-gtid-consistency=ON"]
def wait_connection_error(replica, timeout=60):
"""Polls the replica until its connection thread has recorded an error number."""
for _ in range(timeout * 10):
errno = replica.query("SELECT LAST_ERROR_NUMBER FROM performance_schema.replication_connection_status")
if errno not in ("", "0"):
return
time.sleep(0.1)
sys.exit("the replica recorded no connection error in time")
with Server(options=REP, label="replica", port_label="<replica-port>",
basedir=tarball("mysql-9.7.2")) as replica, \
Server(options=NEW, label="source-267", port_label="<src267-port>",
basedir=tarball("mysql-26.7.0")) as src267:
print("# 1. Start a 9.7.2 replica and a 26.7.0 server on 127.0.0.1, GTIDs on for both.")
replica.up()
src267.up()
print("# 2. The 26.7.0 server gets a replication account, as a source would.")
src267.sql("CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL; "
"GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'", # learn-lint-allow inclusive: MySQL still spells the privilege REPLICATION SLAVE
note="least-privilege replication account on the 26.7.0 server")
print("# 3. The replica turns the safeguard on, then is pointed at the higher-version server.")
replica.sql("SET GLOBAL replica_allow_higher_version_source = OFF", note="OFF on the 9.7.2 replica")
replica.change_source(src267, user="repl", password="replpass",
auto_position=True, ssl=True, verify_ca=True, show=False)
replica.start_replica(user="repl", password="replpass")
wait_connection_error(replica)
replica.replica_status(["Replica_IO_Running", "Replica_SQL_Running", "Last_IO_Errno", "Last_IO_Error"],
note="what the replica's connection thread recorded") Output
# 1. Start a 9.7.2 replica and a 26.7.0 server on 127.0.0.1, GTIDs on for both. # 2. The 26.7.0 server gets a replication account, as a source would. -- least-privilege replication account on the 26.7.0 server $ mysql -t -e "CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'" # 3. The replica turns the safeguard on, then is pointed at the higher-version server. -- OFF on the 9.7.2 replica $ mysql -t -e "SET GLOBAL replica_allow_higher_version_source = OFF" $ mysql -t -vvv --skip-comments < _start_replica.sql -------------- START REPLICA USER='repl' PASSWORD='replpass' -------------- Query OK, 0 rows affected Bye -- what the replica's connection thread recorded mysql> SHOW REPLICA STATUS\G Replica_IO_Running: No Replica_SQL_Running: Yes Last_IO_Errno: 15671 Last_IO_Error: Replication from higher version source (26.7.0) to lower version replica (9.7.2) is disallowed due to replica_allow_higher_version_source=OFF.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 higher_version_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 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, and tarball(name) finds one.
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 and the pinned MySQL installations, 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)
def tarball(name):
"""The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
<name> in a folder called toolchains, looked for in every folder above the one 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)
candidates = [parent / "toolchains" / name]
if (parent / "tmp").is_dir():
candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
for found in candidates:
if (found / "bin").is_dir():
return found
sys.exit(f"no toolchains folder above this one holds {name}")
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().
"""
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 / "mysqld").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")})
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 / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *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(LOG_PREFIX.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 _ping(self):
code, _ = self._call(["mysqladmin", 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(["mysqladmin", 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(LOG_PREFIX.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 = ["mysql", 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 = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", 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 = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", 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(["mysql", 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"mysql> {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() START REPLICA itself returns OK, but the replica’s connection thread stops at once: Replica_IO_Running: No,
with error 15671, “Replication from higher version source (26.7.0) to lower version replica (9.7.2) is disallowed”.
A team that keeps OFF on its 9.7 replicas therefore cannot build the unsupported newer-source, older-replica pair by
mistake; with the default ON, the manual says, the replica would have gone on replicating without that check.
Group Replication has an online-upgrade procedure of its own
(MySQL 8.4 Reference Manual, 20.8.3):
each member in turn leaves the group with STOP GROUP_REPLICATION, is upgraded by one of the single-server methods
and rejoins with START GROUP_REPLICATION, so the group keeps serving throughout. In a single-primary group the
secondaries go first and the primary last, an upgraded member rejoins with super_read_only=ON while any member
still runs the older version, and each member’s own hop follows the same upgrade paths.
Checkpoint
Check: Replicas upgrade first
Rollback is a plan, not a command
Chapter 4 of the 9.7 Reference Manual limits in-place downgrade: Moving back from a newer MySQL version to an older one. In-place downgrades work only within an LTS series; moving back across lines is supported only as a rollback, through a logical dump and load or replication. to the inside of an LTS series: 8.4.11 can go back to 8.4.10 in place, but 9.7.2 cannot go back to 8.4.11. Going back a line, or inside an Innovation series, takes a logical dump and load or replication, and the manual supports it only as a rollback, before any new server feature has touched the data. Starting an older server on a data directory a newer one has upgraded fails outright. The recorded run shows exactly what happens:
rollback_session.py
"""Rollback is a plan, not a command.
MySQL supports in-place downgrades only inside an LTS series (MySQL 9.7 Reference Manual, Chapter 4 "Downgrading
MySQL"): going back a series, such as 9.7 to 8.4, takes a logical dump and load or replication, and the manual
supports it only for rollback, while no new server functionality has been applied to the data. An 8.4.11 server
pointed at a data directory that 9.7.2 has upgraded does not start.
This session records that refusal: initialise 8.4.11, write a row, slow-shutdown, upgrade to 9.7.2 in place, read
the row, slow-shutdown, then point the server back at 8.4.11 against the upgraded directory. 8.4.11 refuses with an
invalid-downgrade error (MY-014061) that names both server versions.
That is why a rollback plan is a backup taken on the old version before the upgrade, not an attempt to run the
older server on the newer directory.
"""
from pathlib import Path
from mysql_session import PINNED, Server, tarball
def switch(my, basedir):
my.basedir = Path(basedir).resolve()
my.bin = my.basedir / "bin"
with Server(redo="8M") as my:
print("# 1. Initialise and start 8.4.11, write a row, slow-shutdown.")
my.up()
my.sql("CREATE DATABASE shop; "
"CREATE TABLE shop.orders (id INT PRIMARY KEY, label VARCHAR(40)); "
"INSERT INTO shop.orders VALUES (1, 'first')")
my.query("SET GLOBAL innodb_fast_shutdown = 0")
my.stop()
print("# 2. Upgrade in place to 9.7.2: the data dictionary moves from 80300 to 90200, the server from 80411")
print("# to 90702. The row is still there.")
switch(my, tarball("mysql-9.7.2"))
my.start()
my.sql("SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders",
note="running on 9.7.2, the row survived")
print("# 3. Slow-shutdown 9.7.2 and point the server back at 8.4.11 against the upgraded data directory.")
print("# The older server refuses to start: it will not pretend the directory is still at its own version.")
my.query("SET GLOBAL innodb_fast_shutdown = 0")
my.stop()
switch(my, PINNED)
my.start(expect_fail=True) Output
# 1. Initialise and start 8.4.11, write a row, slow-shutdown. $ mysql -t -e "CREATE DATABASE shop; CREATE TABLE shop.orders (id INT PRIMARY KEY, label VARCHAR(40)); INSERT INTO shop.orders VALUES (1, 'first')" # 2. Upgrade in place to 9.7.2: the data dictionary moves from 80300 to 90200, the server from 80411 # to 90702. The row is still there. -- running on 9.7.2, the row survived $ mysql -t -e "SELECT VERSION(); SELECT COUNT(*) AS orders FROM shop.orders" +-----------+ | VERSION() | +-----------+ | 9.7.2 | +-----------+ +--------+ | orders | +--------+ | 1 | +--------+ # 3. Slow-shutdown 9.7.2 and point the server back at 8.4.11 against the upgraded data directory. # The older server refuses to start: it will not pretend the directory is still at its own version. [ERROR] [MY-014061] [InnoDB] Invalid MySQL server downgrade: Cannot downgrade from 90702 to 80411. Downgrade is only permitted between patch releases. [ERROR] [MY-010334] [Server] Failed to initialize DD Storage Engine [ERROR] [MY-010020] [Server] Data Dictionary initialization failed. [ERROR] [MY-010119] [Server] Aborting (mysqld ended with status 1)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rollback_session.py
mysql_session.py
"""Throwaway MySQL 8.4.11 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, and tarball(name) finds one.
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 and the pinned MySQL installations, 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)
def tarball(name):
"""The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
<name> in a folder called toolchains, looked for in every folder above the one 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)
candidates = [parent / "toolchains" / name]
if (parent / "tmp").is_dir():
candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
for found in candidates:
if (found / "bin").is_dir():
return found
sys.exit(f"no toolchains folder above this one holds {name}")
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().
"""
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 / "mysqld").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")})
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 / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *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(LOG_PREFIX.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 _ping(self):
code, _ = self._call(["mysqladmin", 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(["mysqladmin", 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(LOG_PREFIX.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 = ["mysql", 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 = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", 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 = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", 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(["mysql", 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"mysql> {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() The run initialises 8.4.11, writes a row, slow-shuts down, upgrades to 9.7.2 in place, confirms the row is there,
slow-shuts down again, and points the server back at 8.4.11 against the upgraded directory. The 8.4.11 start fails
with [ERROR] [MY-014061] [InnoDB] Invalid MySQL server downgrade: Cannot downgrade from 90702 to 80411. Downgrade is only permitted between patch releases., followed by MY-010334 and MY-010020 for the failed data dictionary
init and MY-010119 for the server abort: the older server refuses to pretend the directory is still at its own
version. In practice the rollback plan is a labelled backup taken on the running old server before the slow
shutdown: a logical dump, or a physical copy made while the server runs, such as a local clone with
CLONE LOCAL DATA DIRECTORY (8.4 manual 7.6.7.2). The operator who kept it starts the old version on a fresh
directory, loads the dump or starts on the clone, and is back on the previous version once that finishes; for a
logical dump the load grows with the data.
Checkpoint
Check: Rollback and downgrades
Managed services run on the same rules
Running MySQL as a managed service does not remove the compatibility lineage, only the hands-on parts. Each of the three major managed MySQL services publishes a version policy page that lists the MySQL versions it runs and how long each keeps getting fixes:
| Service | What its page says about MySQL versions |
|---|---|
| Amazon RDS for MySQL | Lists 8.4 minor versions and 8.0.46, each with its own end of standard support, and RDS-built 8.0 minor versions for paid RDS Extended Support. Major upgrades: 5.7 to 8.0 and 8.0 to 8.4. 9.5 to 9.7 and 26.7 run only in its Database Preview environment |
| Cloud SQL for MySQL | Lists 9.7, 8.4 (the default) and 8.0 under regular support. Adds a major version within five months of its community release, keeps regular support while the community supports it, then offers three years of charged extended support |
| Azure Database for MySQL | Lists 8.0 and 8.4 as generally available and 9.5 as a preview Innovation release. Standard support runs past community retirement to its own end date, then charged Extended Support brings critical security updates for up to three years |
The shape across the three is the same: a managed service runs MySQL itself, charges for extra time on a line once its own standard support ends, and upgrades one major version at a time. RDS lists only one-step major upgrades; Cloud SQL’s in-place upgrade page rules out skipping a major version and runs the MySQL Shell upgrade checker as its precheck; Azure asks for read replicas to be upgraded before the primary and for a backup taken before the upgrade. The checks and the order of the earlier sections carry over to a console unchanged.
Checkpoint
Check: Managed services
Practise with the upgrade-path checker
Before the self-check, write the one small piece of logic a plan does by hand: given a starting MySQL version, return the supported in-place path to a current release, and name the shortcut from it that the server refuses.
Exercise · Easy · Python
Return the supported in-place path and the first blocking step
Write upgrade_path(start) in upgrade_path.py so it returns the supported in-place sequence of release lines from start up to the current latest line, with the first blocking step named along the way. The lesson's compatibility rules apply: MySQL's LTS lines step forward one at a time, 9.7 LTS is the one line that may step directly into the first calendar-versioned lineage (26.x), and MariaDB does not share lineages with MySQL at all.
The input start is a string. Treat it as a MySQL version if it begins with mysql (optional, case-insensitive) or starts with a digit, and as MariaDB if it begins with mariadb . The valid MySQL lines are "8.0", "8.4", "9.1", "9.6", "9.7", "26.7", written with or without a patch level ("9.7", "9.7.2", and patch releases of the 26.7 line such as "26.7.1"); a value that cannot be read as any of these gets the unknown-version result described below.
upgrade_path(start) returns a dictionary with four keys:
"family":"mysql"or"mariadb"."path": the ordered list of lines the plan steps through fromstart's line up to and including"26.7". A start on the 26.7 line gives["26.7"]; everything in the list is the line number ("8.0","8.4","9.7","26.7"), never a patch ("26.7.1"is reported as the line"26.7")."first_blocking_step": the shortcut a hurried plan would try first and the server refuses: the direct jump fromstart's line to the line after the next one on the path, as a list[from, to], orNonewhen the path has at most one hop. For"8.0"it is["8.0", "9.7"], because 8.0 may not skip the 8.4 LTS line; for"9.1"it is["9.1", "26.7"], because an Innovation release may not cross out of its lineage; for"9.7", or a start on the 26.7 line, it isNone."note": a short sentence that names what the plan does with the start. For a MariaDB start it says that MySQL does not offer an in-place path and the move is a logical migration (dump and load into a fresh MySQL server); for a 9.x Innovation release before 9.7 it names 9.7 as the LTS inside the lineage; for an unknown version the key is"not a MySQL or MariaDB version this plan recognises".
For "8.0.46", upgrade_path(start) returns {"family": "mysql", "path": ["8.0", "8.4", "9.7", "26.7"], "first_blocking_step": ["8.0", "9.7"], "note": "step forward one LTS line at a time, finishing with the 9.7 to 26.7 bridge"}.
Read start by its prefix, then the digits before the second dot, then look the line up in the plan table. The sample tests cover eleven starting versions (with and without a patch level, a MySQL prefix, stray spaces and one MariaDB release), a malformed value and an empty string.
Starter code · upgrade_path.py
def upgrade_path(start):
"""Return the supported in-place sequence of MySQL release lines from `start` and the first blocking step, as
the prompt describes:
{"family": "mysql"|"mariadb", "path": list of lines, "first_blocking_step": [from, to] or None, "note": str}
"""
# Replace these lines with your checker.
return {"family": "mysql", "path": [], "first_blocking_step": None, "note": ""} The sample tests · test_upgrade_path.py
"""Sample tests for the upgrade-path checker: eleven starting versions written the ways a plan meets them (with and
without a patch level, a MySQL prefix and stray spaces, one MariaDB release), a malformed value and an empty string.
Every expected value follows the compatibility rules of the 26.7 Reference Manual (1.3 MySQL Releases: Innovation
and LTS) and the upgrade paths of the 9.7 Reference Manual (3.2)."""
from upgrade_path import upgrade_path
def test_eight_oh_four_six_full_path():
"""From 8.0.46 the plan hops 8.0 -> 8.4 -> 9.7 -> 26.7; the first refused direct hop is 8.0 straight to 9.7."""
assert upgrade_path("8.0.46") == {
"family": "mysql",
"path": ["8.0", "8.4", "9.7", "26.7"],
"first_blocking_step": ["8.0", "9.7"],
"note": "step forward one LTS line at a time, finishing with the 9.7 to 26.7 bridge",
}
def test_eight_four_eleven_one_hop_left():
"""From 8.4.11 the next hop is 9.7 LTS, then the 9.7 to 26.7 bridge; a direct 8.4 to 26.7 is the first refusal."""
assert upgrade_path("8.4.11") == {
"family": "mysql",
"path": ["8.4", "9.7", "26.7"],
"first_blocking_step": ["8.4", "26.7"],
"note": "8.4 LTS reaches 26.7 through 9.7, not directly",
}
def test_nine_one_zero_innovation():
"""9.1.0 is an Innovation release inside the second sequential lineage; the lineage closes at 9.7 LTS, then the
bridge. A direct 9.1 to 26.7 would cross the lineage boundary, which an Innovation release may not do."""
assert upgrade_path("9.1.0") == {
"family": "mysql",
"path": ["9.1", "9.7", "26.7"],
"first_blocking_step": ["9.1", "26.7"],
"note": "9.7 is the LTS that closes this Innovation lineage before the bridge to 26.7",
}
def test_nine_six_one_innovation():
"""9.6.1 is also inside the 9.x sequential lineage; the LTS at the end is 9.7, then 26.7, and the refused
shortcut is the direct 9.6 to 26.7."""
assert upgrade_path("9.6.1") == {
"family": "mysql",
"path": ["9.6", "9.7", "26.7"],
"first_blocking_step": ["9.6", "26.7"],
"note": "9.7 is the LTS that closes this Innovation lineage before the bridge to 26.7",
}
def test_nine_seven_two_bridge():
"""9.7.2 is on the LTS that bridges straight to 26.7; the path is one hop."""
assert upgrade_path("9.7.2") == {
"family": "mysql",
"path": ["9.7", "26.7"],
"first_blocking_step": None,
"note": "9.7 is the one line that may step directly into the first calendar-versioned lineage",
}
def test_twenty_six_seven_zero_already_there():
"""26.7.0 is the opening of the current calendar lineage; the plan has nowhere further to go."""
assert upgrade_path("26.7.0") == {
"family": "mysql",
"path": ["26.7"],
"first_blocking_step": None,
"note": "already on the first calendar-versioned lineage",
}
def test_twenty_six_seven_one_patch_in_place():
"""26.7.1 is a patch release inside the same lineage; still reported as the 26.7 line."""
assert upgrade_path("26.7.1") == {
"family": "mysql",
"path": ["26.7"],
"first_blocking_step": None,
"note": "already on the first calendar-versioned lineage",
}
def test_mariadb_logical_migration():
"""A MariaDB release is a separate server; the move to MySQL is a logical migration, not an in-place upgrade."""
assert upgrade_path("MariaDB 10.6") == {
"family": "mariadb",
"path": [],
"first_blocking_step": None,
"note": "not an in-place path to MySQL: dump the MariaDB schema and data and load them into a fresh MySQL server",
}
def test_prefix_mysql_accepted():
"""A 'MySQL ' prefix is read and ignored; the version decides the plan."""
assert upgrade_path("MySQL 8.4.11") == {
"family": "mysql",
"path": ["8.4", "9.7", "26.7"],
"first_blocking_step": ["8.4", "26.7"],
"note": "8.4 LTS reaches 26.7 through 9.7, not directly",
}
def test_line_without_patch_level():
"""A line written without its patch level ('9.7') is still the 9.7 line, so the plan is the one-hop bridge."""
assert upgrade_path("9.7") == {
"family": "mysql",
"path": ["9.7", "26.7"],
"first_blocking_step": None,
"note": "9.7 is the one line that may step directly into the first calendar-versioned lineage",
}
def test_spaces_and_prefix_around_a_patch_release():
"""Spaces around the value and a MySQL prefix are ignored; 26.7.1 is a patch release of the 26.7 line."""
assert upgrade_path(" MySQL 26.7.1 ") == {
"family": "mysql",
"path": ["26.7"],
"first_blocking_step": None,
"note": "already on the first calendar-versioned lineage",
}
def test_malformed_version_reports_unknown():
"""A value that cannot be read as a MySQL or MariaDB version falls back to the unknown note."""
assert upgrade_path("something.else") == {
"family": "mysql",
"path": [],
"first_blocking_step": None,
"note": "not a MySQL or MariaDB version this plan recognises",
}
def test_empty_string_reports_unknown():
"""An empty start is treated as unknown and does not propose a path."""
assert upgrade_path("") == {
"family": "mysql",
"path": [],
"first_blocking_step": None,
"note": "not a MySQL or MariaDB version this plan recognises",
} A hint
Start by reading start.lower().strip(). If it begins with mariadb , return the MariaDB result with the logical migration note. Else strip an optional mysql prefix and read the first two dot-separated groups as the line number (so "26.7.1" becomes line "26.7"); keep a short list of the valid MySQL lines in order and the first refusal from each one, so a lookup gives both the path and the first blocking step without walking the rules each time. A line not in the list (an empty or malformed input) returns the unknown-version note.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
15 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why can an 8.4.11 server not step directly to 26.7.0, and what does its error log say?
Section 1.3 of the 26.7 Reference Manual sets the rule the server enforces: an LTS release upgrades only within its
own line or into the immediately following compatibility lineage, never skipping one. 9.7 is the only LTS line that
may step directly into the first calendar-versioned lineage, where 26.7 belongs, so a jump from 8.4 to 26.7 skips
9.7. Pointed at an 8.4.11 directory, 26.7.0 aborts before serving any query and writes
[ERROR] [MY-014060] [Server] Invalid MySQL server upgrade: Cannot upgrade from 80411 to 260700, then says that
source and target must share a lineage unless the source is the immediately preceding LTS. MY-010020 for the
failed data dictionary initialisation and MY-010119 for the abort follow. The way across is one hop at a time:
8.4.11 to 9.7.2, a check of the row count and the error-log upgrade lines, then 9.7.2 to 26.7.0.
Lineage, in one line
LTS releases move within their own line or into the next lineage and never skip one; only 9.7 steps straight into the first calendar-versioned lineage, where 26.7 sits.
Warm-up (fresher): The MySQL Shell upgrade checker reported one error on an authMethodUsage check. What does
that mean, and what do you do before the upgrade?
The error comes from an account whose authentication plugin the target version will not accept. The
mysql_native_password plugin is disabled by default in 8.4, where --mysql-native-password=ON re-enables it, and
removed in 9.0.0, so an account that still uses it cannot log in to 9.x or 26.x. Before the upgrade, list such
accounts with SELECT user, host, plugin FROM mysql.user WHERE plugin = 'mysql_native_password';. For each, agree a
time with the owning application and run ALTER USER ... IDENTIFIED WITH caching_sha2_password BY '...'; its
clients may need TLS or --get-server-public-key, and a replication channel the GET_SOURCE_PUBLIC_KEY option.
Drop accounts nobody uses. The checker reports the same error with targetVersion 9.7.0, as the recorded run shows,
so it is fixed before the first hop, 8.4 to 9.7, not discovered at the second.
Two cases go further than the warm-ups. Work out your own answer for each, then open them to see what an interviewer would ask next.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A retail service runs 400 GB on MySQL 8.0 and must reach 26.7 within three two-hour maintenance windows over a month. It has one primary and three replicas, with reads already on a replica tier. How do you plan the move, which method do you pick for each window, and where does rollback live?
-
Mid to senior
After a 9.7.2 upgrade on the primary, the application sees a wrong result from a query that worked on 8.4.11. The team is unsure whether to spend the night debugging or roll back. How do you decide, and if you do roll back how do you bring the service back onto 8.4.11?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which upgrade hops does MySQL support from an LTS release?
Within its own line, or into the lineage that immediately follows, never skipping one: 8.0 reaches 26.7 through 8.4 and 9.7. Only 9.7 may step straight into the first calendar-versioned lineage.
-
Which four methods move a MySQL server to a new version?
In-place, for a hop that fits one restart; a logical dump and load into a fresh target, which leaves the old server untouched; the clone plugin, only inside one series; and rolling replicas, for a service that cannot stop. None of them skips a line.
-
Which checks run before an upgrade, and where?
On the live old server: mysqlcheck --check-upgrade for storage-level issues and MySQL Shell's util.checkForServerUpgrade for reserved words, obsolete settings and deprecated authentication plugins. Walk every error to zero, then take the backup and do the slow shutdown.
-
What happened when one 8.4.11 data directory was upgraded in place to 9.7.2 and then 26.7.0?
The data dictionary went from 80300 to 90200 and the server from 80411 to 90702 to 260700, with the row count intact at every hop. Starting 26.7.0 directly on the 8.4.11 directory was refused with MY-014060.
-
In a replicated topology, which servers upgrade first, and why?
The replicas, then the source, because an older source may feed a newer replica but not the reverse. On 9.7 and 26.7 replicas, replica_allow_higher_version_source can be set so that the replica stops instead of following a source that was upgraded before it.
-
How do you roll back a MySQL upgrade?
With a plan made beforehand, not a command: an in-place downgrade works only inside an LTS series, so take a labelled backup of the running old server, a logical dump or a local clone, before the slow shutdown.
Key takeaways
- An LTS release upgrades within its own line or into the immediately following lineage and never skips one, so 8.0 reaches 26.7 through 8.4 and 9.7; 9.7 is the only sequential LTS that may step directly into the first calendar-versioned lineage, anchored by 9.7.0.
- Four methods cover the real moves, and none of them skips a line: in-place for a hop that fits one restart, a logical dump and load into a fresh target that leaves the old server untouched, the clone plugin only inside one series, and rolling replicas for a service that cannot stop.
- The pre-upgrade checks run on the live old server:
mysqlcheck --check-upgradefor storage-level issues and MySQL Shellutil.checkForServerUpgradefor reserved words, obsolete settings and deprecated authentication plugins, with every error walked to zero before the slow shutdown. - One in-place chain, one data directory: 8.4.11 to 9.7.2 to 26.7.0 upgrades the data dictionary from 80300 to
90200 and the server from 80411 to 90702 to 260700, with the row count surviving every hop; a direct 26.7.0 on
8.4.11 refuses with
MY-014060. - Replicas upgrade first, then the source, matching the “older source feeds a newer replica” compatibility rule;
replica_allow_higher_version_sourcelets an operator lock a 9.7 or 26.7 replica against any higher-version source, so a source upgraded too early is refused instead of followed. - Rollback is a plan, not a command: in-place downgrade works only inside an LTS series, so the real plan is a labelled backup of the running old server, a logical dump or a local clone, taken before the slow shutdown.
Terms in this lesson
- compatibility lineage
- A group of MySQL releases inside which an in-place upgrade may move forward: the Innovation releases before an LTS release and the LTS release that closes them. An LTS release may also step into the immediately following lineage, but never skip one.
- downgrade
- Moving back from a newer MySQL version to an older one. In-place downgrades work only within an LTS series; moving back across lines is supported only as a rollback, through a logical dump and load or replication.
- in-place upgrade
- An upgrade that stops the old server, swaps the binaries and starts the new server on the same data directory, letting the new server upgrade the data dictionary and server metadata without copying rows.
- logical upgrade
- An upgrade carried out as a logical dump of the old server and a load into a fresh target server: SQL rebuilds the schema and rows on the new version while the old server stays untouched. The manual supports it on the same one-step paths as an in-place upgrade.
- slow shutdown
- The shutdown MySQL takes when innodb_fast_shutdown is set to 0, which does a full purge and a change buffer merge before stopping, so the data directory is in a steady state the next server can upgrade.
- upgrade checker
- MySQL Shell's util.checkForServerUpgrade() utility, which reads a running server against a target version and reports issues (reserved words, obsolete settings, old authentication plugins) that would stop or disturb the upgrade.
References
- MySQL 26.7 Reference Manual: 1.3 MySQL Releases: Innovation and LTS (Oracle Corporation)
- MySQL Product Support EOL Announcements (Oracle Corporation)
- MySQL 9.7 Reference Manual: 3.2 Upgrade Paths (Oracle Corporation)
- MySQL 9.7 Reference Manual: Chapter 4 Downgrading MySQL (Oracle Corporation)
- MySQL 9.7 Reference Manual: 3.6 Preparing Your Installation for Upgrade (Oracle Corporation)
- MySQL 9.7 Reference Manual: 17.14 InnoDB Startup Options and System Variables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 11.3 Keywords and Reserved Words (Oracle Corporation)
- MySQL 9.7 Reference Manual: 11.3 Keywords and Reserved Words (Oracle Corporation)
- MySQL 8.4 Reference Manual: 8.4.1.1 Native Pluggable Authentication (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.6.7.2 Cloning Data Locally (Oracle Corporation)
- MySQL 8.4 Reference Manual: 3.3 Upgrade Best Practices (Oracle Corporation)
- MySQL 8.4 Reference Manual: 4.5.3 mysqlcheck (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.5.3 Upgrading or Downgrading a Replication Topology (Oracle Corporation)
- MySQL 8.4 Reference Manual: 20.8.3 Group Replication Online Upgrade (Oracle Corporation)
- MySQL 9.7 Reference Manual: replica_allow_higher_version_source (Oracle Corporation)
- MySQL Shell 26.7: 12.2 Upgrade Checker Utility (Oracle Corporation)
- Changes in MySQL 26.7.0 (Innovation Release) (Oracle Corporation)
- Amazon RDS User Guide: MySQL on Amazon RDS versions (Amazon Web Services)
- Amazon RDS User Guide: Amazon RDS Extended Support with Amazon RDS (Amazon Web Services)
- Cloud SQL for MySQL documentation: database versions (Google)
- Cloud SQL for MySQL documentation: upgrade the database major version in-place (Google)
- Azure Database for MySQL: version support policy (Microsoft)
- Azure Database for MySQL: major version upgrade (Microsoft)
Report a problem with this lesson
Kept only in this browser. Your Learn progress