Rotation Scheduling & Cron Automation for MySQL Binary Log Archiving and PITR

Binary log rotation is routinely mischaracterised as disk hygiene, but it is the trigger that starts every downstream guarantee: it closes the segment a Point-in-Time Recovery (PITR) chain depends on, releases that segment for durable upload, and marks the boundary after which local disk may be reclaimed. When the rotation cadence drifts out of step with the archiving pipeline, three failure modes appear — a recovery window fractured by a segment that was purged before it was uploaded, a primary that fills its datadir because rotation stalled, or duplicate object-storage uploads from two rotations racing on the same host. This page defines the scheduling layer that sits at the head of the Automated Binlog Archiving to Object Storage pipeline. Naive approaches fail because a fixed-interval cron line that blindly calls mysqladmin flush-logs is blind to server state: it rotates through a long-running transaction, during a replica catch-up, or while a backup holds the log — producing exactly the fractured chains it was meant to prevent. The fix is to move from time-based triggers to state-aware, idempotent, single-flight scheduling.

Visual Overview

State-aware rotation and handoff pipelineA timer or cron tick acquires a single-flight flock, then a pre-flight gate checks replica lag, open-transaction age, and log_bin. On pass it issues FLUSH BINARY LOGS; on a tripped gate it skips the tick and emits a reason, which is a healthy exit-0 outcome. The flush publishes the closed segment to the archive queue, a worker uploads and verifies it, and only a durable verified copy allows the local segment to be purged.acquiregatepassgate tripsclosedsegmentverifiedsystemd / cronOnCalendar tickflocksingle-flightPre-flight gatelag · txn · log_binFLUSH BINARY LOGScloses segmentSkip + emit reasonhealthy · exit 0Archive queuepublish handoffUpload + verifyworker · checksumConfirm durable→ purge OK

Core Concept & Prerequisites

Deterministic scheduling replaces the wall clock with server state as the rotation decision input. The scheduler queries the running instance, confirms replication is healthy and transactions are quiescent, and only then issues FLUSH BINARY LOGS. This guarantees that every segment it closes is fully committed, indexed in the binary log index, and immediately eligible for the transform stage. The two invariants that make this safe are single-flight execution — never two rotations at once on one host — and handoff-before-purge — a closed segment is never deleted until the archiver confirms a durable, verified copy exists. The transform stage that consumes each closed segment is Compression & Encryption Workflows; this page owns everything up to the moment a stable, read-only segment is handed off.

Rotation is only meaningful if the underlying stream is deterministic, so the format and identity choices upstream are prerequisites, not details. The recovery guarantees here assume binlog_format = ROW (the reasoning is in ROW vs STATEMENT vs MIXED Formats) and a gap-free stream of global transaction identifiers from a correctly configured GTID Tracking & Enforcement pipeline. The scheduler must also never let purge outrun archiving — its timing has to be reconciled against the horizons described in Binlog Retention Boundaries.

Prerequisites:

  • MySQL 8.0.22+ on the source, with log_bin = ON, gtid_mode = ON, and enforce_gtid_consistency = ON. From 8.0.22 the status command is SHOW BINARY LOG STATUS (the former SHOW MASTER STATUS) and replication uses SHOW REPLICA STATUS with Seconds_Behind_Source.
  • Python 3.10+ for the orchestration layer (match statements, slots=True typed dataclasses, the walrus operator in streaming loops).
  • mysql-connector-python for the pre-flight queries, tenacity for declarative retry policy, and either systemd (preferred) or a POSIX flock-wrapped cron line for the trigger and mutual-exclusion guard.
  • A dedicated archiver MySQL account holding only RELOAD (for FLUSH BINARY LOGS), REPLICATION CLIENT, and PROCESS — scoped per Security & Access Frameworks.

The Case Against Fixed-Interval Cron

A crontab line such as */15 * * * * mysqladmin flush-logs has no knowledge of what the server is doing at minute 15. Three concrete collisions follow. It can fire mid-way through a large ALTER or a bulk load, closing a segment whose transaction spans the rotation boundary and complicating replay ordering. It can fire while a replica is minutes behind, so rotation and the replica’s relay pull contend for the same index. And with no mutual-exclusion guard, a slow rotation that overruns its 15-minute window is joined by the next tick, so two processes call FLUSH BINARY LOGS and race the archive queue, producing duplicate uploads and, worse, a purge that deletes a segment the other run had not yet shipped.

Systemd timers supersede raw crontab entries for this workload because they provide native OnCalendar precision, Persistent=true catch-up after downtime, integrated journaling, and — combined with flock or a oneshot service — first-class single-flight semantics. The cron path remains valid where systemd is unavailable, provided every invocation is wrapped in flock -n so a second tick exits immediately rather than running concurrently.

A production timer unit enforces cadence and catch-up:

# /etc/systemd/system/mysql-binlog-rotation.timer
[Unit]
Description=MySQL Binary Log Rotation Scheduler
Requires=mysql-binlog-rotation.service

[Timer]
OnCalendar=*:0/15
Persistent=true
AccuracySec=10
Unit=mysql-binlog-rotation.service

[Install]
WantedBy=timers.target

The paired service wraps execution as a oneshot, which systemd already runs single-flight per unit:

# /etc/systemd/system/mysql-binlog-rotation.service
[Unit]
Description=MySQL Binlog Rotation Execution
After=network.target mysqld.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/mysql-binlog-rotate --timeout=300
EnvironmentFile=/etc/mysql/archiver.env
StandardOutput=journal
StandardError=journal
RuntimeDirectory=mysql-rotation

On a cron-only host the equivalent guard is an advisory lock the orchestrator must acquire before it does anything else:

# /etc/cron.d/mysql-binlog-rotation  (cron fallback — flock enforces single-flight)
*/15 * * * * mysql  /usr/bin/flock -n /run/lock/binlog-rotate.lock \
  /usr/local/bin/mysql-binlog-rotate --timeout=300

Production-Grade Python Implementation

The orchestrator is a stateful, idempotent module. It refuses to rotate unless a pre-flight gate passes, holds a flock for its whole run so it is single-flight even across the systemd and cron paths, retries only genuinely transient database faults through tenacity, and emits one structured JSON line per state transition. Deterministic faults — a bad grant, binary logging switched off — are raised immediately rather than retried into a livelock.

#!/usr/bin/env python3
"""State-aware rotation orchestrator for MySQL binary logs.
Targets: MySQL 8.0.22+, Python 3.10+, POSIX. Requires: mysql-connector-python, tenacity."""
from __future__ import annotations

import fcntl
import json
import logging
import sys
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from time import gmtime, strftime

from mysql.connector import Error as MySQLError, connect
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential,
)

logger = logging.getLogger("binlog.rotation")


class TransientRotationError(RuntimeError):
    """Retryable: connection reset, lock-wait timeout, transient I/O."""


class PreflightRejected(RuntimeError):
    """Deterministic for this tick: state gate said 'do not rotate now'."""


@dataclass(slots=True, frozen=True)
class RotationConfig:
    host: str = "127.0.0.1"
    port: int = 3306
    user: str = "archiver"
    password: str = ""
    connect_timeout: int = 10
    max_replica_lag_sec: int = 120
    max_open_txn_age_sec: int = 60
    lock_path: str = "/run/lock/binlog-rotate.lock"
    dry_run: bool = False


@contextmanager
def single_flight(lock_path: str):
    """Advisory flock so a second tick (systemd OR cron) exits, never races."""
    fh = open(lock_path, "w")
    try:
        try:
            fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError as exc:
            raise PreflightRejected("another rotation holds the lock") from exc
        yield
    finally:
        fcntl.flock(fh, fcntl.LOCK_UN)
        fh.close()


@contextmanager
def mysql_connection(cfg: RotationConfig):
    conn = connect(
        host=cfg.host, port=cfg.port, user=cfg.user, password=cfg.password,
        connection_timeout=cfg.connect_timeout, autocommit=True,
    )
    try:
        yield conn
    finally:
        conn.close()


def validate_preconditions(conn, cfg: RotationConfig) -> None:
    """Raise PreflightRejected unless the server is safe to rotate right now."""
    with conn.cursor(dictionary=True) as cur:
        # MySQL 8.0.22+: replica health via SHOW REPLICA STATUS
        cur.execute("SHOW REPLICA STATUS")
        if (replica := cur.fetchone()) and replica.get("Seconds_Behind_Source") is not None:
            if (lag := int(replica["Seconds_Behind_Source"])) > cfg.max_replica_lag_sec:
                raise PreflightRejected(f"replica lag {lag}s > {cfg.max_replica_lag_sec}s")

        # MySQL 8.0+: no long-running open transaction spanning the boundary
        cur.execute(
            """
            SELECT COUNT(*) AS open_txns
            FROM information_schema.innodb_trx
            WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > %s
            """,
            (cfg.max_open_txn_age_sec,),
        )
        if (open_txns := cur.fetchone()["open_txns"]) > 0:
            raise PreflightRejected(f"{open_txns} long-running transaction(s) open")

        # MySQL 8.0+: binary logging actually on (else FLUSH is meaningless)
        cur.execute("SHOW VARIABLES LIKE 'log_bin'")
        if (row := cur.fetchone()) is None or row["Value"] != "ON":
            raise PreflightRejected("log_bin is OFF; nothing to rotate")


@retry(
    retry=retry_if_exception_type(TransientRotationError),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=20),
    reraise=True,
)
def execute_rotation(conn) -> str:
    """Close the active segment and return the new active binlog filename."""
    try:
        with conn.cursor(dictionary=True) as cur:
            cur.execute("FLUSH BINARY LOGS")
            # MySQL 8.0.22+: SHOW BINARY LOG STATUS replaces SHOW MASTER STATUS
            cur.execute("SHOW BINARY LOG STATUS")
            if (status := cur.fetchone()) is None:
                raise PreflightRejected("SHOW BINARY LOG STATUS empty (log_bin off?)")
            return status["File"]
    except MySQLError as exc:
        # 1598 = ER_BINLOG_LOGGING_IMPOSSIBLE (disk/write error) is transient-ish;
        # surface it for retry, everything else deterministic re-raises.
        if exc.errno == 1598:
            raise TransientRotationError(f"binlog write error: {exc.msg}") from exc
        raise


def emit(event: str, **fields) -> None:
    logger.info(json.dumps(
        {"event": event, "ts": strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()), **fields}
    ))


def main() -> int:
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    cfg = RotationConfig(dry_run="--dry-run" in sys.argv)
    try:
        with single_flight(cfg.lock_path), mysql_connection(cfg) as conn:
            validate_preconditions(conn, cfg)
            if cfg.dry_run:
                emit("dry_run", action="rotation_skipped")
                return 0
            new_binlog = execute_rotation(conn)
            emit("rotation_executed", new_binlog=new_binlog)
            return 0
    except PreflightRejected as exc:
        emit("rotation_skipped", reason=str(exc))
        return 0  # a skipped tick is a success, not an error to page on
    except MySQLError as exc:
        emit("mysql_error", code=exc.errno, message=exc.msg)
        return 2
    except Exception as exc:  # noqa: BLE001 — last-resort structured capture
        emit("unhandled_failure", error=repr(exc))
        return 3


if __name__ == "__main__":
    raise SystemExit(main())

Two design choices are load-bearing. A rejected pre-flight exits 0, not non-zero — a tick that correctly declines to rotate is a healthy outcome, and paging on it would train operators to ignore the alert. And the flock is held for the whole connection lifetime, so the systemd and cron paths can coexist during a migration without ever racing. Once execute_rotation returns, the closed segment is published to the archive queue; the durable upload and cross-region replication of that segment are owned by AWS S3 & GCS Sync Pipelines, and the queue depth and worker sizing behind it by Async Processing & Queue Management.

Configuration Reference

Two surfaces govern rotation: MySQL server variables that shape when and how segments close, and the orchestrator parameters in RotationConfig. The server variables below assume MySQL 8.0.22+.

VariableTypeDefaultRecommendedPITR impact
log_binbooleanONONThe whole chain is impossible without it; a rotation call is meaningless when off.
max_binlog_sizeinteger1073741824268435456 (256 MiB)Smaller segments rotate sooner, cutting archiving lag and per-artifact recovery download size.
binlog_expire_logs_secondsinteger2592000≥ 2 × archiving lag SLOServer-driven purge horizon; must never expire a segment before the archiver confirms it durable.
sync_binloginteger11Guarantees a rotated segment is flushed to disk before it is eligible to hand off.
binlog_error_actionenumABORT_SERVERABORT_SERVEROn a write failure the server aborts rather than silently continuing without binlogs and breaking the chain.
gtid_modeenumOFFONGTID-tagged boundaries make each rotated segment independently addressable for replay.
enforce_gtid_consistencyenumOFFONRejects statements that would produce non-deterministic GTID boundaries.
expire_logs_daysinteger0unset (use seconds)Deprecated; setting it alongside binlog_expire_logs_seconds is an error.

Orchestrator parameters live in the typed RotationConfig: max_replica_lag_sec and max_open_txn_age_sec are the two pre-flight gates, connect_timeout bounds a hung primary, and lock_path names the single-flight mutex. Set binlog_expire_logs_seconds from the archiving-lag SLO, not the other way round — the purge horizon is a consequence of how fast the pipeline confirms durability, and the Base Backup Integration for PITR snapshot cadence sets the floor below which no segment may be expired.

Validation & Verification Gates

A rotation is not “done” when FLUSH BINARY LOGS returns. It is done when the closed segment is confirmed durable and the local copy is cleared for reclamation. Each gate below must pass before the next runs.

  • Single-flight acquired. The flock must be held before any query executes. A failed acquisition is a skip, not a failure — another run owns this tick.
  • Pre-flight clean. Replica lag under threshold, no long-running open transaction, and log_bin = ON. Any failed gate skips this tick and records the reason.
  • New segment observed. After FLUSH BINARY LOGS, SHOW BINARY LOG STATUS must report a different File than before the flush, and the previous file must appear in SHOW BINARY LOGS as a closed segment. If the filename did not advance, the rotation did not happen.
  • Handoff-before-purge. The closed segment must be marked confirmed by the archiver — checksum reconciled against the uploaded object — before it is eligible for PURGE BINARY LOGS or before binlog_expire_logs_seconds is allowed to reach it. This is the single gate that prevents a fractured recovery chain.
  • GTID contiguity. The GTID interval of the newly closed segment must join contiguously to the previous one; a gap is a recovery-blocking hole surfaced early.
-- MySQL 8.0.22+: confirm the active segment advanced after a flush
SHOW BINARY LOG STATUS;   -- File must differ from the pre-flush File

-- MySQL 8.0.22+: list closed segments and confirm the just-rotated one is present
SHOW BINARY LOGS;         -- Log_name, File_size, Encrypted

-- MySQL 8.0+: only purge a segment the archiver has already confirmed durable
PURGE BINARY LOGS TO 'mysql-bin.000042';
Rotation verification gate sequenceLeft to right: flock acquired, pre-flight clean, FLUSH BINARY LOGS, File advanced check via SHOW BINARY LOG STATUS, and archiver durable-confirm, ending in purge-eligible. A busy lock or a tripped pre-flight gate skips the tick as a healthy exit-0 outcome. A File that did not advance or an unconfirmed segment holds the segment, blocks purge, and alerts — an unshipped segment is never deleted.heldcleanflushedadvanceddurablelock busygate tripsno advanceunconfirmedflock acquiredsingle-flightPre-flightlag · txn · log_binFLUSH BINARYLOGS · rotateFile advanced?status differsDurable-confirmarchiver checksumPurge-eligible ✓reclaim localSkip this tick — exit 0another run or gate owns itHold segment — block purge, alertan unshipped segment is never deleted

The confirmed segment feeds the manifest a recovery orchestrator later maps a target timestamp or GTID against; that coordinate selection is covered in Timestamp Targeting Strategies.

Error Handling & Failure Modes

Rotation failures split into retryable (back off and retry the same tick) and deterministic (skip or page; retrying cannot help). Mapping each signal to the right class is what keeps the scheduler from either livelocking or silently dropping a segment.

Symptom / signalRoot causeClassRecovery
flock non-blocking acquire failsPrevious tick still running or overran its windowSkipExit 0; the holding run will finish and hand off. Investigate only if skips persist.
ERROR 1598 (HY000) ER_BINLOG_LOGGING_IMPOSSIBLEDisk full or write error on the datadir during FLUSHTransient→pagetenacity retries once or twice; with binlog_error_action=ABORT_SERVER a persistent fault stops the server — free space, then recover.
ERROR 1381 (HY000) ER_NO_BINARY_LOGGINGlog_bin is OFF; SHOW BINARY LOG STATUS returns emptyDeterministicPre-flight catches this and skips; fix the server config, do not retry.
ERROR 1227 (42000) ER_SPECIFIC_ACCESS_DENIED_ERROR on FLUSHarchiver account lacks RELOADDeterministicGrant RELOAD; scope per the security frameworks page. Never fall back to root.
ERROR 3020 / ER_WARN_PURGE_LOG_IN_USE on PURGEPurging a segment a replica still needsDeterministicDo not purge; the handoff-before-purge gate should have blocked this. Let the replica catch up.
Lock-wait timeout on information_schema.innodb_trxContention from a heavy transactionTransientRetry with backoff; if it recurs, the open-txn gate is already telling you not to rotate now.
Two segments archived for one rotationConcurrent runs without a lock guardDeterministic (design)Enforce flock/oneshot; deduplicate downstream by GTID interval.

The full backoff taxonomy, jitter, and dead-letter routing for the transport half of these faults — an upload that throttles or times out after a clean rotation — is the subject of Error Handling & Retry Logic; the TransientRotationError boundary here is deliberately narrow so only genuinely transient database faults are retried.

Observability & Alerting

Every state transition is an observable event. Emit one structured JSON line per tick with stable fields — event, ts, new_binlog, reason, preflight_lag, preflight_open_txns, rotation_ms — so a log pipeline computes cadence and skip-rate without parsing prose. Server-side, watch the source’s own view of binlog activity:

-- MySQL 8.0.22+: binlog cache pressure hints at rotation cadence mismatch
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME IN ('Binlog_cache_use', 'Binlog_cache_disk_use');

-- MySQL 8.0+: watch for a transaction old enough to straddle a rotation boundary
SELECT trx_id, TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) AS age_sec
FROM information_schema.innodb_trx
ORDER BY age_sec DESC
LIMIT 5;

Alert thresholds worth wiring:

  • Rotation staleness — time since the last rotation_executed event. If it exceeds two timer intervals, the timer, the lock, or the primary is stuck; this single SLI catches a wedged scheduler before disk fills.
  • Skip-rate spike — a sustained rise in rotation_skipped means the pre-flight gate is chronically tripping (a replica stuck behind, or a perpetual long transaction), which is a workload problem masquerading as a scheduling one.
  • Archiving lag — age of the oldest closed-but-unconfirmed segment. Page well before it approaches binlog_expire_logs_seconds; this is the metric that guards the handoff-before-purge invariant.
  • Local datadir headroom — free space on the binlog volume. A rotation that succeeds but whose segments are not being purged (because archiving stalled) will still exhaust disk; alert on the trend, not just the failure.

Expose Prometheus counters — mysql_binlog_rotation_success_total, mysql_binlog_rotation_skipped_total{reason}, mysql_binlog_rotation_duration_seconds — so cadence, skip reasons, and latency are dashboard-able platform-wide.

Frequently Asked Questions

Should I use systemd timers or cron for binlog rotation?

Prefer systemd timers where available. They give you OnCalendar precision, Persistent=true catch-up after downtime, journald integration, and — with a Type=oneshot service — single-flight execution for free. Use cron only where systemd is absent, and always wrap the command in flock -n so a slow rotation that overruns its window is not joined by the next tick. During a migration you can run both paths at once safely, because the orchestrator holds the same advisory flock regardless of which trigger fired.

Why rotate on server state instead of a fixed time interval?

A fixed interval is blind to what the server is doing when it fires. It can rotate through a long-running transaction, during a replica catch-up, or while a backup holds the log — closing a segment at exactly the wrong boundary. State-aware scheduling queries replica lag and open-transaction age first and skips the tick if either gate trips, so every segment it closes is fully committed and immediately safe to archive. The wall clock still bounds how often rotation is attempted; server state decides whether it actually happens.

How do I stop rotation and purge from deleting a binlog before it is archived?

Enforce handoff-before-purge: a closed segment is eligible for PURGE BINARY LOGS only after the archiver confirms a durable, checksum-verified copy in object storage. Set binlog_expire_logs_seconds to at least twice your archiving-lag SLO so the server-driven purge horizon can never reach a segment the pipeline has not yet shipped, and alert on the age of the oldest unconfirmed segment. Never let expire_logs_days and binlog_expire_logs_seconds be set together — that combination is rejected by the server.

What replaced SHOW MASTER STATUS for confirming a rotation?

From MySQL 8.0.22, SHOW MASTER STATUS is deprecated in favour of SHOW BINARY LOG STATUS, and replica health uses SHOW REPLICA STATUS with the Seconds_Behind_Source column. To confirm a rotation actually occurred, compare the File reported by SHOW BINARY LOG STATUS before and after FLUSH BINARY LOGS — if the filename advanced and the previous file appears in SHOW BINARY LOGS, the segment closed cleanly.

Back to Automated Binlog Archiving to Object Storage.