Deriving RPO Targets from Binlog Archiving Lag

Recovery point objective is the amount of committed work you would lose if you had to recover right now, and it is almost always quoted rather than measured — “we lose at most a few seconds” is a hope, not a metric. The real number is computable at any instant: it is the age of the newest transaction that is safely archived, which equals the lag between the primary’s gtid_executed and the newest GTID confirmed in object storage. This page derives RPO from that archiving lag: it samples the two coordinates, computes the transaction and time delta between them, and turns it into a monitored seconds-of-exposure number that alerts before it breaches your objective. Unlike RTO, RPO is not measured by a drill — it is a continuous property of the archiving pipeline.

Context & Prerequisites

This is the RPO-derivation companion to RTO/RPO Recovery Drills for MySQL Point-in-Time Recovery, and it reads the same archive manifest produced by Automated Binlog Archiving to Object Storage. You need MySQL 8.0.22+ with gtid_mode=ON, Python 3.10+, read access to the primary’s status and to the manifest, and the archiving pipeline’s confirmed-upload records. The lag you measure here is the same signal the archiver tracks as its primary SLI; this page turns it into a recovery objective.

Step-by-Step Implementation

1. Sample the two coordinates

Read the primary’s newest committed GTID set and the newest GTID confirmed durable in the archive.

# Python 3.10+
def sample_coordinates(conn, manifest) -> tuple[str, str]:
    cur = conn.cursor()
    cur.execute("SELECT @@GLOBAL.gtid_executed")     # MySQL 8.0.22+
    primary = cur.fetchone()[0]
    cur.close()
    archived = manifest.newest_confirmed_gtid()      # last line whose upload verified
    return primary, archived

PITR relevance: the archived coordinate must be the newest confirmed one — a segment mid-upload does not count, because a recovery cannot use it. Counting unconfirmed uploads understates real exposure.

2. Compute the transaction gap

Use GTID_SUBTRACT to find exactly which transactions exist on the primary but not yet in the archive.

-- MySQL 8.0.22+  — transactions committed but not yet safely archived
SELECT GTID_SUBTRACT(@@GLOBAL.gtid_executed, :archived_gtid) AS at_risk;

PITR relevance: this set is precisely the data you would lose in a recover-now scenario. Its cardinality is the RPO expressed in transactions; its oldest member’s age is the RPO expressed in time.

3. Translate the gap to seconds of exposure

Map the oldest at-risk transaction to its commit timestamp (from the primary’s binlog or the archiver’s rotation record) and subtract from now.

# Python 3.10+
import time

def rpo_seconds(at_risk_oldest_commit_epoch: float | None) -> float:
    if at_risk_oldest_commit_epoch is None:
        return 0.0                       # nothing at risk: archive is caught up
    return round(time.time() - at_risk_oldest_commit_epoch, 2)

PITR relevance: seconds-of-exposure is the number stakeholders understand and SLAs are written against; transactions-at-risk is the number engineers debug with. Publish both.

4. Alert against the objective with headroom

Compare the derived RPO to the objective and page before it breaches, not after.

# Python 3.10+
def rpo_status(rpo: float, objective_seconds: float) -> str:
    if rpo <= objective_seconds * 0.7:
        return "green"
    return "amber" if rpo <= objective_seconds else "red"

PITR relevance: archiving lag climbs before it breaches — a throttled bucket, a stalled worker — so an amber band bought by the 0.7 factor is your window to intervene before real exposure exceeds the promise.

Configuration Snippet & Reference Table

InputSourceNote
primary GTIDSELECT @@GLOBAL.gtid_executednewest committed work
archived GTIDmanifest newest confirmed lineunconfirmed uploads do not count
at-risk setGTID_SUBTRACT(primary, archived)the exact data a recover-now would lose
oldest at-risk agecommit timestamp → nowRPO in seconds
objectiveyour SLAe.g. 60s; alert at 0.7×

Verification Checklist

Gotchas & Version-Specific Caveats

Counting unconfirmed uploads understates RPO. A segment that is uploading but not yet checksum-verified cannot satisfy a recovery, so it does not reduce exposure. Always measure from the newest confirmed archived GTID.

Replication lag is not archiving lag. A replica being caught up says nothing about whether logs are in object storage. RPO is about the archive horizon, not the replication horizon — do not substitute Seconds_Behind_Source.

Clock skew between primary and archiver. If commit timestamps come from the primary and “now” from the archiver, a skewed clock distorts the seconds figure; sync both to NTP and prefer the primary’s own clock for both endpoints where possible.

A quiet database can look caught up while broken. If no writes occur, the at-risk set is empty even if archiving has silently stopped. Cross-check with a heartbeat write or the archiver’s last-success timestamp so a stalled pipeline on an idle primary is still detected.

Frequently Asked Questions

Why derive RPO from archiving lag instead of the backup interval?

Because binlog archiving collapses RPO far below the backup interval. A base backup alone bounds your worst-case loss to the time since the last snapshot — hours. Continuous binlog archiving reduces it to the archiving lag — seconds. The whole point of the pipeline is to make RPO a function of how fast logs reach object storage, so that is what you measure.

Is RPO measured during a drill like RTO?

No. RTO is a duration you measure by running a recovery; RPO is a standing property of the archiving pipeline you sample continuously. A drill records the RPO that existed at drill start for context, but the number itself comes from the live gap between the primary and the archive, not from the drill’s replay.

Back to RTO/RPO Recovery Drills.