Python Script for MySQL Checksum Validation

This page implements one concrete task inside the broader checksum validation pipeline: a headless Python validator that reconciles live CHECKSUM TABLE output from a MySQL replica against a cryptographically signed backup manifest and exits with a strict POSIX status code the disaster recovery orchestrator can branch on. Operational recovery drills need deterministic, repeatable proof of logical integrity before a failover promotes a replica — and ad-hoc mysqlcheck or interactive CHECKSUM TABLE runs neither scale across multi-terabyte InnoDB deployments nor emit machine-parseable results. The validator here decouples verification from blocking DDL, bounds parallelism to protect replica health, classifies each table into a status tier that feeds downstream error categorization frameworks, and only ever reports “valid” against a digest captured at backup time. That last property is what makes it a legitimate gate: a “valid” backup is one that both matches its manifest and can restore inside the windows defined by your RTO and RPO mapping.

Architecture and Execution Model

MySQL checksum validator decision flow to POSIX exit codes A top-down decision flow. The validator starts and first checks that two JSON arguments (db config and manifest) are present; if not, it returns exit code 2 to abort the pipeline. Otherwise it loads the db config and manifest, opens a bounded ThreadPoolExecutor over the manifest tables, and runs one CHECKSUM TABLE per table. It then tests whether every live checksum equals the manifest value. Any MISMATCH, MISSING, or ERROR returns exit code 1 to halt failover and quarantine the backup. When all tables are VALID it returns exit code 0 to proceed with promotion. no yes MISMATCH / MISSING / ERROR all VALID Start validator Two JSON args present? exit 2 abort pipeline Load db config & manifest ThreadPoolExecutor over manifest tables CHECKSUM TABLE per table Live equals expected? exit 1 halt failover · quarantine exit 0 proceed with promotion

Figure. Decision flow of the MySQL checksum validator mapping argument parsing and per table CHECKSUM TABLE comparison to POSIX exit codes 0, 1, and 2.

The engine prioritizes non-blocking execution, explicit timeout enforcement, and deterministic error propagation. It uses connection pooling to prevent connection storms during concurrent validation, iterates the manifest to bound the working set, and sets session-level lock timeouts so a stalled table can never inflate replication lag. Validation logic is isolated from the orchestration layer: the script returns strict exit codes and structured, line-oriented logs, and makes no decisions about alerting or promotion itself.

Prerequisites

  • Python 3.8+ (the type hints and concurrent.futures.as_completed usage are stable from 3.8 onward).

  • The MySQL connector driver, installed into the automation environment:

    bash
    pip install "mysql-connector-python>=8.0"
    
  • A read-only replica reachable from the automation host. Run validation against a standby, never the primary, and confirm it is lagging within the tolerance defined for the drill.

  • A dedicated validator account with the SELECT privilege on every schema in the manifest — CHECKSUM TABLE requires SELECT, and read-only scope keeps the credential safe to inject into pipelines:

    sql
    CREATE USER 'dr_validator'@'10.%' IDENTIFIED BY '<from-vault>';
    GRANT SELECT ON `app_production`.* TO 'dr_validator'@'10.%';
    
  • A pre-generated, signed manifest produced during the backup phase (see the walkthrough below). The validator never computes the expected value at run time — doing so would validate a corrupted export against its own corruption.

Production Implementation

The validator uses mysql-connector-python with explicit pooling, context-managed sessions, and a bounded ThreadPoolExecutor. It reads a database configuration file and a manifest, runs one CHECKSUM TABLE per manifest entry against the target replica, and exits with a deterministic status for CI/CD or DR automation hooks. Any table that mismatches, errors, or is missing from the replica fails the run.

python
#!/usr/bin/env python3
"""MySQL backup checksum validator.

Exit codes (consumed by the DR drill orchestrator):
    0  every table checksum matches the signed manifest -> proceed with promotion
    1  at least one MISMATCH, MISSING, or ERROR          -> halt failover, quarantine
    2  usage / configuration error                       -> abort pipeline
"""
import json
import logging
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager
from typing import Dict, List, Tuple

import mysql.connector
from mysql.connector import pooling

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("mysql_checksum_validator")

FAILURE_STATES = {"MISMATCH", "MISSING", "ERROR"}


class MySQLChecksumValidator:
    def __init__(self, db_config: dict, manifest: Dict[str, str],
                 max_workers: int = 8, lock_wait_timeout: int = 5):
        self.db_config = db_config
        self.manifest = manifest
        self.max_workers = max_workers
        self.lock_wait_timeout = lock_wait_timeout
        self.pool = pooling.MySQLConnectionPool(
            pool_name="checksum_pool",
            pool_size=self.max_workers,
            **db_config,
        )

    @contextmanager
    def _get_connection(self):
        conn = self.pool.get_connection()
        try:
            cursor = conn.cursor(dictionary=True)
            # Bound lock waits so a busy table cannot stall a worker or spike lag.
            cursor.execute(f"SET SESSION lock_wait_timeout = {self.lock_wait_timeout}")
            cursor.execute(f"SET SESSION innodb_lock_wait_timeout = {self.lock_wait_timeout}")
            yield cursor
        finally:
            conn.close()

    def _validate_single_table(self, schema: str, table: str) -> dict:
        expected = self.manifest.get(f"{schema}.{table}")
        with self._get_connection() as cursor:
            try:
                cursor.execute(f"CHECKSUM TABLE `{schema}`.`{table}`")
                result = cursor.fetchone()
                # A nonexistent table returns one row with a NULL Checksum, not no rows.
                if result is None or result["Checksum"] is None:
                    return {"schema": schema, "table": table,
                            "status": "MISSING", "checksum": None}

                live = str(result["Checksum"])
                status = "VALID" if live == expected else "MISMATCH"
                return {"schema": schema, "table": table,
                        "status": status, "checksum": live}
            except mysql.connector.Error as exc:
                logger.error("Validation failed for %s.%s: %s", schema, table, exc)
                return {"schema": schema, "table": table,
                        "status": "ERROR", "checksum": None, "error": str(exc)}

    def run_validation(self) -> Tuple[bool, List[dict]]:
        results: List[dict] = []
        # Split on the last dot so schema names containing dots stay intact.
        table_list = [tuple(key.rsplit(".", 1)) for key in self.manifest]

        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            future_to_table = {
                executor.submit(self._validate_single_table, schema, table): f"{schema}.{table}"
                for schema, table in table_list
            }
            for future in as_completed(future_to_table):
                results.append(future.result())

        failures = [r for r in results if r["status"] in FAILURE_STATES]
        return len(failures) == 0, results


def load_json(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as handle:
        return json.load(handle)


def main() -> int:
    if len(sys.argv) != 3:
        logger.error("Usage: mysql_checksum_validator.py <db_config.json> <manifest.json>")
        return 2

    try:
        db_config = load_json(sys.argv[1])
        manifest = load_json(sys.argv[2])
    except (OSError, json.JSONDecodeError) as exc:
        logger.error("Configuration error: %s", exc)
        return 2

    max_workers = int(db_config.get("max_workers", 8))
    validator = MySQLChecksumValidator(db_config["db"], manifest, max_workers)
    success, results = validator.run_validation()

    for record in results:
        level = logging.INFO if record["status"] == "VALID" else logging.WARNING
        logger.log(level, "%s.%s | Status: %s | Checksum: %s",
                   record["schema"], record["table"], record["status"], record["checksum"])

    if not success:
        logger.critical("Validation failed. Aborting DR promotion.")
        return 1

    logger.info("All checksums validated successfully. Proceeding with failover.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

The configuration file supplies pooling parameters under a db key that matches the MySQLConnectionPool signature, plus an optional worker ceiling:

json
{
  "db": {
    "host": "dr-replica-01.internal",
    "port": 3306,
    "user": "dr_validator",
    "password": "${VAULT_DR_PASS}",
    "database": "information_schema"
  },
  "max_workers": 12
}

The manifest maps fully qualified table names to the Checksum column returned by CHECKSUM TABLE at backup time. Generate it once, against the source, and sign it:

json
{
  "app_production.users": "3847291056",
  "app_production.orders": "9281736450",
  "app_production.sessions": "1029384756"
}

Step-by-Step Execution Walkthrough

The one dependency the validator cannot compute for itself is the manifest. Generate it at backup time, from a quiesced source, so it reflects the exact captured state:

python
#!/usr/bin/env python3
"""Emit a signed-ready manifest of CHECKSUM TABLE values for a schema."""
import json
import sys

import mysql.connector


def build_manifest(schema: str, **db) -> dict:
    conn = mysql.connector.connect(database="information_schema", **db)
    cursor = conn.cursor(dictionary=True)
    cursor.execute(
        "SELECT table_name FROM information_schema.tables "
        "WHERE table_schema = %s AND table_type = 'BASE TABLE'",
        (schema,),
    )
    tables = [row["table_name"] for row in cursor.fetchall()]
    manifest = {}
    for table in tables:
        cursor.execute(f"CHECKSUM TABLE `{schema}`.`{table}`")
        row = cursor.fetchone()
        manifest[f"{schema}.{table}"] = str(row["Checksum"])
    conn.close()
    return manifest


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: build_manifest.py <schema>", file=sys.stderr)
        sys.exit(2)
    out = build_manifest(sys.argv[1], host="db-primary.internal",
                         user="backup_reader", password="${VAULT_BACKUP_PASS}")
    json.dump(out, sys.stdout, indent=2)
    sys.exit(0)

With that manifest in hand, the drill proceeds through five deterministic steps:

  1. Generate the manifest at backup time. Run the generator above while the source is quiesced immediately after the logical or physical backup completes, so the captured checksums match the backed-up state exactly.

  2. Sign the manifest. Detach-sign it with an asymmetric key (Ed25519 or RSA-SHA256) and store the signature alongside the backup. Verify the signature in the orchestration wrapper before the validator ever runs, so tampering in transit or at rest is caught first.

  3. Distribute the config. Render db_config.json from your secret store, injecting the replica password at deploy time rather than committing it.

  4. Run the validator against the DR replica:

    bash
    python3 mysql_checksum_validator.py db_config.json manifest.json; echo "exit=$?"
    
  5. Branch on the exit code. 0 promotes, 1 quarantines and escalates, 2 aborts on a malformed invocation — mapped explicitly in the orchestration wrapper below.

Verification and Expected Output

A clean run logs one VALID line per manifest entry, a final success line, and exits 0:

text
2026-07-05 04:12:01 | INFO | mysql_checksum_validator | app_production.users | Status: VALID | Checksum: 3847291056
2026-07-05 04:12:01 | INFO | mysql_checksum_validator | app_production.orders | Status: VALID | Checksum: 9281736450
2026-07-05 04:12:01 | INFO | mysql_checksum_validator | All checksums validated successfully. Proceeding with failover.

A failing run downgrades the offending table to WARNING, emits a CRITICAL summary, and exits 1:

text
2026-07-05 04:12:01 | WARNING | mysql_checksum_validator | app_production.orders | Status: MISMATCH | Checksum: 5510293847
2026-07-05 04:12:01 | CRITICAL | mysql_checksum_validator | Validation failed. Aborting DR promotion.

The exit code is the contract the orchestrator reads:

  • 0 — every table matches the manifest. Proceed to promotion.
  • 1 — at least one MISMATCH, MISSING, or ERROR. Halt failover, quarantine the backup.
  • 2 — missing arguments or malformed configuration. Abort the pipeline.

Failure Modes and Troubleshooting

Symptom Cause Remediation
mysql.connector.errors.PoolError: Failed getting connection max_workers exceeds pool_size or all connections are checked out Keep max_workers equal to pool_size; add exponential backoff in the wrapper before retrying
Every table reports MISSING Validator account lacks SELECT, or connected to the wrong schema/host Confirm the grant and that db.host points at the intended replica
Status: MISMATCH on a write-heavy table Manifest captured a moving target, or the replica has drifted Regenerate the manifest against a quiesced snapshot; validate a replica lagging within the RPO window
ProgrammingError: lock_wait_timeout exceeded A long DDL holds a metadata lock on the table Schedule validation outside DDL windows; the 5-second session timeout fails fast rather than stalling
Rising Seconds_Behind_Master during the run max_workers saturates replica I/O or thread concurrency Cap max_workers at the replica’s innodb_thread_concurrency or vCPU count; exclude volatile staging tables
Exit 2 with JSONDecodeError Malformed config or manifest, or unresolved ${VAULT_*} placeholder Render secrets before invocation; validate JSON in CI before deploy

CHECKSUM TABLE performs a full table scan and computes a CRC over the rows; it is non-blocking for InnoDB under default settings, but heavily fragmented or partitioned tables still consume I/O. Cap concurrency, exclude temporary and staging tables from the manifest, and abort the drill if replica lag exceeds the defined RPO during execution.

Integration Notes

The validator is built for headless orchestration. A thin shell wrapper turns its exit code into an alert and a promotion decision:

bash
#!/usr/bin/env bash
set -euo pipefail

python3 mysql_checksum_validator.py db_config.json manifest.json
case $? in
  0) echo "[$(date -u)] checksums valid; promoting" ;;
  1) curl -s -X POST "$PAGERDUTY_WEBHOOK" -d '{"event":"dr_checksum_mismatch"}'; exit 1 ;;
  *) echo "[$(date -u)] validator misconfigured; aborting"; exit 2 ;;
esac

Wire that wrapper into whichever scheduler owns the drill:

  • Airflow — invoke it from a BashOperator (or a PythonOperator that shells out and inspects returncode); a non-zero exit fails the task and short-circuits the downstream promotion task, keeping the DAG’s run history as the audit trail.
  • Celery — wrap the call in a task that raises on non-zero so the broker records the failure and event-driven drills (triggered when a fresh backup lands) get low-latency dispatch.
  • cron — schedule the wrapper directly; because it returns strict POSIX codes, cron/systemd OnFailure handlers can route quarantine alerts without any extra glue.

Feed the per-table result records into the broader Automated Backup Integrity Check Implementation audit store so every promotion decision carries immutable evidence of which tables were verified, when, and against which manifest version. Where storage-engine anomalies are suspected rather than logical drift, pair this logical check with page corruption scanning techniques; for multi-terabyte manifests, the bounded fan-out generalizes into async batching for large datasets.

Frequently Asked Questions

Why compare against a stored manifest instead of two live databases?

The manifest is captured once, at backup time, from a quiesced source, and then signed. Comparing a restored replica against that fixed digest proves the backup reproduces the exact captured state. Comparing two live databases only proves they currently agree — both could have drifted identically, and neither tells you the backup on disk is restorable. The stored, signed manifest is the only reference that survives transit and storage without becoming a moving target.

Is CHECKSUM TABLE safe to run against a production replica?

It is non-blocking for InnoDB under default settings, but it is a full table scan and consumes I/O. Run it against a standby rather than the primary, cap max_workers at the replica's thread concurrency, set the session lock-wait timeouts as the script does, and abort if Seconds_Behind_Master climbs past your RPO during the run.

How does a missing table differ from a mismatch?

CHECKSUM TABLE on a nonexistent table returns one row with a NULL Checksum rather than an error, so the validator maps that to MISSING. A MISMATCH means the table exists but its checksum diverged from the manifest. Both are treated as failures that exit 1 and halt promotion, but keeping them as distinct statuses lets the downstream categorization layer separate a dropped table from silent bit rot.

What causes exit code 2 versus exit code 1?

Exit 2 is a pipeline-abort condition: wrong argument count, unreadable files, or malformed JSON — the run never got far enough to judge any backup. Exit 1 is a validation verdict: the script ran, checked every table, and at least one failed. Orchestrators should treat 2 as "fix the invocation" and 1 as "quarantine the backup and escalate."

This script is one component of the broader Checksum Validation Pipelines workflow.

For authoritative behavior of the underlying command, consult the MySQL Reference Manual: CHECKSUM TABLE; for thread-pool and executor lifecycle details, see the Python documentation for concurrent.futures.