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 8 – Binary log and replication

GTIDs: sets, tags and auto-positioning

What a GTID is, how sets compress and compare, how auto-positioning finds what a replica lacks, and how a tag labels a batch of transactions.

  • Intermediate
  • 15 minutes
  • Examples run with MySQL 8.4.11 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: A GTID is a source id and a number · Sets compress ranges and two functions compare them · Auto-positioning sends only what the replica lacks · Tags name a batch. With a pass: 2 in-depth parts and 3 interview answers, about 10 minutes.

What you will learn

  • Read a GTID and the ranges in gtid_executed on a running server
  • Compare GTID sets with GTID_SUBSET and GTID_SUBTRACT
  • Explain how auto-positioning finds the transactions a replica lacks
  • Label a batch of transactions and read the tagged range in gtid_executed

Before you start

What you’ll learn · Before you start

What you will learn

  • Read a GTID and the ranges in gtid_executed on a running server
  • Compare GTID sets with GTID_SUBSET and GTID_SUBTRACT
  • Explain how auto-positioning finds the transactions a replica lacks
  • Label a batch of transactions and read the tagged range in gtid_executed

Before you start

On this page

A replica reconnecting to a source after a reboot resumes by comparing two GTID sets, which take a few bytes per range of consecutive transactions: in the recorded run after four committed transactions the server’s set reads as <node-uuid>:1-4. The pre-GTID protocol tracked a file name and a byte offset on each source instead, and finding them again on another source after a failover was error-prone.

This lesson reads a GTID off a MySQL 8.4 server, compares two sets with GTID_SUBSET and GTID_SUBTRACT, follows the handshake of auto-positioning and names a batch of transactions with a tag. Two parts then cover switching a running server into GTID mode and fixing an errant transaction on a replica.

A GTID is a source id and a number

Each committed transaction on a GTID-aware server carries a GTID: A global transaction identifier: a transaction's source server UUID and a positive integer that counts up on that source. The same GTID stays with the transaction on every replica that applies it.: the source’s server_uuid and a positive integer that counts up on that source. The pair never collides across servers, because the UUID is unique. The recorded session starts a server with gtid_mode = ON and enforce_gtid_consistency = ON, commits four transactions and reads the server’s own gtid_executed:

A server with GTIDs on; one UUID, four transactions, and five set-function calls

gtid_math.py

"""GTID sets and the two set functions on one throwaway MySQL 8.4.11 server.

The server starts with gtid_mode=ON and enforce_gtid_consistency=ON, commits a handful of transactions so that
gtid_executed grows into a compressed range, and then runs GTID_SUBSET and GTID_SUBTRACT on literal example UUIDs
so the output is byte-identical on every run. The literal UUIDs are 'aaaa…' and 'bbbb…': they are not the server's
own identifier and nothing on it needs them to be real.
"""
from mysql_session import Server

OPTIONS = [
    "--server-id=11",
    "--gtid-mode=ON",
    "--enforce-gtid-consistency=ON",
    "--log-replica-updates=ON",
]

with Server(options=OPTIONS, label="node") as node:
    node.up(show=True)

    node.sql(
        "SELECT @@gtid_mode AS gtid_mode, @@enforce_gtid_consistency AS enforce_gtid_consistency",
        note="GTIDs on; a statement a replica could not execute safely is refused at commit",
    )

    node.write_file(
        "load.sql",
        "CREATE DATABASE shop;\n"
        "CREATE TABLE shop.events (id INT PRIMARY KEY, note VARCHAR(60));\n"
        "INSERT INTO shop.events VALUES (1, 'placed');\n"
        "INSERT INTO shop.events VALUES (2, 'paid');\n",
        show=False,
    )
    node.mysql("load.sql", note="four transactions commit: CREATE DATABASE, CREATE TABLE and two INSERTs")

    node.sql(
        "SELECT @@global.gtid_executed AS gtid_executed",
        note="the server records each as a GTID; the four are collapsed into one range",
    )

    node.sql(
        "SELECT GTID_SUBSET('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5',"
        " 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10') AS prefix_is_subset",
        note="every GTID of the first set is in the second; 1 means subset, 0 would mean not",
    )

    node.sql(
        "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10',"
        " 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5') AS what_the_second_lacks",
        note="the first set has 1 to 10, the second only 1 to 5; what remains is 6 to 10",
    )

    node.sql(
        "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10',"
        " 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:4-6') AS set_with_a_gap",
        note="a hole in the middle compresses to two ranges separated by a colon",
    )

    node.sql(
        "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10:batch:1-5',"
        " 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10:batch:1-2') AS tagged_remainder",
        note="GTIDs tagged 'batch' live in their own range beside the untagged one and subtract apart",
    )

Output

$ mysqld --no-defaults --datadir=./data --bind-address=127.0.0.1 --port=$PORT --socket=mysql.sock --mysqlx=OFF --server-id=11 --gtid-mode=ON --enforce-gtid-consistency=ON --log-replica-updates=ON &

-- GTIDs on; a statement a replica could not execute safely is refused at commit
$ mysql -t -e "SELECT @@gtid_mode AS gtid_mode, @@enforce_gtid_consistency AS enforce_gtid_consistency"
+-----------+--------------------------+
| gtid_mode | enforce_gtid_consistency |
+-----------+--------------------------+
| ON        | ON                       |
+-----------+--------------------------+

-- four transactions commit: CREATE DATABASE, CREATE TABLE and two INSERTs
$ mysql -t -vvv --skip-comments < load.sql
--------------
CREATE DATABASE shop
--------------

Query OK, 1 row affected

--------------
CREATE TABLE shop.events (id INT PRIMARY KEY, note VARCHAR(60))
--------------

Query OK, 0 rows affected

--------------
INSERT INTO shop.events VALUES (1, 'placed')
--------------

Query OK, 1 row affected

--------------
INSERT INTO shop.events VALUES (2, 'paid')
--------------

Query OK, 1 row affected

Bye

-- the server records each as a GTID; the four are collapsed into one range
$ mysql -t -e "SELECT @@global.gtid_executed AS gtid_executed"
+-----------------+
| gtid_executed   |
+-----------------+
| <node-uuid>:1-4 |
+-----------------+

-- every GTID of the first set is in the second; 1 means subset, 0 would mean not
$ mysql -t -e "SELECT GTID_SUBSET('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10') AS prefix_is_subset"
+------------------+
| prefix_is_subset |
+------------------+
|                1 |
+------------------+

-- the first set has 1 to 10, the second only 1 to 5; what remains is 6 to 10
$ mysql -t -e "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5') AS what_the_second_lacks"
+-------------------------------------------+
| what_the_second_lacks                     |
+-------------------------------------------+
| aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:6-10 |
+-------------------------------------------+

-- a hole in the middle compresses to two ranges separated by a colon
$ mysql -t -e "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:4-6') AS set_with_a_gap"
+-----------------------------------------------+
| set_with_a_gap                                |
+-----------------------------------------------+
| aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-3:7-10 |
+-----------------------------------------------+

-- GTIDs tagged 'batch' live in their own range beside the untagged one and subtract apart
$ mysql -t -e "SELECT GTID_SUBTRACT('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10:batch:1-5', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10:batch:1-2') AS tagged_remainder"
+------------------------------------------------+
| tagged_remainder                               |
+------------------------------------------------+
| aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:batch:3-5 |
+------------------------------------------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gtid_math.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 (dump_defaults.py uses it).

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.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and 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 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\d sec\)", "", text)
        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 line gtid_executed reads <node-uuid>:1-4, with the server’s random UUID masked by the driver: four committed transactions on that source, numbered 1 to 4, which MySQL stores as one range because the numbers are consecutive. A hole in the numbers would print as two ranges separated by a colon, which the recorded gap test shows.

Checkpoint

Check: A GTID names each transaction once

  1. Question 1 of 2 What are the two parts of one GTID, as the recorded gtid_executed shows?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The source server's UUID and a transaction number that counts up on that source

    A GTID is one transaction's identity across every server that applies it. The source assigns one at commit, every replica stores the same one, and no two transactions on one source share a number.

  2. Question 2 of 2 The recorded gtid_executed is "<node-uuid>:1-4" after CREATE DATABASE, CREATE TABLE and two INSERT statements commit on the same server. Why one range and not four separate numbers?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A run of consecutive numbers from one source compresses to a range

    Four committed transactions reach four GTIDs, and the recorded "1-4" is how the server writes a run of them. A hole in the middle would print as two ranges separated by a colon, as the recorded set with a gap shows.

Sets compress ranges and two functions compare them

A GTID set: The set of GTIDs a server has seen, grouped by UUID and then by range. One range of consecutive numbers from one source is stored and printed as a pair of endpoints, so a set of millions of GTIDs takes a few bytes per range. stores every GTID a server has seen, grouped by UUID and then by range: a few bytes per range, no matter how many transactions it holds. Two set functions on recorded sets of one made-up UUID, shortened here to a, show the two questions a replication tool asks. GTID_SUBSET('a:1-5', 'a:1-10') returned 1, because every GTID of the first set is in the second; 0 would have meant the first set has at least one GTID the second lacks. GTID_SUBTRACT('a:1-10', 'a:1-5') returned a:6-10, naming what the first set has and the second lacks; the gap test GTID_SUBTRACT('a:1-10', 'a:4-6') returned a:1-3:7-10.

A server also tracks gtid_purged: The part of gtid_executed whose transactions are in none of the server's binary log files: files since purged, replicated commits applied with binary logging off, or GTIDs added with SET @@GLOBAL.gtid_purged. A source cannot send them, so a replica lacking any of them cannot catch up from it.: the part of gtid_executed that is in none of its binary log files, for example because the files were purged or because the set was written while a backup was restored. A source can feed only a replica that already holds all of it. A gap between a replica’s gtid_executed and the source’s gtid_purged is what the next section’s handshake tests against the source’s log retention.

Checkpoint

Check: Sets compress ranges and the two set functions

  1. Question 1 of 2 The recorded gtid_math session returned GTID_SUBTRACT("<uuid>:1-10", "<uuid>:1-5") as "<uuid>:6-10" and GTID_SUBSET("<uuid>:1-5", "<uuid>:1-10") as 1. Which statement describes the two functions?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: GTID_SUBTRACT returns the first set minus the second; GTID_SUBSET returns 1 when every GTID of the first is in the second

    One answers "what is missing?" and returns a set, the other answers "is everything covered?" and returns 1 or 0. Together they handle every comparison a replica needs to decide whether to connect, catch up or fence itself.

  2. Question 2 of 2 A source has gtid_executed as "<uuid>:1-9000" and gtid_purged as "<uuid>:1-6000". What does gtid_purged mean?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: GTIDs the source committed that are in none of its binary log files any more

    gtid_executed names everything the source has committed; gtid_purged names the part whose events are gone from its files, after expiry, PURGE BINARY LOGS or a restore that set it directly. A replica that lacks any of it cannot catch up from this source.

Auto-positioning sends only what the replica lacks

With SOURCE_AUTO_POSITION = 1 set on the replica, START REPLICA runs a short handshake. The replica sends the GTIDs it has committed or already received: its gtid_executed plus the received set of its relay log. The source subtracts that from its own gtid_executed and streams only the GTIDs the replica is missing, from the earliest gap onwards:

Interactive · stage by stage What a replica sends and what the source sends back Follow one START REPLICA from the replica's gtid_executed to the stream of GTIDs the source returns.
What a replica sends and what the source sends back, step 5 of 5. Showing START REPLICA, Replica sends set, Source works out gap, Source streams GTIDs, Replica applies them, Replica caught up. In focus: Replica caught up.GTID purged: error 1236START REPLICAauto-position onReplica sends setcommitted + receivedSource works out gapwhat the set lacksSource streams GTIDsonly the missing onesReplica applies themand updates its setReplica caught upsets match on both sides

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

Every step as text
  1. The replica knows what it has. On START REPLICA with auto-position on, the replica sends the GTIDs it has committed or already received: its gtid_executed plus the received set of its relay log, a few bytes per range.
  2. Source finds the difference. The source compares the replica's set to its own gtid_executed with the same subtraction GTID_SUBTRACT performs and gets the list of GTIDs the replica is missing, from the earliest gap onwards.
  3. Log retention rules the stream. The missing GTIDs need to be in the source's binary logs. When one is in the source's gtid_purged, the source cannot stream it: the replica stops with fatal error 1236 and needs the missing GTIDs from another server or a fresh copy.
  4. Only the missing ones stream. When the gap is within the source's logs, the source streams exactly those GTIDs, in order. A new replica starting from a fresh backup receives the whole history; an existing one receives only what it is behind.
  5. Replica applies and converges. The replica writes each GTID to its own binary log (log_replica_updates on) and to gtid_executed. When the sets match, the replica is caught up; the operator can promote either side.

No file name or offset is involved, so re-pointing the replica at another source needs no bookkeeping.

Auto-positioning cannot help a replica that lacks GTIDs the source has already purged: their events are in none of the source’s files, so the replica stops with fatal error 1236 from the source. It needs the missing transactions from another server that still has them, or a fresh copy from a backup or the clone plugin, so its gtid_executed jumps forward to a point the source still carries.

Checkpoint

Check: Auto-positioning sends only what the replica lacks

  1. Question 1 of 2 SOURCE_AUTO_POSITION=1 is set on a replica. What does the replica send on START REPLICA, and what does the source send back?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The replica sends its gtid_executed; the source sends every transaction the replica's set lacks

    The replica declares what it already has; the source works out and sends the rest. No file name, no offset, and a swap between replicas needs no bookkeeping beyond the GTID sets themselves.

  2. Question 2 of 2 A replica with gtid_executed "<uuid>:1-1000" reconnects to a source whose gtid_executed is "<uuid>:1-9000" and gtid_purged is "<uuid>:1-5000". What happens on START REPLICA?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The source cannot send the GTIDs between 1001 and 5000, and replication stops with an error

    Auto-positioning depends on the source still holding every transaction the replica needs. Replicate the missing ones from another server that has them, or re-provision the replica from a backup or with the clone plugin.

Tags name a batch

A GTID tag: A short label (8.3 and later) a session attaches to its next transactions with SET gtid_next='AUTOMATIC:<tag>', so their GTIDs land in a tagged range on the same UUID beside the untagged range., available from 8.3 onwards, is a short label a session attaches to its next transactions so their GTIDs land in a separate range on the same UUID. SET gtid_next = 'AUTOMATIC:batch' followed by one INSERT in the recorded tags session produced gtid_executed of <node-uuid>:1-6:batch:1, with the untagged range beside the tagged range of one:

A session tagging its next transaction, and the privileges the tag needs

tags.py

"""Tagged GTIDs on one throwaway MySQL 8.4.11 server.

Tags, available from 8.3 onwards, let a transaction's GTID carry a label beside its number. The session sets up a
table, commits one plain transaction so gtid_executed shows the untagged range, then creates a user who may insert
rows but cannot change session variables; that user's SET gtid_next='AUTOMATIC:batch' fails. After the server grants
TRANSACTION_GTID_TAG and SESSION_VARIABLES_ADMIN, the same statement succeeds and the next INSERT lands in the
tagged range beside the untagged one.
"""
from mysql_session import Server

OPTIONS = [
    "--server-id=11",
    "--gtid-mode=ON",
    "--enforce-gtid-consistency=ON",
    "--log-replica-updates=ON",
]

with Server(options=OPTIONS, label="node") as node:
    node.up(show=False)

    node.write_file(
        "seed.sql",
        "CREATE DATABASE shop;\n"
        "CREATE TABLE shop.events (id INT PRIMARY KEY, note VARCHAR(60));\n"
        "INSERT INTO shop.events VALUES (1, 'first');\n",
        show=False,
    )
    node.mysql("seed.sql", note="three untagged transactions set the scene")

    node.sql(
        "SELECT @@global.gtid_executed AS gtid_executed",
        note="untagged GTIDs so far: a plain 1-3 range",
    )

    node.write_file(
        "make_user.sql",
        "CREATE USER 'tagger'@'127.0.0.1' IDENTIFIED BY 'taggerpass';\n"
        "GRANT INSERT ON shop.events TO 'tagger'@'127.0.0.1';\n",
        show=True,
    )
    node.mysql("make_user.sql", note="a user with INSERT but no admin privilege")

    (node.base / "tagger.cnf").write_text(
        "[client]\n"
        "user=tagger\n"
        "host=127.0.0.1\n"
        f"port={node.port}\n"
        "protocol=TCP\n"
        "password=taggerpass\n"
    )
    (node.base / "tagger.cnf").chmod(0o600)

    node.write_file(
        "set_tag.sql",
        "SET gtid_next = 'AUTOMATIC:batch';\n",
        show=True,
    )
    root_cnf = node.client_cnf
    node.client_cnf = node.base / "tagger.cnf"
    node.mysql(
        "set_tag.sql",
        errors=1,
        note="running as 'tagger': the SET fails before any transaction can be tagged",
    )
    node.client_cnf = root_cnf

    node.sql(
        "GRANT TRANSACTION_GTID_TAG, SESSION_VARIABLES_ADMIN ON *.* TO 'tagger'@'127.0.0.1'",
        note="give the two dynamic privileges a tagging session needs",
    )

    node.write_file(
        "tag_insert.sql",
        "SET gtid_next = 'AUTOMATIC:batch';\n"
        "INSERT INTO shop.events VALUES (2, 'batch 1');\n",
        show=True,
    )
    node.client_cnf = node.base / "tagger.cnf"
    node.mysql(
        "tag_insert.sql",
        note="running as 'tagger' again: the SET passes and the INSERT lands in the tagged range",
    )
    node.client_cnf = root_cnf

    node.sql(
        "SELECT @@global.gtid_executed AS gtid_executed",
        note="the tagged GTID lives beside the untagged range; subtraction treats them apart",
    )

Output

-- three untagged transactions set the scene
$ mysql -t -vvv --skip-comments < seed.sql
--------------
CREATE DATABASE shop
--------------

Query OK, 1 row affected

--------------
CREATE TABLE shop.events (id INT PRIMARY KEY, note VARCHAR(60))
--------------

Query OK, 0 rows affected

--------------
INSERT INTO shop.events VALUES (1, 'first')
--------------

Query OK, 1 row affected

Bye

-- untagged GTIDs so far: a plain 1-3 range
$ mysql -t -e "SELECT @@global.gtid_executed AS gtid_executed"
+-----------------+
| gtid_executed   |
+-----------------+
| <node-uuid>:1-3 |
+-----------------+

$ cat make_user.sql
CREATE USER 'tagger'@'127.0.0.1' IDENTIFIED BY 'taggerpass';
GRANT INSERT ON shop.events TO 'tagger'@'127.0.0.1';

-- a user with INSERT but no admin privilege
$ mysql -t -vvv --skip-comments < make_user.sql
--------------
CREATE USER 'tagger'@'127.0.0.1' IDENTIFIED BY 'taggerpass'
--------------

Query OK, 0 rows affected

--------------
GRANT INSERT ON shop.events TO 'tagger'@'127.0.0.1'
--------------

Query OK, 0 rows affected

Bye

$ cat set_tag.sql
SET gtid_next = 'AUTOMATIC:batch';

-- running as 'tagger': the SET fails before any transaction can be tagged
$ mysql -t -vvv --skip-comments --force < set_tag.sql
--------------
SET gtid_next = 'AUTOMATIC:batch'
--------------

ERROR 6038 (HY000) at line 1: Access denied; you need the TRANSACTION_GTID_TAG and at least one of the: SYSTEM_VARIABLES_ADMIN, SESSION_VARIABLES_ADMIN or REPLICATION_APPLIER privilege(s) for this operation
Bye

-- give the two dynamic privileges a tagging session needs
$ mysql -t -e "GRANT TRANSACTION_GTID_TAG, SESSION_VARIABLES_ADMIN ON *.* TO 'tagger'@'127.0.0.1'"

$ cat tag_insert.sql
SET gtid_next = 'AUTOMATIC:batch';
INSERT INTO shop.events VALUES (2, 'batch 1');

-- running as 'tagger' again: the SET passes and the INSERT lands in the tagged range
$ mysql -t -vvv --skip-comments < tag_insert.sql
--------------
SET gtid_next = 'AUTOMATIC:batch'
--------------

Query OK, 0 rows affected

--------------
INSERT INTO shop.events VALUES (2, 'batch 1')
--------------

Query OK, 1 row affected

Bye

-- the tagged GTID lives beside the untagged range; subtraction treats them apart
$ mysql -t -e "SELECT @@global.gtid_executed AS gtid_executed"
+-------------------------+
| gtid_executed           |
+-------------------------+
| <node-uuid>:1-6:batch:1 |
+-------------------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tags.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 (dump_defaults.py uses it).

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.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and 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 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\d sec\)", "", text)
        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()

Tags belong on grouped work someone will later audit, skip or route separately, such as a nightly batch or a data correction.

Checkpoint

Check: Tags name a transaction's purpose

  1. One question Which SQL names a transaction with the tag "batch", as the recorded tags session uses?

    Choose one answer.

    Show the answer: One question

    Answer: SET gtid_next = 'AUTOMATIC:batch'

    A tag changes gtid_next for the session so that every transaction while the tag is in effect records with it. SET gtid_next = 'AUTOMATIC' clears it again, and the untagged range on the UUID resumes.

Switching into GTID mode on a running server, without stopping writes Pro: opens with a Pro, Premium or Ultimate pass

Switching a running server from gtid_mode = OFF to gtid_mode = ON takes several steps the manual records, each small enough that writes keep flowing. The server enforces the order, since gtid_mode moves one value at a time. This part walks the ladder across a topology, names the defaults that change after 8.4, and shows the trap that catches an upgrade whose option file leaves the two variables unset.

In this part:

  • Switching the GTID mode step by step
  • The upgrade-on-by-default trap on 9.7 and later

1 diagram · about 9 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Finding and fixing one errant transaction on a replica Pro: opens with a Pro, Premium or Ultimate pass

An errant transaction: A transaction committed on a replica but not on its source, usually from a write made on the replica directly: its GTID is in the replica's gtid_executed and not in the source's. is a GTID present on a replica but not on the source, left there by something that wrote to the replica directly. Promoting that replica after a source failure leaves a stream whose GTID sets will not line up again. This part reproduces one, names the two usual fixes and lists the two levers that keep it from happening in the first place.

In this part:

  • Detecting the errant GTID and fixing it
  • Which fix fits, and what prevents the next one

1 runnable example (errant.py, mysql_session.py) · about 9 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise

The questions below use the recorded gtid_math and tags sessions and the figure of the auto-position exchange; weakest idea first:

Practice that adapts to you

Practise this lesson

9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 A GTID stays the same on every server that applies it. Why?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: The source assigns the GTID at commit and every replica stores the same one when it applies

    The source's own UUID in the GTID keeps it unique even across many servers, and every replica writes the GTID the source assigned, so a promotion changes who writes new ones but never who owned the old ones.

  2. Question 2 of 9 Why is "1-9000000" more useful than listing every number from 1 to 9,000,000?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: The stored set stays small and comparisons stay fast as a replica catches up

    The compact form is why checking a replica against a source stays cheap even after months of writes. A set with holes still prints every one of them, which is how an errant transaction stays visible.

  3. Question 3 of 9 GTID_SUBTRACT(replica.gtid_executed, source.gtid_executed) returns a non-empty set. What does that mean?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: The replica has committed one or more transactions the source does not know about

    Promoting a replica with a non-empty subtraction leaves the old source without those GTIDs, so a later rebuild of it as a replica will try to apply them again and may collide with its data. Fix every errant GTID before a promotion.

  4. Question 4 of 9 On a freshly initialized source with no backup restored, how do gtid_executed and gtid_purged relate?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Both are empty, because no transaction has committed and no log has been removed

    The purged set grows when expiry or PURGE BINARY LOGS removes files whose GTIDs are in gtid_executed. Restoring a backup can also set gtid_purged to what the backup covers, so a replica built from it asks its source only for what came after.

  5. Question 5 of 9 You have to write one monitoring query that answers "can this replica still catch up from its source?" using only the two servers' GTID sets. Which comparison answers it?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: GTID_SUBSET(source.gtid_purged, replica.gtid_executed): the replica can catch up only when it already holds everything the source has dropped

    Health here is about retention, not about distance: a replica may be a million transactions behind and still be fine, as long as the source has kept every event it still needs. Alerting on GTID_SUBSET(source.gtid_purged, replica.gtid_executed) returning 0 catches the one case that cannot be fixed by waiting, and it fires before the replica tries to reconnect and fails.

  6. Question 6 of 9 Which problem does SOURCE_AUTO_POSITION solve compared to CHANGE REPLICATION SOURCE TO with a file name and offset?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: A failover that re-points the replica at another source needs no new file name or offset to be worked out

    File-and-offset bookkeeping is per-source: a failover means someone has to find the right position on the new source. With auto-positioning, the sets do the work and the person doing the failover names only the host.

  7. Question 7 of 9 A replica configured with SOURCE_AUTO_POSITION=1 starts after a long outage, and the source's gtid_purged covers transactions the replica still needs. What does the manual say happens?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The receiver stops with fatal error 1236 from the source; the replica needs the missing transactions from another server or a fresh copy

    The prevention is binary log retention on the source longer than any replica's longest outage; the cure is the missing transactions from another server or a fresh copy. Monitoring the gap between replica.gtid_executed and source.gtid_purged is a routine health check for this exact reason.

  8. Question 8 of 9 The recorded tags session shows the user running SET gtid_next = 'AUTOMATIC:batch' failing with error 6038. Which privileges does the error message name as required?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: TRANSACTION_GTID_TAG and at least one of SYSTEM_VARIABLES_ADMIN, SESSION_VARIABLES_ADMIN or REPLICATION_APPLIER

    The two-piece check lets the operator share tag-setting without handing out full admin: granting the GTID tag privilege alone is not enough, and the session variables admin alone is not enough.

  9. Question 9 of 9 A nightly batch rebuilds a reporting table with a dozen UPDATE statements. Why would an operator run them with gtid_next = 'AUTOMATIC:batch' set for the session?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: A tagged range of GTIDs makes the batch easy to name in a GTID_SUBTRACT, so the operator can confirm it finished and skip it on a point-in-time restore

    Tags belong on grouped work that someone will later audit, skip or route separately. The nightly rebuild is a good fit; an unstructured application write is not, because the point of a tag is to isolate work by purpose.

Interview questions

Why does auto-positioning compare GTID sets instead of file names and offsets?

A GTID set is a few bytes per range of consecutive transactions on each source, so comparing two is cheap even across millions of transactions. A file name and offset identifies one position on one source: on a failover the operator has to work out the equivalent position on the new source, which the pre-GTID protocol made error-prone and sometimes impossible without re-provisioning. The GTID set survives a change of source because the UUID in each GTID stays the same, so the replica keeps the record of what it has applied, and the new source works out what to send next with the same subtraction GTID_SUBTRACT performs on demand. A failover becomes a change of host in the replica’s CHANGE REPLICATION SOURCE TO, and no bookkeeping beyond that.

Three harder questions, each with its first step and follow-ups:

  1. Mid to senior

    One source reports gtid_executed as 'a:1-120000' and gtid_purged as 'a:1-100000'. Two replicas report 'a:1-105000' and 'a:1-80000' respectively. What does each pair mean for a reconnect, and which replica is healthy?

    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 staff

    A monitoring probe discovers an errant GTID on a replica: GTID_SUBTRACT(replica, source) returns '<replica-uuid>:1', which turned out to be an INSERT a maintenance script ran on the replica by mistake. A failover of the source is likely within the hour. Which fix do you choose, and what else do you change?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A fleet of 8.4 servers runs with gtid_mode and enforce_gtid_consistency both unset. The team wants to upgrade to 9.7 next quarter without surprises. What do you set, where and in what order, and what do you watch for on each stage of the upgrade?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

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

  1. What is a GTID made of?

    The originating server's server_uuid and a positive number that counts up on that server. The pair never collides across servers and stays with the transaction everywhere it replicates.

  2. Why does a GTID set stay small, however many transactions it covers?

    It groups GTIDs by UUID and keeps consecutive numbers as ranges, a few bytes each: four commits read as uuid:1-4, and a hole in the numbering shows up as two ranges.

  3. What do GTID_SUBSET and GTID_SUBTRACT return?

    GTID_SUBSET returns 1 when every GTID of the first set is also in the second. GTID_SUBTRACT returns what the first set has and the second lacks: a:1-10 minus a:4-6 is a:1-3:7-10.

  4. What is gtid_purged?

    The part of gtid_executed found in none of the server's binary log files, because those files were purged or the set was written while a backup was restored. A source can serve only a replica that already holds all of it.

  5. What happens when a replica with SOURCE_AUTO_POSITION = 1 starts?

    It sends its gtid_executed plus the GTIDs its relay log has received; the source subtracts them from its own set and streams only the missing transactions. No file name or offset is needed, so pointing it at another source needs no bookkeeping.

  6. Why can a replica on auto-positioning stop with fatal error 1236?

    It is missing GTIDs that the source has already purged, so their events are in none of the source's files. It needs them from a server that still has them, or a fresh copy from a backup or the clone plugin.

  7. What does a GTID tag such as SET gtid_next = 'AUTOMATIC:batch' do?

    From 8.3 it files the session's next transactions in their own tagged range on the same UUID, uuid:1-6:batch:1 in the run, so a batch can be audited, skipped or routed later. Setting it needs TRANSACTION_GTID_TAG and session-variables admin.

Key takeaways

  • A GTID is a source’s UUID and a number that counts up on that source; it stays with the transaction on every server, and runs of them are stored as ranges.
  • GTID_SUBSET returns 1 when every GTID of the first set is in the second; GTID_SUBTRACT returns the GTIDs the first set has and the second lacks.
  • With SOURCE_AUTO_POSITION = 1 the replica sends the GTIDs it holds and the source streams only the missing ones, unless it has already purged some of them.
  • A tag on gtid_next names a batch of transactions in its own range on the same UUID, with TRANSACTION_GTID_TAG and session-variables admin needed to set it.

Terms in this lesson

anonymous transaction
A transaction logged without a GTID, as every transaction is on a server whose gtid_mode is OFF. The binary log marks it with an Anonymous_gtid_log_event, and it can be addressed only by binary log file name and position.
errant transaction
A transaction committed on a replica but not on its source, usually from a write made on the replica directly: its GTID is in the replica's gtid_executed and not in the source's.
GTID
A global transaction identifier: a transaction's source server UUID and a positive integer that counts up on that source. The same GTID stays with the transaction on every replica that applies it.
GTID auto-positioning
The replication handshake turned on by SOURCE_AUTO_POSITION=1: the replica sends the GTIDs it has committed or already received (its gtid_executed plus its received set), and the source streams every transaction of its binary log whose GTID is not in that set.
GTID set
The set of GTIDs a server has seen, grouped by UUID and then by range. One range of consecutive numbers from one source is stored and printed as a pair of endpoints, so a set of millions of GTIDs takes a few bytes per range.
GTID tag
A short label (8.3 and later) a session attaches to its next transactions with SET gtid_next='AUTOMATIC:<tag>', so their GTIDs land in a tagged range on the same UUID beside the untagged range.
gtid_executed
A MySQL server's record of every GTID it has applied: either generated locally (a source) or received from a source (a replica). Read it with @@global.gtid_executed.
gtid_purged
The part of gtid_executed whose transactions are in none of the server's binary log files: files since purged, replicated commits applied with binary logging off, or GTIDs added with SET @@GLOBAL.gtid_purged. A source cannot send them, so a replica lacking any of them cannot catch up from it.

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.