Your country

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

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

MySQL & MariaDB in Depth  Module 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.

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

What you will learn

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

Timeline · 5 events in order MySQL release lines and the lineages that upgrades move inside Walk through the lines in order and read the tag on each to see which lineage it belongs to and which direction an upgrade may step in.
  1. 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.

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

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

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

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

Interactive · stage by stage One hop from 8.0 to 26.7, one line at a time Follow the step the manual supports from each line to the next, with the method a real move picks between in-place binaries and a logical dump and load.
One hop from 8.0 to 26.7, one line at a time, step 4 of 4. Showing 8.0 server, 8.4 LTS, 9.7 LTS, 26.7 Innovation. In focus: 26.7 Innovation.8.0 serveroutgoing bugfix series8.4 LTSnew binaries, same datadir9.7 LTSthe bridge to 26.726.7 Innovationfirst calendar lineage

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Question 1 of 2 Which direct in-place upgrade is NOT supported by the MySQL 26.7 release model?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 8.4 LTS straight to 26.7

    An LTS release upgrades within its own line or into the immediately following lineage and never skips one. From 8.4 that means 9.x, up to 9.7 LTS; 26.7 lies one lineage further, so the direct hop refuses with MY-014060.

  2. Question 2 of 2 Why can 9.7 LTS upgrade directly to 26.7, while 8.4 LTS cannot?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The 26.7 manual lets only the final sequentially numbered LTS, 9.7, step into the first calendar-versioned lineage

    The release model turns 9.7 into the one allowed step into the first calendar lineage. An 8.4 estate therefore upgrades to 9.7 first, and only then to 26.7.

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.

Interactive · answer and compare Which upgrade method fits the move? Decide what you want and read down the column; a check means the method keeps that property, a dash means it does not.
How much data is in this server?
How long can the service be unavailable for writes?
What is the hop?

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

Four methods against the four questions a real upgrade asks when it picks between them; none of them skips a line.
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

  1. Question 1 of 2 An 8.0 estate must reach 26.7, and someone proposes one logical dump from 8.0 loaded straight into a fresh 26.7 server. What does the manual's upgrade-path table say about it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It is not a supported path; a logical move follows the same one-step paths, so it still goes 8.0, 8.4, 9.7, 26.7

    Every method in the 9.7 manual's Table 3.1 sits on the same one-step paths, so the method never shortens the path: 8.0 reaches 26.7 in three hops, through 8.4 and 9.7, whichever method each hop uses.

  2. Question 2 of 2 A payments service moving from 8.4 to 9.7 must keep taking reads and writes, apart from a switchover of a few seconds. Which method fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A rolling-replica upgrade, replicas first, then a switchover and the former source

    The rolling-replica method trades a longer, step-wise move for a service that keeps answering: replicas upgrade first, and the one pause in writes is the switchover to an upgraded replica, before the former source upgrades.

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.

mysqlcheck and MySQL Shell's upgrade checker on a seeded 8.4.11 server

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

  1. Question 1 of 2 What does MySQL Shell's util.checkForServerUpgrade() do that mysqlcheck --check-upgrade does not?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Report issues against a chosen target version, including reserved words, obsolete settings and deprecated authentication plugins

    mysqlcheck --check-upgrade is a storage-level check through CHECK TABLE ... FOR UPGRADE; the Shell checker adds target-version-aware checks over names, settings and plugins.

  2. Question 2 of 2 The Shell checker reported errorCount: 1 under authMethodUsage against targetVersion 26.7.0 on the recorded 8.4.11 server. What was the account error about?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An account whose authentication plugin the target version will not accept

    The seeded legacy_app@127.0.0.1 authenticated with mysql_native_password, which 9.0.0 removed; the fix before upgrade is ALTER USER to caching_sha2_password or to delete the account.

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:

One data directory, three MySQL versions, and the refusal in the middle

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.

Interactive · stage by stage One upgrade step from the readiness check to the first query Walk through the eight steps a single-server in-place upgrade runs through, from the pre-check to the first query against the new binaries.
One upgrade step from the readiness check to the first query, step 8 of 8. Showing Readiness check, Backup labelled, Slow shutdown, Swap binaries, Startup upgrade, Smoke query, Error log read, Monitor caught up. In focus: Monitor caught up.issues foundReadiness checkShell plus mysqlcheckBackup labelleddump or local cloneSlow shutdowninnodb_fast_shutdown = 0Swap binariesnew mysqld on datadirStartup upgradedictionary and serverSmoke queryVERSION and a row countError log readMY-013381 and friendsMonitor caught upreplica lag back to 0

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Why does the 9.7 manual advise innodb_fast_shutdown = 0 before an in-place upgrade between major releases?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 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

    With the default fast shutdown (1), InnoDB leaves purge and change buffer merges for a later start; setting 0 finishes them before the stop, which 17.14 advises before an upgrade or downgrade between major releases.

  2. Question 2 of 2 The 9.7.2 start on an 8.4.11 directory wrote MY-011090 and MY-013381. What do those lines mean?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The data dictionary moved from 80300 to 90200, then the server upgraded from 80411 to 90702

    The first MY-011090 and MY-013413 mark the data dictionary upgrade from one on-disk version to another; the MY-013381 pair names the server upgrade from the old version number to the new.

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.

A 9.7.2 replica of an 8.4.11 source, with replica_allow_higher_version_source

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:

A 9.7.2 replica with the variable OFF refuses a 26.7.0 source

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

  1. Question 1 of 2 MySQL 8.4 Reference Manual 19.5.3 says to upgrade every replica before the source in a replication topology. Why that order?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Replication from an older source to a newer replica is supported, but not the other way round

    Replicas-first keeps the topology on the supported pair (older source, newer replica). The reverse pair (newer source, older replica) is not supported, which is why upgrading the source first can break the applier.

  2. Question 2 of 2 What does replica_allow_higher_version_source=OFF do on a 9.7 replica?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It makes the replica reject connections from a source running a higher MySQL version, patch-level differences within an LTS series excepted

    OFF makes the replica refuse a source running a higher version outside its own LTS series: the recorded 9.7.2 replica refused a 26.7.0 source with error 15671, so a 9.7 estate cannot follow a primary that moved too early.

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:

MySQL 8.4.11 refuses to start on a data directory 9.7.2 has upgraded

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

  1. Question 1 of 2 Can 9.7.2 be downgraded in place to 8.4.11 after the data directory has been upgraded?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No; in-place downgrade works only inside an LTS series, so going back from 9.7.2 to 8.4.11 takes a logical dump and load or replication, as a rollback

    The one true rollback plan for a cross-line move is a labelled backup taken from the running old server before the slow shutdown; the clone plugin, auto-position and in-place downgrade do not cross a line.

  2. Question 2 of 2 What is the one piece of state a rollback plan must capture before the slow shutdown of the old server?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A labelled backup of the old server, either a logical dump or a physical copy such as a local clone

    The rollback is a plan around a copy, not a command. A labelled snapshot taken on the old server, before the slow shutdown, is what the plan runs on when the new version has to be abandoned.

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

  1. Question 1 of 2 Which database engine does Amazon RDS for MySQL, Cloud SQL for MySQL and Azure Database for MySQL run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: MySQL, the same engine the lesson's examples drove

    The three managed MySQL services run MySQL on the same compatibility rules, so the same lineage decisions apply inside the console that an operator makes on a laptop.

  2. Question 2 of 2 An Amazon RDS for MySQL major version has passed its RDS end of standard support. What is still available?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Paid RDS Extended Support, with security updates for critical and high-severity issues, for up to three years

    Managed services run the same engine under the same lineage rules; once their own standard support for a line ends, they offer paid extended support for a limited time, not permanent support of a retired line.

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 from start'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 from start's line to the line after the next one on the path, as a list [from, to], or None when 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 is None.
  • "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.

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.

  1. Question 1 of 15 An estate on MySQL 8.0.46 wants to reach 26.7. Which first in-place hop does the release model allow?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: 8.0 to 8.4 LTS

    Of these hops only 8.0 to 8.4 LTS is allowed, because no LTS or bugfix series may be skipped; from 8.4 the path continues to 9.7 LTS, and from 9.7 to 26.7.

  2. Question 2 of 15 Both 8.4 and 26.7 are supported releases. Why does MySQL still refuse a direct in-place upgrade from 8.4 to 26.7?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: 8.4 is not the immediately preceding LTS for 26.7; 9.7 is, so the two lineages differ

    A direct hop needs the source and target in one compatibility lineage, or the source to be the immediately preceding LTS for the target, as the MY-014060 message says. 9.7 is that LTS for 26.7, not 8.4.

  3. Question 3 of 15 A 3 TB MySQL 8.4 server has one two-hour night window to reach 9.7 LTS. A colleague proposes a MySQL Shell dump of the 8.4 server loaded into a fresh 9.7 server that night. What is wrong with the plan?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: The load rebuilds 3 TB of rows and indexes, which two hours rarely hold; an in-place hop, or a 9.7 replica prepared ahead and switched over in the window, fits instead

    The upgrade-path table makes 8.4 to 9.7 one supported hop for several methods, so the choice is about the window. In-place keeps it to one slow shutdown and one start, and a replica built ahead to a switchover; a logical load of 3 TB grows with the data.

  4. Question 4 of 15 A 60 GB MySQL 8.4 server moves to 9.7 LTS in a four-hour weekend window, and the team wants the 8.4 server left exactly as it was, so that falling back means pointing the application at it again. Which method gives that?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: A logical dump of the 8.4 server loaded into a fresh 9.7 server

    A logical upgrade copies the schema and rows into a new server and never writes to the old one, so falling back is pointing the application at it again. The cost is time: the load grows with the data, so the window has to hold the whole 60 GB load.

  5. Question 5 of 15 The recorded Shell checker reported warningCount: 21 under sysVars. What is the usual cause?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: System variables whose default value is different on the target version or whose current value is deprecated there

    The sysVars check targets settings whose defaults or validity change between versions; the warnings guide the next edit to the server's option files.

  6. Question 6 of 15 What does mysqlcheck --check-upgrade do on the server?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: It runs CHECK TABLE ... FOR UPGRADE on every table and prints a one-line status

    mysqlcheck is a client that batches CHECK TABLE commands; --check-upgrade runs the one that reports storage- level incompatibilities.

  7. Question 7 of 15 Which error code does 26.7.0 write when started on an 8.4.11 data directory?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: MY-014060

    MY-014060 names the refusal, and MY-010020 and MY-010119 explain the data-dictionary init failure and the server abort.

  8. Question 8 of 15 The recorded chain wrote two rows on 8.4.11. How many rows did SELECT COUNT(*) return after the upgrade to 26.7.0?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Two

    The chain carries user data unchanged across every hop; only the data dictionary and server metadata are upgraded in place.

  9. Question 9 of 15 A start writes MY-013381 "Server upgrade from '90702' to '260700' completed." to the error log. What happened?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: 26.7.0 binaries upgraded a data directory that 9.7.2 had last run

    MY-013381 names the server upgrade with each version written as a number, so 90702 to 260700 is 9.7.2 to 26.7.0, the one cross-lineage step the recorded chain is allowed to take.

  10. Question 10 of 15 In a primary-and-replicas setup, which server does the rolling-upgrade order touch first?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Every replica, before the source

    Replicas-first keeps the running topology on the supported "older source, newer replica" pair until the one source upgrade switches it to the all-new version.

  11. Question 11 of 15 What is the default value of replica_allow_higher_version_source on a 9.7 server?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: ON

    The 9.7 manual lists ON, so a replica accepts a higher-version source until someone sets OFF. The variable plays no part in the replicas-first phase, where the source is the older server; OFF is the lock a plan sets later.

  12. Question 12 of 15 After a 9.7.2 upgrade, what makes the labelled 8.4.11 backup the right rollback artefact?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: 8.4.11 refuses to start on a 9.7.2-upgraded data directory, so only a copy taken before the upgrade can run on 8.4.11

    A labelled backup from the running old server, taken before the slow shutdown, is the artefact 8.4.11 can run on; the live directory has moved forward.

  13. Question 13 of 15 Where does Chapter 4 of the 9.7 manual allow an in-place downgrade?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Only inside one LTS series, such as 8.4.11 back to 8.4.10

    In-place downgrades stay inside an LTS series. Going back a series, or within an Innovation series, takes a logical dump and load or replication, supported only for rollback before new server features touch the data.

  14. Question 14 of 15 Does running MySQL as a managed service change the one-step upgrade rule between lines?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: No; the service runs the same MySQL engine and keeps the one-step rule

    The services the lesson names publish version policies that list the lines they run and offer paid extended support once their own standard support ends, but the engine behind them keeps the one-step upgrade rule: each lists or allows major upgrades one version at a time.

  15. Question 15 of 15 Which major-version upgrades does the Amazon RDS for MySQL version page list?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: 5.7 to 8.0 and 8.0 to 8.4, one major version at a time

    The RDS page lists 5.7 to 8.0 and 8.0 to 8.4 as its major-version upgrades, one line each, while 9.5 to 9.7 and 26.7 run only in its preview environment.

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.

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

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

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

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

  6. 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-upgrade for storage-level issues and MySQL Shell util.checkForServerUpgrade for 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_source lets 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

Report a problem with this lesson

Quick answers and tool search

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