Query icon
Loop icon
Log icon
If icon
SlackIncomingWebhook icon
Switch icon
Pause icon
Fail icon
Schedule icon

MySQL Binary Log Retention Safety Guard

Purge MySQL binary logs only up to the oldest file a replica or backup still needs, alert Slack, then re-read the server to prove replication survived.

Categories
DataInfrastructure

MySQL binary logs are two things at once: the replication stream and the point in time recovery source. They sit on the primary's own volume, so they fail in both directions. Keep too much and the data volume fills, which takes the primary down. Purge too eagerly and you delete a file a replica has not read yet or one the last backup needs. Replication then stops with a fatal "could not find first log file name in binary log index file". There is no undo for that, only a rebuild of the replica.

binlog_expire_logs_seconds is a clock. It does not know where your replicas are. A replica that has been stalled for a day is exactly the case where a time based purge deletes the file it was about to read. The correct boundary is not a time at all, it is whichever is OLDEST of every replica's current read position and the start of your backup's recovery stream.

This blueprint computes that boundary and purges to it. When it cannot, it says so and changes nothing.

This blueprint was created by zkasuran.

Safety

  • The primary cannot see the read position of a replica whose IO thread is stopped, because that replica has no dump thread on the primary. SHOW REPLICAS lists only connected replicas. So the guard asks every replica listed in the replicas input for its own SHOW REPLICA STATUS. It also cross-checks the connected list by server_id to catch a replica that is reading this primary but is missing from the input.
  • A replica that cannot be reached fails the run. The errors block alerts Slack, no log is purged.
  • The purge is refused outright while any replica has a stopped IO or SQL thread, is further behind than lag_files_threshold files, already needs a file that is gone, is not in the input or while the backup's required file is missing. A stalled replica is the reason the logs are piling up, so the replica is the bug, not the retention.
  • safety_margin_files keeps extra files under the computed boundary, for a replica that reconnects slightly behind its last reported position.
  • The purge runs only when auto_remediate is true, dry_run is false, the footprint is over budget and a person confirms the boundary file by name at a Pause.
  • After the purge the primary and every replica are read again. The run then fails if a replica now needs a missing file, a replication thread stopped or the total did not drop.

Findings

State Meaning Purge
BINLOG_OFF log_bin is not ON, so there is nothing to guard Refused
REPLICA_BLOCKED A replica already needs a log that is gone. It needs a rebuild Refused
TOPOLOGY_UNVERIFIED A connected replica is missing from the input. Or a listed server returned no replica status Refused
REPLICA_LAGGING_PINS_LOGS A replica has a stopped thread or is more than lag_files_threshold files behind Refused
UNSAFE_RETENTION Retention is shorter than the recovery window you promise, the legacy expire_logs_days is set or the backup's required file is already gone Refused when the backup file is gone
DISK_AT_RISK The logs are over binlog_budget_mb or over the allowed share of datadir_capacity_mb Allowed up to the safe boundary
OK Inside budget with every replica caught up Nothing to purge

How it works

  1. read_binlogs (io.kestra.plugin.jdbc.mysql.Query, read-only) runs SHOW BINARY LOGS for the file names and sizes on the primary.
  2. read_registered runs SHOW REPLICAS for the replicas with a live connection.
  3. probe_replicas (io.kestra.plugin.core.flow.Loop, one replica at a time) runs SHOW REPLICA STATUS and SELECT @@server_id on each listed replica, for the file it has read (Source_Log_File), the file it has applied (Relay_Source_Log_File) and its thread states.
  4. plan joins the log list, the replica positions, the connected list and performance_schema.global_variables in one statement. It returns the state, the boundary file, the number of files and bytes below it, the purge decision, plus the refusal reason when there is one.
  5. report logs every number, so a dry run is a full account of what would happen.
  6. notify (io.kestra.plugin.core.flow.If) posts to Slack when the state is not OK.
  7. act (io.kestra.plugin.core.flow.Switch) routes on the decision: PURGE pauses for a named confirmation then runs PURGE BINARY LOGS TO, REFUSE logs and alerts the reason, the rest log why nothing happened. A read-only run always lands in the default branch.
  8. verify_gate fails the run with io.kestra.plugin.core.execution.Fail when the re-read shows a replica whose applied position the purge went past, a replica that can no longer read the log it is on, a stopped thread or a footprint that did not drop.
  9. errors alerts Slack when any query fails, including an unreachable replica.

What you get

  • A purge boundary derived from where your replicas actually are, not from a clock.
  • A refusal with a named reason, instead of a purge that breaks replication at 3am.
  • File count and total bytes before and after every purge, with the replica positions beside them.
  • The retention settings on the server next to the recovery window you say you keep.

Who it's for

  • Teams running MySQL replication where the primary's data volume also holds the binary logs.
  • DBAs who have been burned by binlog_expire_logs_seconds deleting a log a stalled replica needed.
  • Anyone whose point in time recovery depends on binary logs that a cleanup job can reach.

Why orchestrate this with Kestra

The boundary changes every minute, so it has to be recomputed from live positions right before the delete. Kestra runs the read against the primary and every replica in one execution, keeps the irreversible statement behind auto_remediate, dry_run and a human Pause, re-reads the server afterwards and fails loudly if the result is wrong. Each run leaves the before and after numbers in the execution history, which is what you want when someone asks why a log is missing.

Tested end to end

On Kestra 2.0.5 OSS against MySQL 8.4.11, one primary with max_binlog_size lowered so logs roll quickly plus one replica, with a local HTTP sink standing in for Slack. Every row below was confirmed afterwards with SHOW BINARY LOGS and SHOW REPLICA STATUS on the servers themselves.

Run Inputs State Before After (server CLI)
Healthy pair budget 1 MiB OK 21 logs, 88,959 bytes, replica at binlog.000235 Unchanged, NO_DISK_PRESSURE
Over budget, report only auto_remediate: true, dry_run: true DISK_AT_RISK 21 logs, 88,959 bytes Unchanged, boundary binlog.000233, 18 files and 79,925 bytes planned
Same, approved auto_remediate: true, dry_run: false DISK_AT_RISK 21 logs, 88,959 bytes 3 logs, 9,034 bytes, oldest binlog.000233, replica applying at binlog.000235
Replica IO thread stopped over budget REPLICA_LAGGING_PINS_LOGS 27 logs, 115,467 bytes, replica pinned at binlog.000055 Unchanged, REFUSE, reason names the stopped thread and the 24 file lag
Backup file already purged backup_required_binlog: binlog.000053 UNSAFE_RETENTION 13 logs, 55,007 bytes Unchanged, REFUSE, the backup's start file is gone
Retention clock short binlog_expire_logs_seconds: 3600, window 72h UNSAFE_RETENTION 13 logs, 55,007 bytes Unchanged, the clock cannot keep the promised window
Backup pins tighter than the replica backup_required_binlog: binlog.000078 DISK_AT_RISK 13 logs, 55,007 bytes Boundary moves from binlog.000080 to binlog.000076, 6 files planned
Replica missing from the input replicas: [] TOPOLOGY_UNVERIFIED 13 logs, 55,007 bytes Unchanged, REFUSE, names the connected replica w19-my-rep:3306 server_id=2
Approval left blank paused, resumed with binlog.000000 UNSAFE_RETENTION 13 logs, 55,007 bytes Unchanged, nothing purged, the confirmation did not match
Replica unreachable replica container stopped run failed 13 logs, 55,007 bytes Unchanged, errors alert posted
Post purge re-check, applier frozen safety_margin_files: 0 run failed 24 logs, 103,605 bytes verify_gate failed, applied position left behind the boundary
Post purge re-check, position rewound safety_margin_files: 0 run failed 48 logs, 133,540 bytes verify_gate failed, 1 replica applied past the boundary, 1 no longer readable

The refusal was the point and it was checked against the counterfactual. With the replica's IO thread stopped and pinned at binlog.000055 while the primary had rolled to binlog.000079, the guard refused. A manual PURGE BINARY LOGS TO 'binlog.000070' then ran with exit 0 and no warning. The replica then failed immediately with error 1236, Could not find first log file name in binary log index file. MySQL does not stop you. That is the failure this flow exists to prevent.

Prerequisites

  • MySQL 8.0.22 or later on the primary, for SHOW REPLICAS and SHOW REPLICA STATUS.
  • Both the primary and each replica reachable from the Kestra worker over JDBC.
  • Grants for the account: REPLICATION CLIENT on the primary for SHOW BINARY LOGS and SHOW REPLICAS, REPLICATION CLIENT on each replica for SHOW REPLICA STATUS, SELECT on performance_schema, plus BINLOG_ADMIN on the primary for the purge.
  • MySQL reports no filesystem free space over SQL, so set datadir_capacity_mb if you want a share of volume check as well as the absolute budget.
  • expire_logs_days was removed in MySQL 8.4. It is still read and reported when a server has it.

Secrets

  • MYSQL_USERNAME / MYSQL_PASSWORD: credentials used for the primary and every replica.
  • SLACK_WEBHOOK_URL: used by alert, refused_alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
primary_url jdbc:mysql://localhost:3306/mysql The primary holding the binary logs
replicas one example entry Every replica of this primary, as [{"name": ..., "url": ...}]
backup_required_binlog empty File the last backup needs as the start of its recovery stream
pitr_window_hours 72 Recovery window you promise, compared with the retention setting
binlog_budget_mb 20480 Footprint that triggers a reclaim
datadir_capacity_mb 0 Volume size, for the share of disk check. 0 turns it off
datadir_pct_threshold 60 Share of that volume the logs may use
safety_margin_files 2 Files kept below the computed boundary
lag_files_threshold 20 Replica lag in files that blocks a purge
auto_remediate false Allow PURGE BINARY LOGS
dry_run true Must be false for the purge to run

Quick start

  1. Add the MYSQL_USERNAME, MYSQL_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow, set primary_url, then list every replica in replicas.
  3. Run it read-only and read report. Check that registered_count matches replica_count, which is how you find a replica nobody remembered.
  4. Set binlog_budget_mb to the size you are willing to hold, then put backup_required_binlog in from your backup tool's own record.
  5. Run it with auto_remediate: true and dry_run: false, then confirm the boundary file name at the Pause.
  6. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Feed backup_required_binlog from the backup job in the same flow, so the boundary tracks the last successful backup instead of an input.
  • Add a replica that reads through a delay (SOURCE_DELAY) to the input. Its position is older, so the boundary follows it automatically.
  • Raise safety_margin_files on topologies with a replica that reconnects from a stored position.
  • Call this flow from the replica lag alert, so a purge is never attempted while lag is open.
  • Add a second primary by running the flow once per primary_url.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.