From d8339dcbff57b8630d6beb4084bfe486242e9b84 Mon Sep 17 00:00:00 2001 From: MaXiMo000 Date: Sun, 27 Sep 2026 06:56:26 +0530 Subject: [PATCH] MySQL and MariaDB dumps `firedrill run` recognises a mysqldump / mariadb-dump file (plain or .gz) by its header, restores it into mysql: / mariadb: from that header, and runs smoke, semantics and CHECK TABLE. A missing `-- Dump completed` trailer is ARCHIVE_TRUNCATED before anything is restored; the client's `ERROR nnnn at line N` becomes a finding. MariaDB 10.5.25+/11.x dumps open with a sandbox-mode line before the header; it is read past. The password reaches the client via MYSQL_PWD inside the container -- exported, since a prefix on a pipeline applies only to its first command and `cat dump | mysql` logged in without one. Tested on datacharmer/test_db's employees database (300,024 employees, 2,844,047 salaries) dumped by MySQL 8.4 and MariaDB 11.4: both pass, gzipped passes, a copy cut at 80 MB fails with the trailer, the client's error line, 7 of 8 tables and both counts wrong. Co-Authored-By: Claude Opus 5.5 --- README.md | 32 ++++++ firedrill/drill.py | 133 +++++++++++++++++++++++- firedrill/mysql.py | 222 ++++++++++++++++++++++++++++++++++++++++ firedrill/report.py | 6 ++ tests/test_firedrill.py | 67 +++++++++++- 5 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 firedrill/mysql.py diff --git a/README.md b/README.md index ec5b049..e03ba91 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,38 @@ supplies. A plain dump can only be restored whole, so the `fast` and `sample` tiers refuse it (`TIER_UNSUPPORTED`) instead of quietly running a full restore. +## MySQL and MariaDB + +`firedrill run backup.sql` recognises a `mysqldump` or `mariadb-dump` file +(plain or `.gz`) by its own header and takes the same path Postgres does: +the server version comes from the dump, the restore runs in `mysql:` +or `mariadb:`, and then it runs smoke, your `semantics:` checks, and +`CHECK TABLE` on every restored table. + +- **Truncation.** Both tools end a finished dump with `-- Dump completed on + ...`. The `mysql` client restores a dump cut at a statement boundary with + exit 0, so a missing trailer is `ARCHIVE_TRUNCATED` (critical) before + anything is restored. +- **Errors.** The client stops at the first failing statement; its + `ERROR nnnn (state) at line N` becomes a finding naming the line, since + everything after it was never restored. +- **MariaDB 10.5.25+/11.x** dumps open with a `/*M!999999\- enable the + sandbox mode */` line before the header -- the reason they won't load into + MySQL. firedrill reads past it and restores them into MariaDB. +- The password reaches the client through `MYSQL_PWD` inside the container, + never argv. + +Tested on [datacharmer/test_db](https://github.com/datacharmer/test_db)'s +employees database (300,024 employees, 2,844,047 salary rows), dumped by +each engine's own tool: + +| dump | result | +|---|---| +| MySQL 8.4, 168 MB | PASS: 8 tables, both exact counts, `CHECK TABLE` clean (restore 63 s) | +| MariaDB 11.4, 172 MB, with the sandbox-mode line | PASS (restore 25 s) | +| the MySQL dump gzipped, 36 MB | PASS | +| the MySQL dump cut off at 80 MB | FAIL: `ARCHIVE_TRUNCATED`, the client's `ERROR 1064 at line 265`, 7 of 8 tables, both counts wrong | + ## Which PostgreSQL versions Tested end to end against **13, 14, 15, 16, 17 and 18** — dump on any of them, diff --git a/firedrill/drill.py b/firedrill/drill.py index 6875411..81e49df 100644 --- a/firedrill/drill.py +++ b/firedrill/drill.py @@ -14,7 +14,7 @@ import tempfile import time -from . import archive, docker, history as history_module, ladder, physical, pitr, \ +from . import archive, docker, history as history_module, ladder, mysql, physical, pitr, \ restore as restore_stage, sources from . import config as _config_module from .config import DEFAULT as DEFAULT_CONFIG @@ -468,6 +468,131 @@ def stop(rule, message, fix, evidence="", status=FAILED): return _suppress(report, cfg) +def _run_mysql(report: Report, found: dict, dump_path, *, cfg, flavour: str, + pin_major, ready_timeout: int, began: float) -> Report: + """A mysqldump / mariadb-dump: version from its header, restored into that + same engine and version, then smoke, semantics and CHECK TABLE.""" + def stage(name): + return next(s for s in report.stages if s.name == name) + + engine, version = found["engine"], pin_major or found["version"] + label = "MariaDB" if engine == "mariadb" else "MySQL" + report.archive.update({"format": f"{engine} dump", "server_version": found["version"], + "engine": engine, "gzipped": found["gzipped"]}) + for name in ("structure", "volume"): + stage(name).status = NOT_CONFIGURED + stage(name).detail = "PostgreSQL only" + + stage("inspect").status = OK + stage("inspect").detail = f"{label} {found['version'] or '?'}" + if not version: + stage("inspect").status = FAILED + report.findings.append(Finding( + stage="inspect", rule="VERSION_UNKNOWN", severity="critical", + message=f"the {label} dump names no server version", + fix="Pass --postgres with the server version (e.g. 8.4) -- the flag " + "pins whichever engine the dump is from. firedrill will not guess.", + evidence="")) + report.total_seconds = time.monotonic() - began + return report + if not found["complete"]: + # The trailer is written only when the dump finished. The mysql client + # restores a dump cut at a statement boundary with exit 0. + stage("inspect").status = FAILED + report.findings.append(Finding( + stage="inspect", rule="ARCHIVE_TRUNCATED", severity="critical", + message=f"the dump has no `-- Dump completed` trailer: {label}'s dump " + "tool did not finish writing it", + fix="The backup job ran out of disk, was killed, or its output was " + "cut off. Check the writer's exit status; the file is incomplete.", + evidence="")) + + usable, why = docker.docker_available() + if not usable: + stage("target").status = NOT_RUN + report.findings.append(Finding( + stage="target", rule="TARGET_UNAVAILABLE", severity="critical", + message=f"the restore could NOT be verified: {why}", + fix="Start Docker and run again.", evidence="")) + report.total_seconds = time.monotonic() - began + return report + + container = mysql.MySQLContainer(engine, version, dump_path, flavour=flavour, + ready_timeout=ready_timeout) + started = time.monotonic() + try: + try: + container.start() + container.wait_ready() + except docker.TargetError as exc: + stage("target").status = FAILED + stage("target").seconds = time.monotonic() - started + report.findings.append(Finding( + stage="target", rule="TARGET_UNAVAILABLE", severity="critical", + message=f"the restore could NOT be verified: {str(exc).splitlines()[0]}", + fix="The target never came up, so nothing about this dump was proved.", + evidence="")) + report.total_seconds = time.monotonic() - began + return report + stage("target").status = OK + stage("target").seconds = time.monotonic() - started + stage("target").detail = container.image + report.archive["restored_into_major"] = version + + started = time.monotonic() + code, found_errors, _ = mysql.restore(container, found["gzipped"]) + report.verified = True + report.findings.extend(found_errors) + stage("restore").seconds = time.monotonic() - started + stage("restore").status = FAILED if found_errors else OK + stage("restore").detail = f"exit {code}" + + started = time.monotonic() + restored = mysql.schemas(container) + stage("smoke").seconds = time.monotonic() - started + stage("smoke").detail = ", ".join(f"{n}: {c} table(s)" for n, c in restored.items()) or "nothing" + if not restored: + stage("smoke").status = FAILED + report.findings.append(Finding( + stage="smoke", rule="SMOKE_EMPTY", severity="high", + message="the restore left no user tables in any database", + fix="The dump restored nothing you would miss.", evidence="")) + else: + stage("smoke").status = OK + + if cfg.semantics: + db = next(iter(restored), None) if len(restored) == 1 else None + if db is None: + stage("semantics").status = NOT_RUN + report.findings.append(Finding( + stage="semantics", rule="DATABASE_AMBIGUOUS", severity="high", + message="semantics checks need one database; the dump restored " + f"{', '.join(restored) or 'none'}", + fix="Qualify table names in the checks (db.table).", evidence="")) + else: + started = time.monotonic() + sem, _ = ladder.semantics(container, cfg, db) + report.findings.extend(sem) + stage("semantics").seconds = time.monotonic() - started + stage("semantics").status = FAILED if sem else OK + stage("semantics").detail = f"{len(cfg.semantics)} check(s) in {db}" + else: + stage("semantics").status = NOT_CONFIGURED + stage("semantics").detail = "no semantics checks in the config" + + started = time.monotonic() + checked = mysql.check_tables(container, list(restored)) + report.findings.extend(checked) + stage("integrity").seconds = time.monotonic() - started + stage("integrity").status = FAILED if checked else OK + stage("integrity").detail = f"CHECK TABLE on {sum(restored.values())} table(s)" + finally: + container.teardown() + + report.total_seconds = time.monotonic() - began + return report + + def _sequence_detail(info: dict) -> str: """`9 of 13 sequence(s)` when some could not be linked to a column. @@ -615,6 +740,12 @@ def stage(name: str) -> Stage: evidence=f"{artifact.origin}", )) + # A MySQL or MariaDB dump takes its own path from here. + found = mysql.detect(dump_path) + if found is not None: + return _run_mysql(report, found, dump_path, cfg=cfg, flavour=flavour, + pin_major=pin_major, ready_timeout=ready_timeout, began=began) + # -- inspect ----------------------------------------------------------- started = time.monotonic() try: diff --git a/firedrill/mysql.py b/firedrill/mysql.py new file mode 100644 index 0000000..78d7d87 --- /dev/null +++ b/firedrill/mysql.py @@ -0,0 +1,222 @@ +"""MySQL and MariaDB dumps: the same promise, a second engine. + +`mysqldump` output has the two properties firedrill's plain-SQL Postgres +support rests on: a header naming the server version, and a trailer +(`-- Dump completed on ...`) written only when the dump finished. So the +version is read from the file, the restore runs in a container of that +same version, and a dump without its trailer is truncated before anything +is restored. + +MariaDB's `mariadb-dump` writes the same shape and says so in its header; +its 11.x images ship `mariadb*` clients only, so the client name follows the +engine. +""" +from __future__ import annotations + +import gzip +import pathlib +import re + +from . import docker +from .finding import Finding + +_HEAD = re.compile(r"^-- (MySQL|MariaDB) dump ") +_SERVER = re.compile(r"^-- Server version\s+(\d+)\.(\d+)\.\d+(-MariaDB)?", re.M) +_TRAILER = "-- Dump completed" +# ERROR 1064 (42000) at line 12: You have an error in your SQL syntax ... +_CLIENT_ERROR = re.compile(r"^ERROR (\d+) \(([0-9A-Z]{5})\)(?: at line (\d+))?: (.*)$", re.M) +_SYSTEM_SCHEMAS = ("mysql", "sys", "information_schema", "performance_schema") + + +def _open(path: pathlib.Path): + with open(path, "rb") as handle: + magic = handle.read(2) + return gzip.open(path, "rb") if magic == b"\x1f\x8b" else open(path, "rb") + + +def detect(path) -> dict | None: + """{engine, version, gzipped, complete} for a mysqldump/mariadb-dump + file, or None when it is not one.""" + path = pathlib.Path(path) + if not path.is_file(): + return None + try: + with _open(path) as handle: + head = handle.read(4096).decode("utf-8", errors="replace") + except (OSError, EOFError): + return None + # MariaDB 10.5.25+/11.x dumps open with `/*M!999999\- enable the sandbox + # mode */` before the header -- the line that makes them unloadable into + # MySQL. Skipped here, so every modern MariaDB dump is still recognised. + lines = head.splitlines() + if lines and lines[0].startswith("/*M!999999"): + head = "\n".join(lines[1:]) + if not _HEAD.match(head): + return None + server = _SERVER.search(head) + engine = "mariadb" if (server and server.group(3)) or head.startswith("-- MariaDB") else "mysql" + version = f"{server.group(1)}.{server.group(2)}" if server else None + gzipped = _is_gz(path) + return {"engine": engine, "version": version, "gzipped": gzipped, + "complete": _TRAILER in _tail(path, gzipped)} + + +def _is_gz(path) -> bool: + with open(path, "rb") as handle: + return handle.read(2) == b"\x1f\x8b" + + +def _tail(path: pathlib.Path, gzipped: bool, size: int = 512) -> str: + """The last bytes of the (decompressed) dump. A .gz is streamed through, + and a .gz that is itself cut short reads as incomplete.""" + if not gzipped: + with open(path, "rb") as handle: + handle.seek(max(0, path.stat().st_size - size)) + return handle.read().decode("utf-8", errors="replace") + last = b"" + try: + with gzip.open(path, "rb") as handle: + while chunk := handle.read(1 << 20): + last = (last + chunk)[-size:] + except (OSError, EOFError): + return "" + return last.decode("utf-8", errors="replace") + + +def image_for(engine: str, version: str, flavour: str = "") -> str: + if "{major}" in flavour: + return flavour.format(major=version) + return f"{engine}:{version}{flavour}" + + +def _client(engine: str, version: str, tool: str) -> str: + """mysql, mysqladmin, mysqlcheck -- or mariadb, mariadb-admin, mariadb-check, + which is all MariaDB 11 images still ship.""" + if engine == "mariadb": + return {"mysql": "mariadb", "mysqladmin": "mariadb-admin", "mysqlcheck": "mariadb-check"}[tool] + return tool + + +class MySQLContainer(docker.Container): + """A disposable MySQL/MariaDB of the dump's own version.""" + + def __init__(self, engine: str, version: str, dump, flavour: str = "", + ready_timeout: int = docker.DEFAULT_READY_TIMEOUT): + super().__init__(version, dump=dump, ready_timeout=ready_timeout) + self.engine = engine + self.image = image_for(engine, version, flavour) + + def _cmd(self, tool: str) -> str: + return _client(self.engine, self.major, tool) + + def _run_argv(self) -> list[str]: + # By name, never value -- same rule as the Postgres target. + return ["docker", "run", "-d", "--name", self.name, "--label", f"{docker.LABEL}=1", + "-e", "MYSQL_ROOT_PASSWORD", "-e", "MARIADB_ROOT_PASSWORD", + "-v", f"{self.dump}:{docker.DUMP_PATH}:ro", self.image] + + def start(self) -> None: + env = {"PATH": docker._path(), "HOME": docker._home(), + "MYSQL_ROOT_PASSWORD": self.password, "MARIADB_ROOT_PASSWORD": self.password} + result = docker._run(self._run_argv(), env=env) + if result.returncode != 0: + raise docker.TargetError(f"could not start {self.image}: {(result.stderr or '').strip()}") + self.started = True + + def shell(self, script: str, timeout: int = 3600): + """A command with the client's password in MYSQL_PWD, read inside the + container from its own environment -- never through argv.""" + # Exported, not prefixed: a prefix applies only to the first command + # of a pipeline, so `cat dump | mysql` logged in with no password. + return self.exec(["sh", "-c", f'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; {script}'], + timeout=timeout, user="root") + + def wait_ready(self) -> None: + # Over TCP: like the Postgres image, the entrypoint runs a temporary + # server with networking off while it initialises. + import time + deadline = time.monotonic() + self.ready_timeout + while time.monotonic() < deadline: + probe = self.shell(f"{self._cmd('mysqladmin')} -h127.0.0.1 -uroot ping", timeout=30) + if probe.returncode == 0: + return + if not self.running(): + raise docker.TargetError(f"{self.image} exited before accepting connections.\n{self.logs()}") + time.sleep(1) + raise docker.TargetError(f"{self.image} did not accept connections within " + f"{self.ready_timeout}s\n{self.logs()}") + + def sql(self, statement: str, database: str = "mysql", timeout: int = 300): + quoted = statement.replace("'", "'\"'\"'") + return self.shell(f"{self._cmd('mysql')} -h127.0.0.1 -uroot -N -B -D {database} -e '{quoted}'", + timeout=timeout) + + +def restore(container: MySQLContainer, gzipped: bool) -> tuple[int, list[Finding], str]: + reader = "gunzip -c" if gzipped else "cat" + result = container.shell( + f"{reader} {docker.DUMP_PATH} | {container._cmd('mysql')} -h127.0.0.1 -uroot") + stderr = "\n".join(l for l in (result.stderr or "").splitlines() + if "Using a password on the command line" not in l) + findings = [] + for code, state, line, message in _CLIENT_ERROR.findall(stderr): + findings.append(Finding( + stage="restore", rule="RESTORE_ERROR", severity="high", + message=f"ERROR {code} ({state}){f' at line {line}' if line else ''}: {message.strip()}", + fix="The mysql client stops at the first failing statement, so " + "everything after this line of the dump was never restored.", + evidence="")) + if result.returncode != 0 and not findings: + findings.append(Finding( + stage="restore", rule="RESTORE_FAILED", severity="critical", + message=f"the restore exited {result.returncode}", + fix="An unexplained non-zero exit is not evidence of success.", + evidence=stderr.strip()[-500:] or "(no stderr)")) + return result.returncode, findings, stderr + + +def schemas(container: MySQLContainer) -> dict[str, int]: + listed = container.sql( + "select table_schema, count(*) from information_schema.tables where table_schema " + f"not in {tuple(_SYSTEM_SCHEMAS)} group by table_schema order by 1") + out = {} + for line in (listed.stdout or "").splitlines(): + name, _, count = line.partition("\t") + if count.strip().isdigit(): + out[name] = int(count) + return out + + +def check_tables(container: MySQLContainer, databases: list[str]) -> list[Finding]: + """CHECK TABLE on every restored table: the engine's own verdict on each. + Plain SQL rather than mysqlcheck, which the mysql:8.4 image doesn't ship.""" + if not databases: + return [] + listed = container.sql( + "select concat('`', table_schema, '`.`', table_name, '`') from information_schema.tables " + f"where table_type = 'BASE TABLE' and table_schema in {tuple(databases) + ('',)}") + tables = [t for t in (listed.stdout or "").split() if t] + if listed.returncode != 0: + return [Finding(stage="integrity", rule="TABLE_CHECK_UNRUNNABLE", severity="high", + message="could not list the restored tables", + fix="Nothing about table integrity has been proved.", + evidence=(listed.stderr or "").strip()[-300:])] + findings = [] + for i in range(0, len(tables), 50): + ran = container.sql("check table " + ", ".join(tables[i:i + 50])) + if ran.returncode != 0: + findings.append(Finding(stage="integrity", rule="TABLE_CHECK_UNRUNNABLE", severity="high", + message="CHECK TABLE could not run", + fix="Nothing about table integrity has been proved.", + evidence=(ran.stderr or "").strip()[-300:])) + continue + # Table Op Msg_type Msg_text -- anything but "status OK" is the + # engine saying the table is not sound. + for row in (ran.stdout or "").splitlines(): + cols = row.split("\t") + if len(cols) == 4 and not (cols[2] == "status" and cols[3] == "OK") and cols[2] != "note": + findings.append(Finding(stage="integrity", rule="TABLE_CHECK_FAILED", severity="high", + message=f"CHECK TABLE {cols[0]}: {cols[2]} {cols[3]}", + fix="The engine itself reports this table as damaged.", + evidence="")) + return findings diff --git a/firedrill/report.py b/firedrill/report.py index 4bdbbc5..45e9ce4 100644 --- a/firedrill/report.py +++ b/firedrill/report.py @@ -41,6 +41,12 @@ def human(report: Report, colour: bool = False) -> str: else "WAL replayed to the end of the archive") lines.append(f" source PostgreSQL {a.get('server_major')} -> {towards} " f"on postgres:{a.get('restored_into_major')}") + elif a.get("engine"): + name = "MariaDB" if a["engine"] == "mariadb" else "MySQL" + packed = ", gzipped" if a.get("gzipped") else "" + lines.append(f" archive {name} dump{packed}") + lines.append(f" source {name} {a.get('server_version')} -> restored into " + f"{a['engine']}:{a.get('restored_into_major')}") elif a.get("recovery_target_time"): # A PITR run has no archive to describe. Printing the dump fields here # rendered "archive None vNone 0B from None", which is worse than diff --git a/tests/test_firedrill.py b/tests/test_firedrill.py index 8d131d3..aeb9a93 100644 --- a/tests/test_firedrill.py +++ b/tests/test_firedrill.py @@ -368,6 +368,71 @@ def test_physical_restores_real_pgbackrest_and_walg_repositories(): check(f"{tool}: and that is a FAIL, not could-not-verify", early.verified, True) +# ------------------------------------------------------------------- mysql -- + +MYSQL_DUMP = """-- MySQL dump 10.13 Distrib 8.4.11, for Linux (x86_64) +-- +-- Host: localhost Database: shop +-- ------------------------------------------------------ +-- Server version\t8.4.11 + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `shop`; +USE `shop`; +CREATE TABLE `orders` (`id` int NOT NULL AUTO_INCREMENT, `amount` int, PRIMARY KEY (`id`)) ENGINE=InnoDB; +INSERT INTO `orders` VALUES (1,10),(2,20),(3,30); +CREATE TABLE `customers` (`id` int NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB; +INSERT INTO `customers` VALUES (1),(2); + +-- Dump completed on 2026-09-26 21:12:51 +""" + + +def test_mysql_and_mariadb_dumps_are_recognised(): + import gzip + import tempfile + from firedrill import mysql + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + (tmp / "m.sql").write_text(MYSQL_DUMP) + check("mysql", mysql.detect(tmp / "m.sql"), + {"engine": "mysql", "version": "8.4", "gzipped": False, "complete": True}) + # MariaDB 11.x opens with a sandbox-mode line before its header. + maria = ("/*M!999999\\- enable the sandbox mode */ \n-- MariaDB dump 10.19-11.4.13-MariaDB, " + "for debian-linux-gnu (x86_64)\n--\n-- Server version\t11.4.13-MariaDB-ubu2404\n" + "-- Dump completed on 2026-09-26 21:33:06\n") + (tmp / "mdb.sql").write_text(maria) + check("mariadb behind the sandbox line", (mysql.detect(tmp / "mdb.sql") or {}).get("engine"), "mariadb") + check("its version", mysql.detect(tmp / "mdb.sql")["version"], "11.4") + (tmp / "m.sql.gz").write_bytes(gzip.compress(MYSQL_DUMP.encode())) + check("gzipped", mysql.detect(tmp / "m.sql.gz")["gzipped"], True) + (tmp / "cut.sql").write_text(MYSQL_DUMP[:len(MYSQL_DUMP) // 2]) + check("no trailer is incomplete", mysql.detect(tmp / "cut.sql")["complete"], False) + (tmp / "pg.sql").write_text("--\n-- PostgreSQL database dump\n--\n") + check("a Postgres dump is not MySQL", mysql.detect(tmp / "pg.sql"), None) + + +def test_integration_mysql_dump_restores_and_truncation_is_caught(): + """Against a real mysql:8.4. The truncated copy ends on a statement + boundary, which the mysql client restores with exit 0 -- only the + missing trailer says it is incomplete.""" + import tempfile + needs_docker() + with tempfile.TemporaryDirectory() as tmp: + good = pathlib.Path(tmp) / "shop.sql" + good.write_text(MYSQL_DUMP) + cfg = config.loads("version: 1\nsemantics:\n - name: orders\n" + " sql: select count(*) from orders\n expect: \"== 3\"\n") + report = drill.run(good, cfg=cfg) + check("mysql dump passes", (report.ok, [f.rule for f in report.findings]), (True, [])) + check("both tables back", "shop: 2 table(s)" in next( + s for s in report.stages if s.name == "smoke").detail, True) + cut = pathlib.Path(tmp) / "cut.sql" + cut.write_text(MYSQL_DUMP.split("CREATE TABLE `customers`")[0]) + report = drill.run(cut) + check("truncation caught", [f.rule for f in report.findings], ["ARCHIVE_TRUNCATED"]) + check("and it is a FAIL", (report.verified, report.ok), (True, False)) + + def test_notify_posts_the_verdict_to_a_real_webhook(): """A real HTTP receiver on localhost. Failing and could-not-verify runs notify; a pass does not unless asked; the URL never reaches an error.""" @@ -2788,7 +2853,7 @@ def main() -> int: # A floor, not a target. Edits that replace a range of lines have silently # swallowed whole blocks of tests before; the suite then goes green with # fewer tests and says nothing. - FLOOR = 155 + FLOOR = 157 if len(tests) < FLOOR: raise SystemExit( f"test suite shrank: {len(tests)} < {FLOOR}. An edit probably deleted "