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
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_completedusage 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
SELECTprivilege on every schema in the manifest —CHECKSUM TABLErequiresSELECT, 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.
#!/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:
{
"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:
{
"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:
#!/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:
-
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.
-
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.
-
Distribute the config. Render
db_config.jsonfrom your secret store, injecting the replica password at deploy time rather than committing it. -
Run the validator against the DR replica:
bash python3 mysql_checksum_validator.py db_config.json manifest.json; echo "exit=$?" -
Branch on the exit code.
0promotes,1quarantines and escalates,2aborts 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:
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:
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 oneMISMATCH,MISSING, orERROR. 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:
#!/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 aPythonOperatorthat shells out and inspectsreturncode); 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/systemdOnFailurehandlers 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."
Related
- Checksum Validation Pipelines — the parent workflow this validator plugs into as its database-native stage.
- Page corruption scanning techniques — storage-engine-level CRC verification that complements this logical check.
- Async batching for large datasets — how the bounded fan-out generalizes to terabyte-scale manifests.
- Error categorization frameworks — mapping each table’s status tier to an orchestrator action.
- RTO and RPO mapping frameworks — the recovery envelope that defines what a “valid” gate means.
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.