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 4 – InnoDB internals

InnoDB architecture: pages, tablespaces, threads

What InnoDB keeps in memory, which file every structure ends up in, how pages, extents and segments grow, and the background threads a MySQL 8.4 server runs.

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

What you will learn

  • Map InnoDB's in-memory and on-disk structures to the files they live in
  • Explain what pages, extents and segments are and how a tablespace grows
  • Choose between a file-per-table, a general and the system tablespace for a table
  • Read a table's own definition out of its tablespace with ibd2sdi
  • Name the InnoDB background threads and the other engines a server still loads

Before you start

What you’ll learn · Before you start

What you will learn

  • Map InnoDB's in-memory and on-disk structures to the files they live in
  • Explain what pages, extents and segments are and how a tablespace grows
  • Choose between a file-per-table, a general and the system tablespace for a table
  • Read a table's own definition out of its tablespace with ibd2sdi
  • Name the InnoDB background threads and the other engines a server still loads

Before you start

On this page

A MySQL 8.4.11 server that has just been initialised already holds 27 entries in its data directory, and ten of them are InnoDB’s: a system tablespace, the file the data dictionary lives in, two undo tablespaces, a global temporary tablespace, two doublewrite files, a buffer pool dump, a folder of redo files and a folder holding ten more tablespaces. None of them is the table you are about to create, and none is written when you commit.

This tour maps those names onto the structures that use them. You will watch one UPDATE travel from a page in memory to the files it eventually reaches, read the page, extent and segment sizes out of a running server, decide which kind of tablespace a table belongs in, pull a table’s own definition out of its .ibd file with ibd2sdi, count the background threads a server starts before any client connects, and see which storage engines besides InnoDB a stock server still loads.

Every change happens in memory first

A commit of one row changes no table file. InnoDB works on copies of pages held in memory, writes a compact record of what changed to the redo log, and lets a background thread put the page into the table’s file whenever that suits it. The InnoDB page: InnoDB's unit of storage and of every read and write: a block of 16 KB by default, holding rows or index entries. Every tablespace of one server uses the same page size, which is fixed when the data directory is initialised. is the unit of all of this: 16 KB of rows or index entries, read and written whole.

Two in-memory structures carry a write. The buffer pool holds pages, both the clean ones read from files and the dirty ones changed since. The log buffer holds the change records themselves, which are far smaller than the pages they describe. Step through one row change and watch which structure it touches at each moment:

Interactive · step by step One UPDATE through memory and into the files Step through one row change, from the page in memory to the files it finally reaches.
One UPDATE through memory and into the files, step 6 of 6. Showing One UPDATE, Buffer pool (a dirty page), Log buffer, Undo tablespaces, orders.ibd (the new page), Redo log files. In focus: orders.ibd.old version16 KB pagesat commitOne UPDATEone row of ordersBuffer poola dirty pageLog bufferchanges, not pagesUndo tablespacesundo_001 and 002orders.ibdthe new pageRedo log filesin #innodb_redodirty pagesthrough the .dblwr files

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

Every step as text
  1. The page has to be in memory. InnoDB changes nothing in a file directly. The 16 KB page holding the row is read from the table's own file into the buffer pool, or is found there already from an earlier read.
  2. The row changes in memory. The statement edits the row inside that copy of the page. The page is now dirty: it differs from the 16 KB sitting in the file, and no file has changed yet.
  3. The old version goes to undo. Before the row is overwritten, its earlier version is written to an undo log in an undo tablespace, so that a rollback, or a reader that started earlier, can still see it.
  4. The change is logged. The change itself, not the page, goes to the log buffer and from there to the redo log files, which must be on disk before COMMIT returns. These are small sequential writes.
  5. The commit is already safe. The statement is now durable although orders.ibd still holds the old 16 KB. Recovery would replay the redo record over that page, which is why the commit never waits for the table's file.
  6. The page is written much later. A page cleaner thread writes dirty pages when it suits InnoDB, not when a statement ends, and each page passes through the doublewrite files on its way.

The order in the figure is the reason InnoDB survives a power cut without writing a table file at every commit: the redo record reaches disk at commit, and recovery can replay it over an old page afterwards. The buffer pool, the redo log and doublewrite buffer and the undo logs each have a lesson of their own in this module; here they are landmarks on the map.

Key idea

A commit makes the change durable in the redo log, not in the table’s file. Every page a statement touched is written later, by a background thread, and a listing of the data directory therefore says nothing about how much work the server is doing.

The files a fresh server already has

The recorded session below initialises a server, starts it and then lists its data directory before a single user table exists. The second listing is the folder of session temporary tablespaces, and the two statements read the page size and every InnoDB file the server opened:

A fresh 8.4.11 data directory, file by file

datadir_tour_session.py

"""Every file a brand-new MySQL 8.4.11 data directory holds, and the InnoDB structure behind each name.

A throwaway server (mysql_session.py) is initialised and started, and then the session lists the data directory and
the folder of session temporary tablespaces inside it, and asks the server for the page size and for the files InnoDB
opened at startup. Nothing has been created by a user yet, so every file here belongs to the server itself.

The #innodb_redo folder is left out of the listing on purpose: the number of spare redo files in it changes while the
server starts, so two runs of this program would not print the same thing.

The FILES query shows mysql.ibd, the data dictionary tablespace, with its extent count as a band of 20 to 40. That
count differs by a few extents between fresh servers (25 on one run of this program, 28 on others), so the query
prints the band instead, and the lesson reads growth off the stable ibdata1 (12 extents) and undo files (16 each).
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.listing("data")
    my.listing("data/#innodb_temp")
    my.mysql("datadir_tour.sql")

Output

$ ls -p data
#ib_16384_0.dblwr  #ib_16384_1.dblwr  #innodb_redo/  #innodb_temp/  auto.cnf
binlog.000001  binlog.index  ca-key.pem  ca.pem  client-cert.pem
client-key.pem  ib_buffer_pool  ibdata1  ibtmp1  mysql.ibd  mysql.sock
mysql.sock.lock  mysql/  mysql_upgrade_history  performance_schema/
private_key.pem  public_key.pem  server-cert.pem  server-key.pem  sys/
undo_001  undo_002

$ ls -p data/#innodb_temp
temp_1.ibt  temp_10.ibt  temp_2.ibt  temp_3.ibt  temp_4.ibt  temp_5.ibt
temp_6.ibt  temp_7.ibt  temp_8.ibt  temp_9.ibt

$ mysql -t -vvv --skip-comments < datadir_tour.sql
--------------
SELECT @@innodb_page_size AS page_bytes, @@innodb_page_size * 64 / 1024 / 1024 AS extent_mb
--------------

+------------+------------+
| page_bytes | extent_mb  |
+------------+------------+
|      16384 | 1.00000000 |
+------------+------------+
1 row in set

--------------
SELECT FILE_NAME, FILE_TYPE, TABLESPACE_NAME,
       IF(TABLESPACE_NAME = 'mysql' AND TOTAL_EXTENTS BETWEEN 20 AND 40, '20 to 40', TOTAL_EXTENTS) AS TOTAL_EXTENTS,
       EXTENT_SIZE
FROM information_schema.FILES
ORDER BY FILE_NAME
--------------

+----------------------+------------+------------------+---------------+-------------+
| FILE_NAME            | FILE_TYPE  | TABLESPACE_NAME  | TOTAL_EXTENTS | EXTENT_SIZE |
+----------------------+------------+------------------+---------------+-------------+
| ./ibdata1            | TABLESPACE | innodb_system    | 12            |     1048576 |
| ./ibtmp1             | TEMPORARY  | innodb_temporary | 12            |     1048576 |
| ./mysql.ibd          | TABLESPACE | mysql            | 20 to 40      |     1048576 |
| ./sys/sys_config.ibd | TABLESPACE | sys/sys_config   | 0             |     1048576 |
| ./undo_001           | UNDO LOG   | innodb_undo_001  | 16            |     1048576 |
| ./undo_002           | UNDO LOG   | innodb_undo_002  | 16            |     1048576 |
+----------------------+------------+------------------+---------------+-------------+
6 rows in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 datadir_tour_session.py

datadir_tour.sql

-- The page size of this instance, and the size of an extent of 64 such pages.
SELECT @@innodb_page_size AS page_bytes, @@innodb_page_size * 64 / 1024 / 1024 AS extent_mb;

-- Every InnoDB file the server opened before any user table existed, with the tablespace it belongs to and the
-- extents it is made of. EXTENT_SIZE is in bytes. The data dictionary's mysql.ibd prints its extents as a band,
-- because that count differs by a few extents from one fresh server to the next (a count outside the band prints
-- as it is).
SELECT FILE_NAME, FILE_TYPE, TABLESPACE_NAME,
       IF(TABLESPACE_NAME = 'mysql' AND TOTAL_EXTENTS BETWEEN 20 AND 40, '20 to 40', TOTAL_EXTENTS) AS TOTAL_EXTENTS,
       EXTENT_SIZE
FROM information_schema.FILES
ORDER BY FILE_NAME;

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=()):
        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.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."""
        self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        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):
        """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)."""
        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)

    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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        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.])", "<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 (the session uses it to wait or to decide)."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql)
        if code != 0:
            sys.exit(f"the query failed: {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


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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

The InnoDB structures behind those names are these, and most of them have a lesson of their own in this module:

In the listing What it is
ibdata1 The system tablespace: InnoDB's shared tablespace, whose data file is ibdata1 by default. It is the storage area for the change buffer, and also holds tables when innodb_file_per_table is switched off; its file never shrinks., which holds the change buffer and anything created in it
mysql.ibd The tablespace of the data dictionary: The server's own metadata: the definitions of every schema, table, column and index. Since MySQL 8.0 they are InnoDB tables inside the mysql.ibd tablespace, so DDL commits and rolls back like any transaction.: the server’s own metadata tables
undo_001, undo_002 The two undo tablespaces, where earlier versions of changed rows are kept
#innodb_redo/ The redo log files, written at every commit
#ib_16384_0.dblwr Doublewrite storage, one pair of files named after the 16,384-byte page size
ibtmp1, #innodb_temp/ The global temporary tablespace, and the pool of ten session temporary tablespaces

The query confirms what the names suggest. information_schema.FILES reported ibdata1 as the file of innodb_system with 12 extents, mysql.ibd as the tablespace named mysql, each undo file as an UNDO LOG of 16 extents, and EXTENT_SIZE as 1,048,576 bytes everywhere. The dictionary’s own count prints as a band of 20 to 40 extents, because it differs by a few extents from one fresh server to the next. The manual’s pool of ten .ibt files is there in full, and they are recreated at every start, so nothing in that folder survives a restart.

Deleting a file because the server is not using it

A stopped server looks like a folder of files, and ibdata1 on a server with no tables in it looks empty. Removing it, or an undo file, or the redo folder, does not save 12 MB; it removes the change buffer and the undo and redo state that recovery needs, and the server refuses to start. Shrink a data directory by dropping tables and rebuilding the instance, never by deleting its files.

Checkpoint

Check: Where a committed change really is · The files of a fresh data directory

  1. Question 1 of 3 COMMIT has just returned for an UPDATE of one row. Which file is certain to hold that change?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: A redo log file in

    The figure's order is the answer: page changed in memory, undo record written, change record to the log buffer and then to the redo files, which the commit waits for. The table file is written afterwards.

  2. Question 2 of 3 In the recorded listing, which file holds the table definitions of the server itself?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: mysql.ibd

    Dictionary data lives in InnoDB tables in mysql.ibd, in the data directory, so a CREATE TABLE is a transaction with the usual commit and rollback behaviour.

  3. Question 3 of 3 How many .ibt session temporary tablespaces did the recorded #innodb_temp folder hold after startup?

    Type a number.

    A hint

    The second listing of the first session shows the folder in full.

    Show the answer: Question 3 of 3

    Answer: 10

    A pool of ten session temporary tablespaces is created at every start, named temp_1.ibt to temp_10.ibt, and the pool is recreated on the next start, so nothing in that folder outlives a restart.

Pages, extents and segments

The page is the smallest unit, but it is not the unit a file grows by. InnoDB groups 64 consecutive 16 KB pages into an extent: A run of consecutive InnoDB pages allocated as one unit: 64 pages of 16 KB, so 1 MB. Once a structure is past its first 32 pages, InnoDB allocates whole extents, which is why files grow in megabyte steps. of 1 MB, which is why the recorded EXTENT_SIZE was 1,048,576 and why file sizes move in megabyte steps. A segment: A set of extents inside a tablespace used for one purpose. Each index gets two: one for the leaf pages that hold the data and one for the branch pages above them, so leaf pages stay contiguous on disk. is a run of extents used for one purpose, and the manual is precise about how many each index gets: two, one for the leaf pages that hold the data and one for the branch pages above them.

Growth follows those levels. The first 32 pages of a new segment are allocated one page at a time, so a small table stays small; after that InnoDB allocates whole extents, and up to four at once, so that the pages of one index stay close together on disk. Step out one level at a time:

Interactive · stage by stage From one row to the file that holds it Step out one level at a time and see what each level of InnoDB space is made of.
From one row to the file that holds it, step 6 of 6. Showing One row, A 16 KB page, A 1 MB extent, A segment, The tablespace (grows by extents). In focus: A 1 MB extent, The tablespace.One rowwhat you insertedA 16 KB pagerows in key orderA 1 MB extent64 pages in a rowA segmenttwo per indexThe tablespacegrows by extents

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

Every step as text
  1. The row. A row never sits on its own. It lives inside a page of the index that stores it, beside its neighbours in key order.
  2. The page. The page is InnoDB's unit of reading and writing: 16,384 bytes on this server, the value innodb_page_size reported. Every read, every write and every buffer-pool slot is one page.
  3. The extent. 64 consecutive 16 KB pages make an extent of 1 MB, the unit InnoDB allocates in once a structure has grown past its first 32 pages. It may add four extents at a time to keep data sequential.
  4. The segment. A segment is a run of extents used for one purpose. Each index gets two: one for its leaf pages, which hold the data, and one for the branch pages above them.
  5. The tablespace. The tablespace is the file, or the files, those segments live in. The recorded system tablespace ibdata1 held 12 extents, and the data dictionary mysql.ibd is a tablespace of the same kind.
  6. Why the levels matter. Because they explain growth. A file does not grow row by row but extent by extent, and the space a dropped table gives back is returned in extents too.

The clustered index: The index that stores an InnoDB table's rows in its leaf pages, in the order of its key. It is the primary key where one is declared, so the table and that index are one structure. More in “InnoDB clustering: tables stored by primary key” of a table has its own pair of segments, and so does every secondary index: Any InnoDB index other than the clustered one. Each of its entries holds the indexed columns plus the primary key of the row, which it uses to search the clustered index when a query needs the rest of the row. More in “InnoDB clustering: tables stored by primary key”, which is why adding an index to a large table adds extents in proportion to the index, not to the number of rows you insert next. Reserved space is part of the design too: 12.5 % of a file segment’s pages are kept empty by default, so that the B-tree can keep allocating contiguous pages.

Checkpoint

Check: Pages, extents and segments

  1. Question 1 of 2 With the recorded page size of 16,384 bytes, how many pages are there in one 1 MB extent?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 64

    1,048,576 divided by 16,384 is 64, which is why EXTENT_SIZE was reported as 1,048,576 bytes and why a tablespace grows by whole megabytes once a segment is past its first 32 pages.

  2. Question 2 of 2 How many segments does InnoDB allocate for one index?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Two: one for leaf pages and one for the branch pages above them

    Two segments per index, leaf and non-leaf, is the manual's rule. It is the reason the data pages of an index can be read in long sequential runs.

Which tablespace holds which data

A tablespace: One or more files holding InnoDB pages. A table's data lives in a file-per-table, general or system tablespace; undo and temporary tablespaces belong to the server itself and hold no user tables. is one or more files with InnoDB pages in them, and a table’s data has to live in one of three kinds. The default is a file-per-table tablespace: The default home of a new InnoDB table: one .ibd file named after the table, in the folder of its schema. Truncating or dropping the table returns its space to the operating system.: CREATE TABLE makes one .ibd file per table in the schema’s folder, which the next section’s session shows. A general tablespace is one you create yourself with CREATE TABLESPACE and then put many tables in. The system tablespace, ibdata1, takes tables when innodb_file_per_table is off, and holds the change buffer whatever you do.

Two further kinds are the server’s alone: undo tablespaces (undo_001 and undo_002 in the listing, plus any you add) and temporary tablespaces (ibtmp1 and the .ibt pool). Neither is a place you put a table.

The choice between the three that take tables is a real decision, and it has nothing to do with speed of queries. Answer the three questions in the table for the server you are planning, and watch options drop out:

Interactive · answer and compare Which tablespace should hold this table? Answer three questions about the tables you are planning and see which choices survive.
Will tables be dropped or truncated, with the disk space needed back afterwards?
How many tables will this server hold?
Does one table's data have to sit on a path of its own?

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

The three places an InnoDB table's data can live, against what each one costs you.
Tablespace Space back after a drop Open files and fsyncs Tables per tablespace Metadata kept in memory
File-per-table Created by: CREATE TABLE, the default Good truncating or dropping the table returns its space to the operating system Poor one open file handle per table, and fsyncs cannot be combined across tables Mixed exactly one: 2,000 tables mean 2,000 .ibd files Mixed tablespace metadata is kept in memory for every one of those files
General Created by: CREATE TABLESPACE Poor the space stays inside the shared file for InnoDB to use again Good one file for many tables, so fewer handles and fewer fsyncs Good as many as you put there, and they can be moved in with ALTER TABLE Good fewer tablespaces hold less metadata in memory than the same tables apart
System (ibdata1) Created by: innodb_file_per_table OFF Poor ibdata1 does not shrink; the space is reused inside it Good one shared file, which also holds the change buffer Mixed any number, but mixed in with the server's own change buffer Good one tablespace to keep metadata for, whatever it holds
How the answers decide
  • “Will tables be dropped or truncated, with the disk space needed back afterwards?” Yes, tables come and go: rules out System (ibdata1) and adds a caveat to General. Dropping a table in a shared tablespace releases space only inside that file, and the file does not shrink, so the disk never comes back.
  • “How many tables will this server hold?” Thousands: adds a caveat to File-per-table. Thousands of file-per-table tablespaces mean thousands of open file handles and thousands of sets of tablespace metadata in memory.
  • “How many tables will this server hold?” A few dozen and “Will tables be dropped or truncated, with the disk space needed back afterwards?” No, the schema is stable: adds a caveat to System (ibdata1). With few tables and no drops, a shared file that uses its own empty extents again costs little, but everything then sits in one file with the change buffer.
  • “Does one table's data have to sit on a path of its own?” Yes, its own disk or path: rules out System (ibdata1). The system tablespace's data files are set once for the whole server, so a single table cannot be given a disk of its own there.
  • “Does one table's data have to sit on a path of its own?” One place for everything and “How many tables will this server hold?” Thousands: adds a caveat to General. One general tablespace can hold all those tables in one file, which is the case its memory advantage was written for.

In short: keep the default unless a server will hold thousands of tables, in which case a general tablespace saves file handles and the per-tablespace metadata the server keeps in memory for every open tablespace; and avoid putting tables in ibdata1, because a shared file never shrinks when a table in it is dropped.

Checkpoint

Check: The three tablespaces a table can live in

  1. Question 1 of 3 A plain CREATE TABLE on a stock 8.4 server puts the table's data where?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: In a file-per-table tablespace, one .ibd file in the schema's folder

    innodb_file_per_table is on by default, so every new table gets its own .ibd file, named after the table, in the folder of its schema.

  2. Question 2 of 3 A 40 GB table in the system tablespace is dropped. What happens to the disk space the file was using?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: It stays inside ibdata1, which does not shrink, and InnoDB uses it for new data

    Truncating or dropping a table in a shared tablespace leaves empty space inside its data file. Only a file-per-table tablespace returns the space to the operating system when the table goes.

  3. Question 3 of 3 A multi-tenant server will hold about 8,000 small tables that are never dropped. Which choice does that argue for, and why?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: A general tablespace, because 8,000 .ibd files cost file handles and per-tablespace metadata in memory

    With many tables and no churn, the per-tablespace costs dominate: open file handles, metadata kept in memory for every open tablespace, and fsyncs that cannot be combined across files.

The data dictionary and the copy in every tablespace

Since MySQL 8.0 there are no .frm files: the table definitions live in InnoDB tables inside mysql.ibd, so a CREATE TABLE is a transaction with the same commit, rollback and recovery guarantees as your own data. That is also a single point of failure, and InnoDB answers it by writing a second copy of each definition into the tablespace that holds the table, as serialized dictionary information (SDI): A second copy of an object's definition, which InnoDB writes as compact JSON into the tablespace file that holds the object. The ibd2sdi tool prints it, so a definition can be read without the data dictionary. in a compact JSON form.

The session below creates one table, shows that its data directory entry is a single orders.ibd, stops the server and runs ibd2sdi on that file. The program prints the tool’s first lines as they came out, then picks the table’s own record apart:

A table's definition read out of its own .ibd file

sdi_session.py

"""A table's own definition, read out of its .ibd file with ibd2sdi, while the server is shut down.

The session creates one small table in a throwaway MySQL 8.4.11 server, shows the file the table lives in, stops the
server and then runs ibd2sdi on that file. ibd2sdi prints the whole serialized dictionary information of the
tablespace as JSON, a few hundred lines, so the session prints its first lines as the tool wrote them and then picks
two objects out of the same JSON: the table's name and the definition of one column.
"""

import json

from mysql_session import Server

KEEP = ("name", "type", "column_type_utf8", "ordinal_position", "char_length", "is_nullable", "column_key")

with Server() as my:
    my.up()
    my.mysql("sdi.sql")
    my.listing("data/shop")
    my.stop()                                   # ibd2sdi reads a file no running server is writing to
    dump = my.output("ibd2sdi", "data/shop/orders.ibd")

    print("$ ibd2sdi data/shop/orders.ibd   # the first lines, as the tool printed them")
    print("\n".join(dump.splitlines()[:12]))
    print(f"... {len(dump.splitlines())} lines in all\n")

    records = [r for r in json.loads(dump)[1:] if isinstance(r, dict)]
    table = next(r["object"]["dd_object"] for r in records if r["object"]["dd_object_type"] == "Table")
    columns = table["columns"]
    print("the records in this file:",
          ", ".join(f"{r['object']['dd_object_type']} (type {r['type']}, id {r['id']})" for r in records))
    print("the table it describes:", f"{table['schema_ref']}.{table['name']}, {len(columns)} columns,",
          f"{len(table['indexes'])} indexes, engine {table['engine']}")
    print("its columns, in the order the record lists them:", ", ".join(c["name"] for c in columns))
    print("\nthe second column, with the keys this program keeps:")
    print(json.dumps({k: v for k, v in columns[1].items() if k in KEEP}, indent=2))

Output

$ mysql -t -vvv --skip-comments < sdi.sql
--------------
CREATE DATABASE shop
--------------

Query OK, 1 row affected

--------------
CREATE TABLE shop.orders (
  id        BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  reference CHAR(12)        NOT NULL,
  amount    DECIMAL(10, 2)  NOT NULL,
  placed_at DATETIME        NOT NULL,
  UNIQUE KEY by_reference (reference)
)
--------------

Query OK, 0 rows affected

--------------
INSERT INTO shop.orders (reference, amount, placed_at)
VALUES ('MSC-00000001', 1250.00, '2031-03-04 09:15:00')
--------------

Query OK, 1 row affected

--------------
SELECT NAME, SPACE_TYPE, PAGE_SIZE FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'shop/orders'
--------------

+-------------+------------+-----------+
| NAME        | SPACE_TYPE | PAGE_SIZE |
+-------------+------------+-----------+
| shop/orders | Single     |     16384 |
+-------------+------------+-----------+
1 row in set

Bye

$ ls -p data/shop
orders.ibd

$ ibd2sdi data/shop/orders.ibd   # the first lines, as the tool printed them
["ibd2sdi"
,
{
	"type": 1,
	"id": 366,
	"object":
		{
    "mysqld_version_id": 80411,
    "dd_version": 80300,
    "sdi_version": 80019,
    "dd_object_type": "Table",
    "dd_object": {
... 396 lines in all

the records in this file: Table (type 1, id 366), Tablespace (type 2, id 7)
the table it describes: shop.orders, 6 columns, 2 indexes, engine InnoDB
its columns, in the order the record lists them: id, reference, amount, placed_at, DB_TRX_ID, DB_ROLL_PTR

the second column, with the keys this program keeps:
{
  "name": "reference",
  "type": 29,
  "is_nullable": false,
  "ordinal_position": 2,
  "char_length": 48,
  "column_key": 3,
  "column_type_utf8": "char(12)"
}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sdi_session.py

sdi.sql

CREATE DATABASE shop;
CREATE TABLE shop.orders (
  id        BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  reference CHAR(12)        NOT NULL,
  amount    DECIMAL(10, 2)  NOT NULL,
  placed_at DATETIME        NOT NULL,
  UNIQUE KEY by_reference (reference)
);
INSERT INTO shop.orders (reference, amount, placed_at)
VALUES ('MSC-00000001', 1250.00, '2031-03-04 09:15:00');

-- The tablespace of the new table, as the server sees it.
SELECT NAME, SPACE_TYPE, PAGE_SIZE FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'shop/orders';

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=()):
        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.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."""
        self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        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):
        """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)."""
        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)

    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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        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.])", "<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 (the session uses it to wait or to decide)."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql)
        if code != 0:
            sys.exit(f"the query failed: {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


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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

Three things in that output are worth keeping. The file carried 396 lines of JSON for a four-column table, in two records: one of type 1 for the table and one of type 2 for the tablespace, which is what the manual means by metadata redundancy. The column list has six entries, not four, because DB_TRX_ID and DB_ROLL_PTR are part of the stored row. And the reference column, declared CHAR(12), reports char_length 48, four bytes per character in utf8mb4.

What SDI is not

SDI is a copy of a definition, not a backup of a table, and it is updated by DDL rather than by a server upgrade. Temporary and undo tablespaces carry none, and for a partitioned table the whole definition sits in the tablespace of the first partition.

Checkpoint

Check: The data dictionary and SDI

  1. Question 1 of 2 Where did ibd2sdi read the definition of shop.orders from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: From the table's own tablespace file, orders.ibd

    SDI is the second copy of object metadata, inside the tablespace itself, which is what makes the definition readable when the dictionary or the server is unavailable.

  2. Question 2 of 2 The SDI record listed six columns for a table declared with four. Which columns are the other two?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: DB_TRX_ID and DB_ROLL_PTR, the transaction fields InnoDB stores in every row

    A clustered index record carries the row's transaction id and its rollback pointer beside the declared columns, and the serialized definition lists them, which is why the count is six and not four.

The threads InnoDB runs in the background

A MySQL server is busy before any client connects, because most of InnoDB’s work is done by threads that nobody asked for. performance_schema.threads names them, and the recorded session groups them by name and then reads the settings that sized them:

Every InnoDB background thread, counted on this lab host

threads_session.py

"""The InnoDB background threads a server runs, counted on this lab host, and the settings that sized them.

The session starts one throwaway MySQL 8.4.11 server and reads performance_schema.threads, which names every thread
the server runs, filtered to InnoDB's own. The counts belong to this host: several InnoDB defaults are computed from
the number of logical processors, so another machine starts a different number of the same threads.
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("threads.sql")

Output

$ mysql -t -vvv --skip-comments < threads.sql
--------------
SELECT NAME, COUNT(*) AS threads
FROM performance_schema.threads
WHERE NAME LIKE 'thread/innodb/%'
GROUP BY NAME
ORDER BY NAME
--------------

+---------------------------------------------+---------+
| NAME                                        | threads |
+---------------------------------------------+---------+
| thread/innodb/buf_dump_thread               |       1 |
| thread/innodb/buf_resize_thread             |       1 |
| thread/innodb/clone_gtid_thread             |       1 |
| thread/innodb/dict_stats_thread             |       1 |
| thread/innodb/fts_optimize_thread           |       1 |
| thread/innodb/io_ibuf_thread                |       1 |
| thread/innodb/io_read_thread                |       9 |
| thread/innodb/io_write_thread               |       4 |
| thread/innodb/log_checkpointer_thread       |       1 |
| thread/innodb/log_files_governor_thread     |       1 |
| thread/innodb/log_flush_notifier_thread     |       1 |
| thread/innodb/log_flusher_thread            |       1 |
| thread/innodb/log_write_notifier_thread     |       1 |
| thread/innodb/log_writer_thread             |       1 |
| thread/innodb/page_flush_coordinator_thread |       1 |
| thread/innodb/srv_error_monitor_thread      |       1 |
| thread/innodb/srv_lock_timeout_thread       |       1 |
| thread/innodb/srv_master_thread             |       1 |
| thread/innodb/srv_monitor_thread            |       1 |
| thread/innodb/srv_purge_thread              |       1 |
| thread/innodb/srv_worker_thread             |       3 |
+---------------------------------------------+---------+
21 rows in set

--------------
SELECT @@innodb_purge_threads AS purge_threads, @@innodb_read_io_threads AS read_io,
       @@innodb_write_io_threads AS write_io, @@innodb_page_cleaners AS page_cleaners,
       @@innodb_buffer_pool_instances AS pool_instances
--------------

+---------------+---------+----------+---------------+----------------+
| purge_threads | read_io | write_io | page_cleaners | pool_instances |
+---------------+---------+----------+---------------+----------------+
|             4 |       9 |        4 |             1 |              1 |
+---------------+---------+----------+---------------+----------------+
1 row in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 threads_session.py

threads.sql

-- Every InnoDB background thread this server runs, by name, with how many of each.
SELECT NAME, COUNT(*) AS threads
FROM performance_schema.threads
WHERE NAME LIKE 'thread/innodb/%'
GROUP BY NAME
ORDER BY NAME;

-- The settings that decide how many of some of them there are. On this host the server computed the first two from
-- the number of logical processors.
SELECT @@innodb_purge_threads AS purge_threads, @@innodb_read_io_threads AS read_io,
       @@innodb_write_io_threads AS write_io, @@innodb_page_cleaners AS page_cleaners,
       @@innodb_buffer_pool_instances AS pool_instances;

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=()):
        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.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."""
        self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        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):
        """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)."""
        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)

    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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        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.])", "<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 (the session uses it to wait or to decide)."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql)
        if code != 0:
            sys.exit(f"the query failed: {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


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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

Twenty-one thread names appeared, and the ones to recognise are these. io_read_thread and io_write_thread, nine and four of them here, carry page reads and page writes. page_flush_coordinator_thread is the page cleaner: it decides which dirty pages go to disk and when. srv_purge_thread and three srv_worker_thread entries add up to the four purge threads that remove row versions no transaction needs any more. log_writer_thread, log_flusher_thread and log_checkpointer_thread move the redo log forward, and srv_master_thread runs the periodic housekeeping.

Three of those counts are not universal. The same server computes innodb_purge_threads as 1 up to 16 logical processors and 4 above that, innodb_read_io_threads as half the processors with a minimum of 4, and the number of buffer pool instances from the pool size, so the recorded 4, 9 and 1 describe this lab host and not yours. Read your own values before you tune anything; the later tuning lesson of this track does exactly that.

Key idea

Each background thread exists because some work must not happen inside your statement: writing pages, purging old row versions, flushing the log, keeping statistics. When one falls behind, the symptom appears in your queries although no query caused it.

Checkpoint

Check: InnoDB's background threads

  1. Question 1 of 2 Which recorded thread decides when dirty pages are written to the table files?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: page_flush_coordinator_thread

    Page cleaning is its own thread so that a statement never waits for a page write. The I/O threads do the writing the coordinator asks for.

  2. Question 2 of 2 The recorded server reported innodb_purge_threads 4 and innodb_read_io_threads 9. What should you conclude for your own server?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; both are computed from the host's logical processors, so read them on that server

    Several InnoDB defaults are sized from the host. Quoting them as universal numbers is how tuning advice goes wrong; read the values from the server you are working on.

When another storage engine still appears

InnoDB is the default engine and the only one in this track that matters for stored data, but a stock 8.4 server loads several others, and one of them handles work you did not ask for. The session reads the engine list and the settings behind in-memory temporary tables, then compares those settings with the recorded variable dumps of two builds:

The engines a stock 8.4.11 server offers, and where temporary tables live

engines_session.py

"""Which storage engines a stock MySQL 8.4.11 server offers, and where in-memory temporary tables live.

The session asks a throwaway server for its engine list and for the settings behind in-memory internal temporary
tables, which are not InnoDB's. It then reads two variable dumps recorded earlier by dump_defaults.py (a tab of this
example, run once per build) to show the one of those settings whose default changed in MySQL 9.7.2.
"""

from mysql_session import Server

GiB = 1024 ** 3
DUMPS = ("defaults-8.4.11.tsv", "defaults-9.7.2.tsv")
NAMES = ("internal_tmp_mem_storage_engine", "temptable_max_ram", "temptable_max_mmap", "temptable_use_mmap")


def read(path):
    with open(path, encoding="utf-8") as handle:
        pairs = (line.rstrip("\n").partition("\t") for line in handle)
        return {name: value for name, _, value in pairs}


with Server() as my:
    my.up()
    my.mysql("engines.sql")

print("the same settings in the recorded dumps of two builds:")
dumps = {path: read(path) for path in DUMPS}
print(f"{'variable':33} " + " ".join(f"{dumps[p]['version']:>20}" for p in DUMPS))
for name in NAMES:
    print(f"{name:33} " + " ".join(f"{dumps[p].get(name, '(not in this build)'):>20}" for p in DUMPS))

ram = {p: int(dumps[p]["temptable_max_ram"]) for p in DUMPS}
print()
for path in DUMPS:
    value = ram[path]
    rule = (f"exactly {value // GiB} GiB, a fixed number on any host" if value % GiB == 0 else
            f"3% of the {value / 0.03 / GiB:.0f} GiB of memory the host it was recorded on has")
    print(f"{dumps[path]['version']}: temptable_max_ram {value} bytes = {value / GiB:.2f} GiB, {rule}")

Output

$ mysql -t -vvv --skip-comments < engines.sql
--------------
SELECT ENGINE, SUPPORT, TRANSACTIONS, SAVEPOINTS FROM information_schema.ENGINES ORDER BY ENGINE
--------------

+--------------------+---------+--------------+------------+
| ENGINE             | SUPPORT | TRANSACTIONS | SAVEPOINTS |
+--------------------+---------+--------------+------------+
| ARCHIVE            | YES     | NO           | NO         |
| BLACKHOLE          | YES     | NO           | NO         |
| CSV                | YES     | NO           | NO         |
| FEDERATED          | NO      | NULL         | NULL       |
| InnoDB             | DEFAULT | YES          | YES        |
| MEMORY             | YES     | NO           | NO         |
| MRG_MYISAM         | YES     | NO           | NO         |
| MyISAM             | YES     | NO           | NO         |
| ndbcluster         | NO      | NULL         | NULL       |
| ndbinfo            | NO      | NULL         | NULL       |
| PERFORMANCE_SCHEMA | YES     | NO           | NO         |
+--------------------+---------+--------------+------------+
11 rows in set

--------------
SELECT @@internal_tmp_mem_storage_engine AS in_memory_engine, @@temptable_max_ram AS temptable_max_ram,
       @@tmp_table_size AS tmp_table_size
--------------

+------------------+-------------------+----------------+
| in_memory_engine | temptable_max_ram | tmp_table_size |
+------------------+-------------------+----------------+
| TempTable        |        1546188225 |       16777216 |
+------------------+-------------------+----------------+
1 row in set

Bye

the same settings in the recorded dumps of two builds:
variable                                        8.4.11                9.7.2
internal_tmp_mem_storage_engine              TempTable            TempTable
temptable_max_ram                           1546188225           1073741824
temptable_max_mmap                                   0                    0
temptable_use_mmap                                 OFF  (not in this build)

8.4.11: temptable_max_ram 1546188225 bytes = 1.44 GiB, 3% of the 48 GiB of memory the host it was recorded on has
9.7.2: temptable_max_ram 1073741824 bytes = 1.00 GiB, exactly 1 GiB, a fixed number on any host

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 engines_session.py

engines.sql

-- Every storage engine this build knows, and whether it is available in this server.
SELECT ENGINE, SUPPORT, TRANSACTIONS, SAVEPOINTS FROM information_schema.ENGINES ORDER BY ENGINE;

-- Where an internal temporary table starts its life, and the limits on that memory.
SELECT @@internal_tmp_mem_storage_engine AS in_memory_engine, @@temptable_max_ram AS temptable_max_ram,
       @@tmp_table_size AS tmp_table_size;

dump_defaults.py

"""Dumps the compiled defaults of one MySQL build: every global variable of a brand-new server started with
--no-defaults, one "name<TAB>value" line each, sorted by name.

    python3 dump_defaults.py                      # the pinned MySQL 8.4.11
    python3 dump_defaults.py <unpacked tarball>   # any other build, for example the official 9.7.2 or 26.7.0 tarball

The server is a throwaway one from mysql_session.py (127.0.0.1 only, X Protocol off, deleted afterwards). Values that
would differ from one computer or run to the next are replaced: the run folder is ".", MySQL's own folder <basedir>,
the run's port <port>, the server's UUID <uuid> and the host name lab-host. Values a server sizes from the machine
(CPU count, memory, the open-files limit) are kept as this computer gave them.
"""

import sys

from mysql_session import Server

QUERY = "SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_variables ORDER BY VARIABLE_NAME"

with Server(basedir=sys.argv[1] if len(sys.argv) > 1 else None) as my:
    my.up()
    uuid = my.query("SELECT @@server_uuid")
    for line in my.query(QUERY).splitlines():
        name, _, value = line.partition("\t")
        if value == str(my.port):
            value = "<port>"
        print(f"{name}\t{value.replace(uuid, '<uuid>')}")

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=()):
        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.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."""
        self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        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):
        """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)."""
        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)

    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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        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.])", "<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 (the session uses it to wait or to decide)."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql)
        if code != 0:
            sys.exit(f"the query failed: {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


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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

The engine list reports InnoDB as DEFAULT and seven others as available, none of them transactional, while FEDERATED and the two NDB entries are listed as NO, meaning this server knows the name but cannot open such a table; MySQL NDB Cluster is a different server build, outside this track. MEMORY still exists for a table you declare that way, and MyISAM is there for data from an older server, with no transactions, no row locks and no crash recovery; moving such a table to InnoDB is the usual first step of an upgrade. The engine you meet without choosing it is TempTable, which held the in-memory internal temporary tables the optimizer needs; when one outgrows tmp_table_size, 16 MiB here, it becomes an InnoDB table on disk, in one of the .ibt tablespaces from the first listing.

The dumps show one default that moved between releases. temptable_max_ram was 1,546,188,225 bytes on 8.4.11, which is 3 % of this host’s 48 GiB, and exactly 1 GiB on 9.7.2, where the value no longer depends on the machine; the temptable_use_mmap variable of 8.4 is gone from the later build. A limit that is a share of memory on one release and a fixed number on the next is worth checking during an upgrade: on this host, and on any host with more memory, the allowance shrinks after the move.

Engines are a MariaDB difference too

MariaDB ships engines MySQL does not have, among them Aria, MyRocks, ColumnStore, Spider and S3, and uses Aria for its own system tables. The MariaDB module of this track covers them; the sessions here were recorded on MySQL 8.4.11 and on the 9.7.2 build, so nothing above is a measurement of MariaDB.

Checkpoint

Check: The other engines a server loads

  1. Question 1 of 2 Which engine holds an in-memory internal temporary table on a stock 8.4 server?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: TempTable

    TempTable is the default engine for in-memory internal temporary tables, with tmp_table_size as the per-table limit and temptable_max_ram as the limit across the server.

  2. Question 2 of 2 The engine list showed MyISAM as available with TRANSACTIONS NO. When is that a reason to act?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When a table on a server you inherited still uses it, because a crash can leave it inconsistent

    The engines a server loads tell you what it can open, not what you should choose. MyISAM data is a migration task, and the engine list is how you discover it.

Practise with the recorded sessions

Every answer is in the output above: the listing, the file table, the SDI record, the thread names and the engine list. The session opens with whichever idea your answers leave weakest:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 16 What does it mean that a page in the buffer pool is dirty?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: Its contents differ from the same page in the file on disk

    Dirty means "changed in memory, not yet written". The whole design rests on the difference between the page in the buffer pool and the page in the file.

  2. Question 2 of 16 A server is killed one second after a commit, before any page was written. What makes the committed row survive?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: Recovery replays the redo record over the old page it finds in the file

    Write-ahead logging is the trade: a small sequential write at commit, and the cost of replaying it after a crash, instead of a 16 KB page write for every statement.

  3. Question 3 of 16 Why is it faster to write a small redo record at every commit than the 16 KB page the statement changed?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: Redo records from many transactions append to the same file in order, while pages sit all over the files

    One commit may need pages from many places in many files; the same commit needs one append to the redo log, which can be combined with other commits happening at the same moment.

  4. Question 4 of 16 In the recorded data directory, what are undo_001 and undo_002?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: The two undo tablespaces, holding earlier versions of changed rows

    Two undo tablespaces are created at initialisation, and later lessons add more and truncate them. The file table in the recorded output names them as undo logs.

  5. Question 5 of 16 A server is restarted. Which of the recorded files and folders is rebuilt from scratch, losing what it held?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: The #innodb_temp pool of .ibt files

    Session temporary tablespaces exist only for connected sessions, so the pool is thrown away and remade. That is also why a query's temporary data cannot be recovered after a restart.

  6. Question 6 of 16 Put these in order from smallest to largest: extent, page, segment, tablespace.

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: page, extent, segment, tablespace

    Each level is built from the one before, and the recorded numbers show the first two: 16,384 bytes a page and 1,048,576 bytes an extent.

  7. Question 7 of 16 The recorded FILES output gave ibdata1 12 extents with EXTENT_SIZE 1,048,576 bytes. How many megabytes of extents is that?

    Type a number.

    Show the answer: Question 7 of 16

    Answer: 12 MB

    12 extents of 1 MB each. Reading sizes as extents rather than bytes makes growth easy to follow, because a tablespace past its first pages grows by whole extents.

  8. Question 8 of 16 A new table receives rows steadily. Why does its .ibd file stay small at first and then grow in jumps?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: The first 32 pages of a segment are allocated one at a time, and whole extents after that

    Allocation changes unit as a structure grows: pages while it is tiny, then extents, so that sequential reads of the leaf pages stay sequential.

  9. Question 9 of 16 Where did the recorded session find orders.ibd?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: In a folder named after the schema, shop, inside the data directory

    A file-per-table tablespace is an .ibd file named after the table in the schema's folder, which is why a database's size on disk can be read with an ordinary file listing.

  10. Question 10 of 16 Reporting tables are created and dropped every night, and the operations team needs the disk space back each morning. Which tablespace suits them, and what must you avoid?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: File-per-table, and avoid the system tablespace, whose file would never shrink

    Churn is the one argument that decides this clearly. Shared tablespaces reuse their own space but never give it back, while a file-per-table tablespace disappears with its table.

  11. Question 11 of 16 Since MySQL 8.0, what replaced the per-table .frm metadata files?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: InnoDB tables in the mysql.ibd tablespace

    Transactional storage of dictionary data is what makes a half-finished CREATE TABLE impossible after a crash: the definition is committed, or it is not there.

  12. Question 12 of 16 You have an .ibd file from a server that no longer starts, and no backup of the schema. What can you get from the file alone?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: The table's definition, from the SDI inside it, with ibd2sdi

    Metadata redundancy is the stated purpose of SDI. Recovering the definition is the first step in importing such a file into a working server.

  13. Question 13 of 16 Which of these appeared among the recorded InnoDB background threads?

    Choose every answer that is right.

    Show the answer: Question 13 of 16

    Answer:

    • I/O threads for reads and for writes
    • A page flush coordinator
    • A log writer and a log flusher

    The InnoDB threads are grouped by what must not happen inside a statement: writing pages, moving the log on, purging old versions, keeping statistics.

  14. Question 14 of 16 innodb_read_io_threads defaults to half the host's logical processors, with a minimum of 4. The recorded server reported 9. How many logical processors does that host have?

    Type a number.

    Show the answer: Question 14 of 16

    Answer: 18

    Half of 18 is 9, which is also why the recorded innodb_purge_threads was 4: the default is 1 up to 16 processors and 4 above that.

  15. Question 15 of 16 An internal temporary table grows past tmp_table_size, 16 MiB on the recorded server. What happens to it?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: It becomes an InnoDB table in a session temporary tablespace on disk

    Two limits matter: tmp_table_size for one table and temptable_max_ram across the server. Past the first, the table is converted to InnoDB on disk, which is where a slow query's hidden cost often is.

  16. Question 16 of 16 The recorded engine list shows ndbcluster with SUPPORT NO. What does that mean?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: This server build cannot use it; MySQL NDB Cluster is a separate product

    The engine list answers "what can this server open", which matters when a dump from another server arrives with tables in an engine the target does not have.

On a server you look after

Run SELECT FILE_NAME, TABLESPACE_NAME, TOTAL_EXTENTS FROM information_schema.FILES ORDER BY TOTAL_EXTENTS DESC LIMIT 10; and then SELECT NAME, COUNT(*) FROM performance_schema.threads WHERE NAME LIKE 'thread/innodb/%' GROUP BY NAME;. The first names your largest tablespaces in megabytes of extents, the second tells you how many purge and I/O threads that machine chose.

Interview questions

Where does a committed row change actually live on disk, immediately after the commit returns?

In the redo log, and nowhere else that is final. The statement changed a page in the buffer pool and made it dirty, wrote the earlier version of the row into an undo log, and wrote a small record of the change into the log buffer; the commit returns once that record is in the redo log files. The table’s own .ibd file still holds the old 16 KB page, sometimes for minutes. A page cleaner thread writes dirty pages later, each one going through the doublewrite files first, so that a torn write can be repaired. If the server is killed in between, recovery reads the redo log and replays the change onto the page it finds in the file. This is why commit latency depends on the redo log and not on the size of the table, and why a quiet data directory is not evidence of a quiet server.

Two questions go further: one about a data directory that has grown and will not shrink, one about reading a table whose server will not start. Each opens with a first step and its follow-ups on this module’s question page:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A MySQL 8.4 server's data directory is 400 GB and ibdata1 alone is 320 GB, although two large tables were dropped last month and the disk is nearly full. Explain why nothing shrank, and give a plan that returns the space.

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

  2. Mid to staff

    A developer hands you one orders.ibd file from a server that no longer starts. There is no dump and nobody kept the schema. They want the table's definition, and the rows if that is possible at all. What can you get from the file, and how?

    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. When an InnoDB transaction commits, what makes the change durable?

    Its record in the redo log. The changed pages stay in the buffer pool and a background thread writes them to the table's file later, so the data files say nothing about how busy the server is.

  2. Which InnoDB files does a fresh MySQL 8.4.11 data directory already hold?

    The system tablespace ibdata1, the data dictionary in mysql.ibd, two undo tablespaces, a pair of doublewrite files, the redo log folder and ten session temporary tablespaces.

  3. ibdata1 looks unused on a server without tables. Why must it never be deleted?

    It holds the change buffer and state that recovery needs, as the undo files and the redo folder do; remove any of them and the server refuses to start. Shrink a data directory by dropping tables and rebuilding the instance.

  4. How does InnoDB divide the space of a tablespace?

    Into 16 KB pages, extents of 64 pages (1 MiB, the recorded EXTENT_SIZE), and segments built from extents, two for each index. That is why tablespace files grow in megabyte steps.

  5. Where does a table's data live by default, and when does a general tablespace make sense?

    In a file-per-table tablespace of its own. A general tablespace suits thousands of tables that would otherwise need too many file handles; the system tablespace holds table data only on a server configured to put it there.

  6. Where does MySQL 8.4 keep table definitions?

    In the data dictionary, made of InnoDB tables in mysql.ibd. Each tablespace also carries its own definitions as serialized dictionary information, which ibd2sdi prints as JSON, hidden columns included.

  7. Which InnoDB background threads run before any client connects, and why do they matter?

    I/O threads, the page cleaner, purge threads and the log writer, flusher and checkpointer, several of them sized from the host's processors. They keep that work out of your statements, so one that falls behind slows queries that did not cause it.

Key takeaways

  • InnoDB changes pages in the buffer pool and makes a commit durable by writing the change to the redo log, so a table’s file is written later by a background thread and tells you nothing about current activity.
  • A fresh 8.4.11 data directory already holds the system tablespace ibdata1, the data dictionary in mysql.ibd, two undo tablespaces, a doublewrite pair, the redo folder and ten session temporary tablespaces.
  • Space is organised as 16 KB pages, extents of 64 such pages, and segments of extents, two per index, which is why files grow in megabyte steps and why the recorded EXTENT_SIZE was 1,048,576 bytes.
  • A table’s data lives in a file-per-table tablespace by default, in a general tablespace when thousands of tables would cost too many file handles, and in the system tablespace only on a server configured that way.
  • The data dictionary is InnoDB tables in mysql.ibd, and every tablespace also carries its own definitions as serialized dictionary information, which ibd2sdi prints as JSON with the hidden row columns included.
  • An 8.4 server starts I/O threads, a page cleaner, purge threads and the log writer, flusher and checkpointer before any client connects, and sizes several of those counts from the host’s processors, so the recorded 9 read I/O threads and 4 purge threads describe this lab host; besides InnoDB it still loads MEMORY and MyISAM for tables declared that way, and uses TempTable for in-memory internal temporary tables, whose memory limit changed from a share of RAM on 8.4.11 to a fixed 1 GiB on 9.7.2.

Terms in this lesson

data dictionary
The server's own metadata: the definitions of every schema, table, column and index. Since MySQL 8.0 they are InnoDB tables inside the mysql.ibd tablespace, so DDL commits and rolls back like any transaction.
extent
A run of consecutive InnoDB pages allocated as one unit: 64 pages of 16 KB, so 1 MB. Once a structure is past its first 32 pages, InnoDB allocates whole extents, which is why files grow in megabyte steps.
file-per-table tablespace
The default home of a new InnoDB table: one .ibd file named after the table, in the folder of its schema. Truncating or dropping the table returns its space to the operating system.
InnoDB page
InnoDB's unit of storage and of every read and write: a block of 16 KB by default, holding rows or index entries. Every tablespace of one server uses the same page size, which is fixed when the data directory is initialised.
segment
A set of extents inside a tablespace used for one purpose. Each index gets two: one for the leaf pages that hold the data and one for the branch pages above them, so leaf pages stay contiguous on disk.
serialized dictionary information (SDI)
A second copy of an object's definition, which InnoDB writes as compact JSON into the tablespace file that holds the object. The ibd2sdi tool prints it, so a definition can be read without the data dictionary.
system tablespace
InnoDB's shared tablespace, whose data file is ibdata1 by default. It is the storage area for the change buffer, and also holds tables when innodb_file_per_table is switched off; its file never shrinks.
tablespace
One or more files holding InnoDB pages. A table's data lives in a file-per-table, general or system tablespace; undo and temporary tablespaces belong to the server itself and hold no user tables.

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.