Measuring RTO with Scheduled PITR Restore Drills

“We can recover in about an hour” is a sentence with no evidence behind it until a drill produces the number. Recovery time objective is a duration, and the only honest way to know it is to run the full restore-and-replay on a schedule and time each phase. This page builds the per-phase timing harness: it stamps the boundaries between provisioning a target, restoring the base backup, replaying the archived binary logs to a chosen point, and verifying the result, then stores those durations as a time series so a regression — a restore that has quietly doubled, a replay that no longer keeps pace — pages before a real incident exposes it. The output is not a single number but a breakdown that tells you which phase to optimize when RTO drifts.

Context & Prerequisites

This is the RTO-measurement specialization of the drill harness in RTO/RPO Recovery Drills for MySQL Point-in-Time Recovery; the scheduling, teardown, and pass/fail semantics there apply here. You need MySQL 8.0.22+, Python 3.10+, a base backup source, the archived manifest from Automated Binlog Archiving to Object Storage, and disposable infrastructure to provision a throwaway target. RTO is measured from “decide to recover” (restore start) to “verified ready” (contiguity confirmed), so those two boundaries must be stamped precisely.

Step-by-Step Implementation

1. Stamp phase boundaries with a monotonic clock

Use time.monotonic() for durations so an NTP step during the drill cannot corrupt a measurement.

# Python 3.10+
import time
from contextlib import contextmanager

@contextmanager
def timed(store: dict, phase: str):
    t0 = time.monotonic()
    try:
        yield
    finally:
        store[phase] = round(time.monotonic() - t0, 2)

PITR relevance: per-phase timings localize a regression — a slow restore points at backup transport, a slow replay points at target I/O or archive fetch — so you optimize the phase that actually moved.

2. Run the phases and accumulate the RTO

The measured RTO window spans restore through verify; provisioning is often excluded if a warm target pool exists, but record it either way.

# Python 3.10+
def measure_rto(target, backup, archive, verifier, point: str) -> dict:
    t = {}
    with timed(t, "provision"):
        target.provision()
    rto0 = time.monotonic()
    with timed(t, "restore"):
        coord = backup.restore_latest(target); target.seed_gtid_purged(coord)
    with timed(t, "replay"):
        target.replay(archive.resolve(coord, point), point)
    with timed(t, "verify"):
        ok = verifier.contiguous(target, coord, point)
    t["rto_seconds"] = round(time.monotonic() - rto0, 2)
    t["ok"] = ok
    return t

PITR relevance: excluding provisioning from the headline RTO is only honest if you actually keep a warm target ready; otherwise provisioning is part of your real recovery time and must be counted.

3. Store durations as a time series and compare to budget

Persist each drill’s phase map keyed by timestamp, and compare the RTO against your SLA budget.

# Python 3.10+
def evaluate(result: dict, budget_seconds: float) -> str:
    match result:
        case {"ok": True, "rto_seconds": r} if r <= budget_seconds:
            return "green"
        case {"ok": True, "rto_seconds": r} if r <= budget_seconds * 1.2:
            return "amber"      # within 20% — investigate before it breaches
        case _:
            return "red"

PITR relevance: an amber band gives you warning before an SLA breach, turning RTO management into a trend you steer rather than a threshold you trip.

Configuration Snippet & Reference Table

# drill target — MySQL 8.0.22+  (disposable: relax durability to measure realistic apply time)
[mysqld]
innodb_flush_log_at_trx_commit = 2
sync_binlog                    = 0
innodb_buffer_pool_size        = 8G
PhaseTypically dominated byOptimize with
provisioninfra API / image pullwarm target pool
restorebase-backup size + transportincremental backups, faster storage
replayarchive fetch + applyparallel fetch, tuned target, filtered replay
verifyGTID arithmeticprecompute manifest intervals

Verification Checklist

Gotchas & Version-Specific Caveats

A suspiciously fast drill usually applied nothing. If replay time is near zero, the resolved segment list was probably empty — assert a non-zero applied-transaction count so an empty replay reads as red, not green.

Wall-clock timing corrupts on NTP steps. Always use time.monotonic() for durations; reserve wall-clock time only for the timestamp key.

Relaxed durability skews the number in your favor only if production matches. Measuring apply time with sync_binlog=0 is realistic for a disposable target, but if your real recovery target must be durable during replay, measure it that way instead.

8.4 variable renames. slave_* variables are replica_* on 8.4; a drill script pinned to the old spelling errors on a newer target.

Frequently Asked Questions

Should provisioning time count toward RTO?

Only if you would actually provision during a real recovery. If you keep a warm standby target ready to receive a restore, provisioning is off the critical path and can be excluded — but you must genuinely maintain that pool. If a real incident would require spinning up infrastructure, that time is part of your RTO and must be measured, or your quoted number is optimistic fiction.

How do I shrink a replay phase that dominates RTO?

Three levers, in order: parallelize segment fetch and decrypt so network cost hides behind apply time; tune the disposable target aggressively for write throughput; and, when the incident is schema-scoped, filter the replay to the affected database. The first two are general; the third is covered in Filtering mysqlbinlog Replay by Database and Table.

Back to RTO/RPO Recovery Drills.