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

Set up GTID replication with TLS

Build a MySQL 8.4 source and replica over TLS with auto-positioning, read SHOW REPLICA STATUS, and provision new replicas without stale GTIDs.

  • Intermediate
  • 15 minutes
  • Examples run with MySQL 8.4.11 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A replication account on its own, with TLS required · One CHANGE REPLICATION SOURCE statement, over TLS, with auto-positioning · SHOW REPLICA STATUS: twelve fields a running system cares about. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Create a replication account and connect a replica over TLS with auto-positioning
  • Build a new replica from a dump that seeds GTID_PURGED
  • Read SHOW REPLICA STATUS and the performance_schema replication tables
  • Use the 8.4 statement names and avoid the removed ones

Before you start

A production MySQL system almost always has at least one replica following the primary server: a second server that reads the primary’s binary log and applies the same transactions, so a failed primary can be replaced in minutes. On MySQL 8.4 the 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. More in “GTIDs: sets, tags and auto-positioning” gives every transaction a stable name, so a replica on 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. More in “GTIDs: sets, tags and auto-positioning” can ask the source “send me everything after what I already have” over a TLS connection, and reconnect to a new source after a failover with the same request.

You will build the whole set-up: a dedicated account that requires TLS, one CHANGE REPLICATION SOURCE TO statement that turns TLS and auto-positioning on, and the twelve SHOW REPLICA STATUS fields a running system reads first.

A replication account on its own, with TLS required

A replica logs in to its source as a MySQL user, so the first step is a user for that one job. The account needs no schema privileges at all; the only grant it needs is REPLICATION SLAVE. Give the account its own name (never reuse root), restrict its host to the hosts a replica may run on, and add REQUIRE SSL, which refuses any login that does not use TLS. The whole account fits in two statements:

A source and a replica on 127.0.0.1 with TLS and auto-positioning

two_node.py

"""Source and replica on 127.0.0.1 with TLS and auto-positioning (MySQL 8.4.11).

Shows the whole set-up a replica needs:
  - a least-privilege replication account that must use TLS (REQUIRE SSL),
  - CHANGE REPLICATION SOURCE TO with SOURCE_AUTO_POSITION=1 and SOURCE_SSL=1,
  - START REPLICA with USER and PASSWORD given at start time, so no password is stored in the connection metadata,
  - the running state in SHOW REPLICA STATUS: Replica_IO_Running and Replica_SQL_Running Yes, Source_SSL_Allowed Yes,
    Auto_Position 1 and Seconds_Behind_Source 0.
(learn-lint-allow inclusive: the removed statements are named as MySQL spelled them)
Also reproduces the removed 8.4 statements as syntax errors: CHANGE MASTER TO, START SLAVE, SHOW SLAVE STATUS, and
GRANT REPLICATION REPLICA. Last, a replica started with --super-read-only=ON refuses INSTALL PLUGIN (ERROR 1290).
"""
import re
import sys
import time
from mysql_session import Server


def wait_sbs_zero(replica, timeout=30):
    """Waits until SHOW REPLICA STATUS reads Seconds_Behind_Source 0. The field is a clock difference counted in
    whole seconds, so right after the last transaction was applied it can still read 1 or more; the record shows the
    settled value."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        _, out = replica._call(["mysql", f"--defaults-file={replica.client_cnf}", "-E", "-e", "SHOW REPLICA STATUS"])
        m = re.search(r"Seconds_Behind_Source:\s*(\S+)", out)
        if m and m.group(1) == "0":
            return
        time.sleep(0.5)
    sys.exit("Seconds_Behind_Source did not settle at 0")

SRC = ["--server-id=1", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON"]
REP = ["--server-id=2", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON",
       "--read-only=ON", "--super-read-only=ON", "--skip-replica-start=ON"]

with Server(options=SRC, label="source", port_label="<source-port>") as source, \
     Server(options=REP, label="replica", port_label="<replica-port>") as replica:
    source.up()
    replica.up()

    print("# 1. Create the replication account on the source. Least privilege (REPLICATION SLAVE) and REQUIRE SSL.")  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
    source.write_file("repluser.sql",
                      "-- The privilege is still named REPLICATION SLAVE in 8.4; REPLICATION REPLICA is a syntax error.\n"  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
                      "CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL;\n"
                      "GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1';\n"  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
                      "GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1';\n", show=True)
    source.mysql("repluser.sql", errors=1)

    print("# 2. Seed a row on the source so the replica has something to catch up to.")
    source.write_file("seed.sql",
                      "CREATE DATABASE shop;\n"
                      "CREATE TABLE shop.t (id INT PRIMARY KEY, v INT);\n"
                      "INSERT INTO shop.t VALUES (1, 10), (2, 20);\n", show=False)
    source.mysql("seed.sql", note="seed data on the source")

    print("# 3. Point the replica at the source over TLS with auto-positioning.")
    replica.change_source(source, user="repl", password="replpass",
                          auto_position=True, ssl=True, verify_ca=True)

    print("# 4. Start the replica with USER/PASSWORD given at start time, so no password is stored in the metadata.")
    replica.start_replica(user="repl", password="replpass")
    replica.wait_replica_running()
    replica.wait_caught_up(source)
    wait_sbs_zero(replica)

    print("# 5. Replication health: IO and SQL threads Yes, SSL allowed, auto-position on, 0 seconds behind.")
    replica.replica_status(["Replica_IO_Running", "Replica_SQL_Running", "Source_SSL_Allowed",
                            "Auto_Position", "Seconds_Behind_Source", "Last_Error"])

    print("# 6. The row the source wrote is on the replica too, read through its read-only session.")
    replica.sql("SELECT id, v FROM shop.t ORDER BY id", db="shop")

    print("# 7. The 8.4 names: the old statements are removed. One ERROR line per statement.")
    replica.write_file("removed_names.sql",
                       "CHANGE MASTER TO SOURCE_HOST='127.0.0.1';\n"  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
                       "START SLAVE;\n"  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
                       "SHOW SLAVE STATUS;\n", show=True)  # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
    replica.mysql("removed_names.sql", errors=3)

    print("# 8. super_read_only=ON blocks INSTALL PLUGIN on a running replica (ERROR 1290).")
    replica.sql("INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so'",
                errors=1, note="install plugins BEFORE you enable super_read_only")

Output

# 1. Create the replication account on the source. Least privilege (REPLICATION SLAVE) and REQUIRE SSL.
$ cat repluser.sql
-- The privilege is still named REPLICATION SLAVE in 8.4; REPLICATION REPLICA is a syntax error.
CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL;
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1';
GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1';

$ mysql -t -vvv --skip-comments --force < repluser.sql
--------------
CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL
--------------

Query OK, 0 rows affected

--------------
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'
--------------

Query OK, 0 rows affected

--------------
GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1'
--------------

ERROR 1064 (42000) at line 4: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'REPLICA ON *.* TO 'repl'@'127.0.0.1'' at line 1
Bye

# 2. Seed a row on the source so the replica has something to catch up to.
-- seed data on the source
$ mysql -t -vvv --skip-comments < seed.sql
--------------
CREATE DATABASE shop
--------------

Query OK, 1 row affected

--------------
CREATE TABLE shop.t (id INT PRIMARY KEY, v INT)
--------------

Query OK, 0 rows affected

--------------
INSERT INTO shop.t VALUES (1, 10), (2, 20)
--------------

Query OK, 2 rows affected
Records: 2  Duplicates: 0  Warnings: 0

Bye

# 3. Point the replica at the source over TLS with auto-positioning.
$ mysql -t -vvv --skip-comments < _change_source.sql
--------------
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='127.0.0.1',
  SOURCE_PORT=<source-port>,
  SOURCE_USER='repl',
  SOURCE_PASSWORD='replpass',
  SOURCE_AUTO_POSITION=1,
  SOURCE_SSL=1,
  SOURCE_SSL_CA='source-ca.pem'
--------------

Query OK, 0 rows affected, 1 warning

Bye

# 4. Start the replica with USER/PASSWORD given at start time, so no password is stored in the metadata.
$ mysql -t -vvv --skip-comments < _start_replica.sql
--------------
START REPLICA USER='repl' PASSWORD='replpass'
--------------

Query OK, 0 rows affected

Bye

# 5. Replication health: IO and SQL threads Yes, SSL allowed, auto-position on, 0 seconds behind.
mysql> SHOW REPLICA STATUS\G
  Replica_IO_Running: Yes
  Replica_SQL_Running: Yes
  Source_SSL_Allowed: Yes
  Auto_Position: 1
  Seconds_Behind_Source: 0
  Last_Error: 

# 6. The row the source wrote is on the replica too, read through its read-only session.
$ mysql -t shop -e "SELECT id, v FROM shop.t ORDER BY id"
+----+------+
| id | v    |
+----+------+
|  1 |   10 |
|  2 |   20 |
+----+------+

# 7. The 8.4 names: the old statements are removed. One ERROR line per statement.
$ cat removed_names.sql
CHANGE MASTER TO SOURCE_HOST='127.0.0.1';
START SLAVE;
SHOW SLAVE STATUS;

$ mysql -t -vvv --skip-comments --force < removed_names.sql
--------------
CHANGE MASTER TO SOURCE_HOST='127.0.0.1'
--------------

ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'MASTER TO SOURCE_HOST='127.0.0.1'' at line 1
--------------
START SLAVE
--------------

ERROR 1064 (42000) at line 2: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SLAVE' at line 1
--------------
SHOW SLAVE STATUS
--------------

ERROR 1064 (42000) at line 3: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SLAVE STATUS' at line 1
Bye

# 8. super_read_only=ON blocks INSTALL PLUGIN on a running replica (ERROR 1290).
-- install plugins BEFORE you enable super_read_only
$ mysql -t -e "INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so'"
ERROR 1290 (HY000) at line 1: The MySQL server is running with the --super-read-only option so it cannot execute this statement

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

Step 1 of the recorded run creates 'repl'@'127.0.0.1' with REQUIRE SSL and grants REPLICATION SLAVE. Then it tries GRANT REPLICATION REPLICA, the natural-sounding name, and the server refuses it with ERROR 1064: the privilege is still called REPLICATION SLAVE in 8.4. The newer REPLICA family of statements (CHANGE REPLICATION SOURCE TO, START REPLICA, SHOW REPLICA STATUS) does not extend to privileges.

Key idea

The replication account: A MySQL user whose only privilege is REPLICATION SLAVE and whose only job is to log in from a replica to read the source's binary log. Created with REQUIRE SSL, it is refused any login that does not use TLS. carries one privilege (REPLICATION SLAVE) and REQUIRE SSL, and no access to any table: it can read the binary log and nothing else.

One CHANGE REPLICATION SOURCE statement, over TLS, with auto-positioning

Step 3 of the recorded run points the replica at the source. In 8.4 the one statement is CHANGE REPLICATION SOURCE TO, with SOURCE_HOST, SOURCE_PORT, SOURCE_USER and SOURCE_SSL=1 for a TLS connection, and SOURCE_AUTO_POSITION=1 for GTID auto-positioning. The example also sets SOURCE_SSL_CA to the authority the server trusts (the server generates ca.pem in its data directory when it finds no certificates), so the replica checks that the source’s certificate chains to that authority; SOURCE_SSL_VERIFY_SERVER_CERT=1 would also check that the certificate names the host.

Interactive · message by message A replica joining a source over TLS with auto-positioning Walk through the five steps between CHANGE REPLICATION SOURCE and the first replicated row.
A replica joining a source over TLS with auto-positioning, step 5 of 5. Messages so far: Replica to Replica: SOURCE_SSL=1, AUTO_POSITION=1; Replica to Replica: USER, PASSWORD at start time; Replica to Source: TLS, then login as repl; Replica to Source: send me gtids after my set; Source to Replica: transactions follow the gap.Replicaboth threads Yes, SBS 0Sourcebinary log open, feeding eventsSOURCE_SSL=1, AUTO_POSITION=1USER, PASSWORD at start timeTLS, then login as replsend me gtids after my settransactions follow the gapa running GTID replica

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

Every step as text
  1. CHANGE REPLICATION SOURCE. The replica is told who the source is, who to log in as, that the connection uses TLS and that it is on auto-positioning. Nothing travels yet; the settings land in the connection metadata.
  2. START REPLICA. The replica is started with USER and PASSWORD passed in at start time, so the password lives only in the running server's memory and never in the metadata repository on disk.
  3. TLS handshake and login. The replica opens a TLS connection to the source and checks the source's certificate against the certificate authority it was given (SOURCE_SSL_CA) before it sends the user name and password.
  4. Replica asks for the gap. The replica tells the source the GTIDs it has committed or received. The source answers with every transaction after them, in order. Nothing in the set is replayed; nothing is skipped.
  5. Caught up, both threads ON. The IO thread writes received transactions to the relay log; the applier reads it and commits them. When the applier has nothing left to apply, Seconds_Behind_Source is 0 and Replica_IO_Running and Replica_SQL_Running both read Yes.

Step 7 of the recorded run shows the older names, deprecated during 8.0 and removed in 8.4. CHANGE MASTER TO, START SLAVE and SHOW SLAVE STATUS each return ERROR 1064: a script written for an older version runs into a syntax error, not a silent misconfiguration.

Step 4 then runs START REPLICA USER='repl' PASSWORD='replpass'. Passing the password at start time keeps it in the memory of the running replica only, so it never lands in the replica’s connection metadata on disk. The server recommends this form itself: when a user name or password is stored with CHANGE REPLICATION SOURCE TO, it logs warning MY-010897, which calls storing them in the connection metadata repository not secure.

Checkpoint

Check: The replication account and TLS · Auto-positioning and CHANGE REPLICATION SOURCE

  1. Question 1 of 2 Which privilege does the replication account need, on MySQL 8.4?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: REPLICATION SLAVE

    The privilege a replication connection needs is still called REPLICATION SLAVE in MySQL 8.4, even though the related statements (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) have been renamed.

  2. Question 2 of 2 A migration from 5.7 runs the script CHANGE MASTER TO SOURCE_HOST='...' on the 8.4 replica. What happens, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ERROR 1064, because CHANGE MASTER TO has been removed in 8.4

    MySQL 8.4 removes CHANGE MASTER TO, START SLAVE and SHOW SLAVE STATUS. Scripts that still use the old names fail with ERROR 1064 at parse time rather than running silently.

SHOW REPLICA STATUS: twelve fields a running system cares about

Step 5 of the recorded run reads the state of the replica, and step 6 finds the source’s row on it. SHOW REPLICA STATUS prints dozens of fields, and twelve of them answer the first questions about a running replica. For the receiver (IO) thread: whether the connection to the source is up (Replica_IO_Running), whether the channel is set to use TLS (Source_SSL_Allowed), the current state of the thread (Replica_IO_State, usually “Waiting for source to send event”), and its last error (Last_IO_Error). The applier (SQL) thread has its own: Replica_SQL_Running, Last_SQL_Error and the delay from the source’s commit time, Seconds_Behind_Source, measured against the events already received, so it reads 0 once the applier has caught up with the receiver. Auto_Position is 1 when the connection is on auto-positioning, as in this run. Retrieved_Gtid_Set is what the IO thread has received so far, and Executed_Gtid_Set is what the SQL thread has committed; the two match when the replica is caught up and no apply is in flight.

Step 8 then shows a last catch of the first-time setup: a replica running with super_read_only=ON refuses INSTALL PLUGIN with ERROR 1290. If semi-sync or any plugin a replica may need is installed only after the read-only flag is on, the install itself cannot run. The habit is to install every plugin a replica may need before the server reaches its read-only state, and the lesson’s two_node.py session reproduces that error on purpose so the fix is remembered.

Checkpoint

Check: Reading SHOW REPLICA STATUS

  1. Question 1 of 2 In the recorded two_node.py run, Replica_IO_Running is Yes, Replica_SQL_Running is Yes and Seconds_Behind_Source is 0. What does the pair say about the replica?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both threads are running, and the applier has applied every transaction the IO thread has received so far

    SBS 0 means the applier has caught up with the IO thread. It is a necessary condition for "replica is up to date" but not sufficient: comparing the source's gtid_executed with the replica's Retrieved_Gtid_Set shows what never arrived.

  2. Question 2 of 2 The recorded run tries INSTALL PLUGIN rpl_semi_sync_replica after the replica is up with super_read_only=ON. What does the server do, and how do you avoid the error on a real set-up?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ERROR 1290. Install every plugin a replica may need before super_read_only is turned on.

    super_read_only blocks every write, INSTALL PLUGIN included. The habit is to install plugins before the read-only flag is on, during the first few seconds of the server's life.

Provisioning a replica: a dump, GTID_PURGED and the clone plugin, with the trade-off Pro: opens with a Pro, Premium or Ultimate pass

A replica starts empty. For it to catch up by replaying every transaction from the beginning of the binary log, the source would need to still have the binary log file that holds row 1, which almost no production server does: binary logs are rotated and purged. A new replica is therefore seeded from a snapshot of the source, and told, through GTID_PURGED, which transactions that snapshot already carries.

In this part:

  • Seed the purged set from a consistent snapshot

2 runnable examples (SQL, mysql_session.py, provision.py) · about 7 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).

Reading replication health from performance_schema, including a stopped-IO drill Pro: opens with a Pro, Premium or Ultimate pass

SHOW REPLICA STATUS is a snapshot of about forty fields in one long table. performance_schema holds the same facts in four structured tables that update continuously, by channel and by worker, with microsecond timestamps instead of whole seconds. A monitoring system reads them directly, and so does a human looking at a mystery lag.

In this part:

  • Four PFS tables for channel health

1 diagram · 1 runnable example (mysql_session.py, status_fields.py) · about 7 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: read a replica from its recorded sessions

The questions below come back to the account, the CHANGE REPLICATION SOURCE TO statement and the status fields, weakest idea first:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 6 Which clause on CREATE USER refuses any login over a plain-text connection?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: REQUIRE SSL

    REQUIRE SSL on an account refuses every plain-text login, including replication. Pair it with SOURCE_SSL=1 on the channel and the replica's connection is encrypted end to end.

  2. Question 2 of 6 Which option turns auto-positioning on in a CHANGE REPLICATION SOURCE TO statement?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: SOURCE_AUTO_POSITION=1

    SOURCE_AUTO_POSITION=1 is the single option that puts the connection on GTID auto-positioning; without it the channel tracks a binary log file name and position instead.

  3. Question 3 of 6 In SHOW REPLICA STATUS, which field tells whether the channel is set to connect over TLS?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Source_SSL_Allowed (Yes or No)

    Source_SSL_Allowed = Yes means the channel is set to use TLS. Pair it with the account's REQUIRE SSL, which refuses any other login, and a running receiver is known to be on an encrypted connection.

  4. Question 4 of 6 A migration script carries over three lines from a 5.7 source. Only one of them creates a working MySQL 8.4 replication account that must use TLS. Which one?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: CREATE USER 'repl'@'10.0.0.%' IDENTIFIED BY '...' REQUIRE SSL; GRANT REPLICATION SLAVE ON . TO 'repl'@'10.0.0.%'

    A good replication account has a name of its own, a host that fits the network it will log in from, REQUIRE SSL at the account level and only the REPLICATION SLAVE grant.

  5. Question 5 of 6 Which of these are needed on CHANGE REPLICATION SOURCE TO to put the replica of this lesson on TLS with auto-positioning against the source on 127.0.0.1?

    Choose every answer that is right.

    Show the answer: Question 5 of 6

    Answer:

    • SOURCE_SSL=1
    • SOURCE_HOST='127.0.0.1'
    • SOURCE_SSL_CA='source-ca.pem'
    • SOURCE_AUTO_POSITION=1

    The host, the account, SOURCE_AUTO_POSITION=1, SOURCE_SSL=1 and a CA file are the five parts of the recorded statement. SOURCE_SSL=1 only encrypts; the CA file is what lets the replica check that the certificate it is offered was issued by the authority it trusts. The server refuses a file name and offset together with SOURCE_AUTO_POSITION=1, and setting one is the usual sign that someone copied a pre-GTID runbook.

  6. Question 6 of 6 A replica's SHOW REPLICA STATUS shows both threads Yes and Seconds_Behind_Source 0, yet a row the source changed a minute ago still reads the old value there. Which comparison settles whether the replica has received everything?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: The source's gtid_executed against the replica's Retrieved_Gtid_Set

    Seconds_Behind_Source is the applier against the receiver, so 0 says the replica has applied what it received. Whether it received everything is a question for the GTID sets: subtracting the replica's Retrieved_Gtid_Set from the source's gtid_executed, as GTID_SUBTRACT does, names whatever never arrived.

On a server you look after

Run SHOW REPLICA STATUS\G on a replica and find the twelve fields of this lesson: both Running flags, Source_SSL_Allowed, Auto_Position, Seconds_Behind_Source, the two Last_*_Error fields, Replica_IO_State, Source_Host, Source_User and the two GTID sets.

Interview questions

Three questions go further than the set-up: a new replica whose GTID state is wrong, an account and a channel that missed TLS, and the choice between a dump and the clone plugin for terabytes of data.

  1. Mid to senior

    A fresh MySQL 8.4 replica has been loaded with mysqldump of the source and then started with CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1. The applier stops at once with a row conflict. How do you diagnose and fix the state, and how do you keep the next new replica from falling into the same trap?

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

  2. Mid to senior

    A code review of a replication set-up finds that the repl account has no REQUIRE SSL clause and that CHANGE REPLICATION SOURCE TO sets SOURCE_USER and SOURCE_PASSWORD without SOURCE_SSL=1. Nothing on the running server looks wrong. What is the risk, and what sequence of changes fixes it on a running pair without stopping writes?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A 2.1 TB InnoDB database must be copied to a new replica in the same data centre, overnight. The schema has no MyISAM tables, the source carries about 4,000 writes a second, and the operations team has used mysqldump for years. Which provisioning path do you choose, and what is the plan, including the roll-back?

    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. Which grants does a replication account need on MySQL 8.4?

    Only the privilege that lets it read the binary log, plus REQUIRE SSL so that it logs in over TLS alone; no table access at all. GRANT REPLICATION REPLICA fails with ERROR 1064 because that privilege kept its older name in 8.4.

  2. Which statement connects an 8.4 replica over TLS with auto-positioning?

    CHANGE REPLICATION SOURCE TO with SOURCE_HOST, SOURCE_PORT, SOURCE_USER, SOURCE_SSL=1 and SOURCE_AUTO_POSITION=1. SOURCE_SSL_CA makes the replica check the source's certificate chain; SOURCE_SSL_VERIFY_SERVER_CERT=1 also checks the host name in it.

  3. Why give the replication password to START REPLICA rather than store it with CHANGE REPLICATION SOURCE TO?

    START REPLICA USER=... PASSWORD=... keeps it only in the running replica's memory. A stored one goes into the connection metadata on disk, and the server warns about that with MY-010897.

  4. What happens on 8.4 when a script uses the replication statement names deprecated during 8.0?

    They were removed, so each one returns ERROR 1064: the script stops with a syntax error instead of misconfiguring anything. The replacements use the words SOURCE and REPLICA, as in START REPLICA.

  5. Which SHOW REPLICA STATUS fields answer the first questions about a running replica?

    Replica_IO_Running and Replica_SQL_Running, Source_SSL_Allowed, Auto_Position, Seconds_Behind_Source, Last_IO_Error and Last_SQL_Error, Replica_IO_State, Source_Host, Source_User, and Retrieved_Gtid_Set beside Executed_Gtid_Set.

  6. What does Seconds_Behind_Source measure?

    The applier's delay behind the source's commit times, measured against the events the replica has already received, so it reads 0 once the applier has caught up with the receiver.

  7. Why install every plugin a replica may need before super_read_only is on?

    With super_read_only=ON, INSTALL PLUGIN fails with ERROR 1290, so a semi-sync plugin left for later cannot be installed.

Key takeaways

  • A replication account carries only REPLICATION SLAVE and REQUIRE SSL; its password is passed to START REPLICA at start time, not stored in the connection metadata.
  • One CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1, SOURCE_SSL=1 is enough to connect a replica on 8.4; CHANGE MASTER TO, START SLAVE and SHOW SLAVE STATUS are removed and return ERROR 1064.
  • Plugins a replica may need go in before super_read_only is on; afterwards INSTALL PLUGIN fails with ERROR 1290.
  • The twelve SHOW REPLICA STATUS fields to read first are the two Running flags, Source_SSL_Allowed, Auto_Position, Seconds_Behind_Source, the two Last_*_Error fields, Replica_IO_State, Source_Host, Source_User, and the Retrieved_Gtid_Set / Executed_Gtid_Set pair.

Terms in this lesson

clone plugin
A server plugin, mysql_clone.so, that copies a running donor server's data over the network into a recipient server: CLONE INSTANCE FROM removes the recipient's own schemas, tables and binary logs and writes the donor's data in their place.
multi-source replication
A set-up in which one replica reads from several sources at once, each on its own channel. Each channel keeps its own connection, relay log and applier, and is named by FOR CHANNEL '<name>' in CHANGE REPLICATION SOURCE TO, START REPLICA and SHOW REPLICA STATUS.
relay log
A replica's numbered files, in the binary log's format, holding the events the receiver (IO) thread fetched from the source until the applier runs them. A file rotates at max_relay_log_size (max_binlog_size when that is 0), and the applier deletes each file once it has run all its events.
replication account
A MySQL user whose only privilege is REPLICATION SLAVE and whose only job is to log in from a replica to read the source's binary log. Created with REQUIRE SSL, it is refused any login that does not use TLS.
replication channel
A named replication connection on a replica: one source, one connection, one relay log and one applier. Multi-source replication runs several channels in parallel, each addressed by FOR CHANNEL '<name>'.
super_read_only
A system variable: when ON, the server refuses client updates even from accounts with SUPER or CONNECTION_ADMIN, while replication threads still apply replicated transactions. Setting it ON also sets read_only ON. INSTALL PLUGIN and INSTALL COMPONENT are refused too, as they write to the mysql system schema.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

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