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.
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:
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
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
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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:
-
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?
-
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?
-
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?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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_SUBSETreturns 1 when every GTID of the first set is in the second;GTID_SUBTRACTreturns the GTIDs the first set has and the second lacks.- With
SOURCE_AUTO_POSITION = 1the 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_nextnames a batch of transactions in its own range on the same UUID, withTRANSACTION_GTID_TAGand 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
- MySQL 8.4 Reference Manual: 19.1.3.1 GTID Format and Storage (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.3.2 GTID Life Cycle (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.3.3 GTID Auto-Positioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.4.2 Enabling GTID Transactions Online (Oracle Corporation)
- MySQL 8.4 Reference Manual: 14.18.2 Functions Used with Global Transaction Identifiers (GTIDs) (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.4.1 Replication Mode Concepts (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.7.3 Skipping Transactions (Oracle Corporation)
- MySQL 8.4 Error Reference: Server Error Message Reference (Oracle Corporation)
- MySQL 9.7 Reference Manual: 19.1.6 Replication Options for GTIDs (Oracle Corporation)
- MariaDB Server Documentation: Global Transaction ID (MariaDB Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress