Webhook icon
Schedule icon
If icon
Fail icon
List icon
Download icon
Downloads icon
Commands icon
Docker icon
Set icon
SlackIncomingWebhook icon

Prove Your MySQL Backups Restore to the Second, or to the Statement Before a Mistake

Prove MySQL PITR works. Newest mysqldump plus archived binlogs, gap and checksum checks, stop before a DROP or DELETE, your SQL checks, and an RTO.

Categories
Infrastructure

A MySQL point-in-time restore needs two backups that fail separately: a full dump, and every binlog written since. The dump job can succeed for months while the binlog archiver stopped, skipped a file, or uploaded one that is corrupt; the dump can be cut short or made without the binlog coordinates a replay needs. And when the day comes, the usual way to stop a replay, --stop-datetime, works to the second: a mistake made in the same second as the last good writes cannot be separated from them by time.

This blueprint is the MySQL counterpart of the Postgres point-in-time restore drill. It restores for real, in a throwaway container: the newest dump from S3, then the binlog chain from the dump's coordinates on, replayed up to a target time or right before the first transaction matching a statement you name (a DROP TABLE, a DELETE without WHERE). Your SQL checks run on the result, and the restore time is compared with your RTO. Demo mode needs only Docker: it builds a database, writes across three binlogs, drops a table by mistake in the same second as the last insert, and recovers to the statement before the DROP.

How it works

  1. after_backup (Webhook, called by the dump job) or weekly_drill (Schedule, shipped disabled). concurrency limit 1, and a flow sla of one hour.
  2. s3_mode (If): list_dumps (aws.s3.List), dump_found (If + Fail), download_dump (aws.s3.Download, the newest by upload time, picked with the jq Pebble filter), and download_binlogs (aws.s3.Downloads of every *.NNNNNN file under the binlog prefix).
  3. restore (shell.Commands, Docker runner, one throwaway container). The downloaded files arrive as inputFiles (the binlog map merged with the dump):
    • The dump: readable gzip, ends with -- Dump completed, and carries the binlog coordinates (--source-data=2) and GTID_PURGED.
    • The chain: every binlog from the dump's file on, with no number missing and every event checksum good (mysqlbinlog --verify-binlog-checksum). The newest archived event must be younger than archive_lag_minutes.
    • The stop: stop_before_statement scans the binlogs with mysqlbinlog -v (row events are matched on their pseudo-SQL) and stops at the start of the first matching transaction; target_time uses --stop-datetime; neither replays the whole archive.
    • The restore: a scratch mysqld with no binlog of its own and the source's GTID mode, the dump, then every binlog in one mysqlbinlog call (temporary tables and transactions can span files), then your checks.
    • It reports JSON whether it passes or not.
  4. verdict (If): pass when every check passed and the restore beat rto_minutes; kv.Set remembers the last good drill. Otherwise Fail with every problem found.
  5. errors → one Slack alert with the task, the reason and the last good drill. A run stopped by the sla is reported from afterExecution.

What you get

  • Messages from the test runs:
    • "PASSED (s3): mysql/dumps/shop-….sql.gz plus 2 binlog(s) binlog.000001 to binlog.000002 (GTID ON; …), replayed right before /DELETE FROM shop.orders/ (binlog.000002 position 33260), in 5 s. Checks: PASS: 10700 orders (10700 expected)"
    • "FAILED: binlog.000002 is missing from the archive: nothing after binlog.000001 (2026-10-16 10:56:19) can be replayed"
    • "FAILED: binlog.000002 is corrupt: ERROR: Could not read entry at offset 410: Error in log format or read error 1."
    • "FAILED: the newest archived binlog event is from 2026-10-16 10:57:43 UTC, 2 min ago, over the 1 min limit: the binlog archiver has stopped"
    • "FAILED: mysql/dumps/….sql.gz has no binlog coordinates: make the dump with --source-data=2"
  • outputs.result: the dump, the binlogs replayed, where the archive ends, the GTID mode, where the replay stopped, dump and restore seconds, the check results and the problems.
  • The KV entry mysql_pitr_drill_last_good.

Error handling

  • Every problem is collected before the verdict (a gap and a failing check are reported together), and the run ends FAILED.
  • What cannot be restored stops the drill with its reason: an unreadable or cut-short dump, a dump without coordinates, a corrupt binlog, a stop_before_statement that matches nothing, a scratch server that does not start (its first error is quoted), or a replay error from mysql or mysqlbinlog.
  • A check that cannot run (wrong table, syntax error) counts as failed and quotes MySQL's error.
  • Slower than the RTO is a failure, even when the data is right. A drill that hangs is stopped by the one-hour sla and reported.
  • Nothing touches production. The drill only reads the bucket; the restored server lives and dies inside the container.

What this blueprint teaches about Kestra

  • aws.s3.Downloads into inputFiles: {{ outputs.download_binlogs.outputFiles | merge({'dump.sql.gz': outputs.download_dump.uri}) }} hands a whole prefix of objects, plus one more file, to a script task.
  • A flow sla with afterExecution, next to an RTO measured inside the task.
  • The Docker task runner as a disposable database: two mysqld servers (the demo source and the restore) start and stop inside one task.
  • A script that reports instead of failing, so If decides and the outputs stay readable when the drill fails.

Prerequisites

  • A Kestra worker that can run Docker containers.
  • For s3 mode:
    • dumps made with mysqldump --single-transaction --source-data=2 --set-gtid-purged=ON ... | gzip, uploaded to dump_prefix;
    • binlogs archived under their own names, for example with mysqlbinlog --read-from-remote-server --raw --stop-never --result-file=/archive/ binlog.000001 and a sync of /archive to binlog_prefix.
  • The image needs mysqlbinlog, which the official mysql image no longer ships; the default is Percona Server 8.0, a drop-in MySQL build.
  • A Slack incoming webhook.

Secrets

  • SLACK_WEBHOOK_URL.
  • MYSQL_PITR_DRILL_WEBHOOK_KEY: a long random string for the webhook.
  • AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: read access to the bucket (s3 mode).

Inputs

  • mode (demo or s3), s3_bucket, dump_prefix (mysql/dumps/), binlog_prefix (mysql/binlogs/), s3_endpoint.
  • target_time (UTC) or stop_before_statement (a regular expression); neither replays everything.
  • checks (SQL returning ok and detail), archive_lag_minutes (30), rto_minutes (30), mysql_image.

Outputs

  • result (JSON), described above.

Quick start

  1. Add SLACK_WEBHOOK_URL and MYSQL_PITR_DRILL_WEBHOOK_KEY, and run with the defaults (demo mode).
  2. Real result: "PASSED (demo): … 3 binlog(s) binlog.000001 to binlog.000003 (GTID ON …), replayed right before /DROP TABLE/ (binlog.000003 position 197), in 4 s. Checks: PASS: 2000 orders recovered (2000 before the accidental DROP TABLE)".
  3. For your backups: set mode: s3, the bucket and prefixes, a check per table that matters, and call the webhook from the dump job.

How to read the result

  • target_time works to the second: mysqlbinlog --stop-datetime stops at the first event at or after it, so nothing from the target second is replayed. In the tests, 700 inserts and an accidental DELETE ran in the same second. Stopping at that second lost the inserts (10000 orders), stopping one second later replayed the DELETE (100 orders), and stop_before_statement brought back exactly the 10700 orders that existed before it.
  • restore_seconds includes the dump load; the replay is the rest. A long replay means the dump is too old for your RTO: dump more often.

How to extend

  • Drill a dump from xtrabackup instead of mysqldump: read the coordinates from xtrabackup_binlog_info.
  • Keep the recovered table: mysqldump it from the scratch server and upload it, to hand back an accidentally dropped table without a full restore.
  • Run the drill against each replica's archive too, if they are archived separately.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.