Planned failover: moving the primary on purpose

One annotation, twelve phases, and an RPO of zero by construction. The only path on which Bloodraven drains your application's connections — and the only phase from which it will roll back.

By the end of this topic you can

  1. Trigger a planned failover with the annotation and follow the phases through to Succeeded
  2. Explain why planned failover is RPO 0 by construction and what the lag gate actually compares
  3. Choose the rollback behaviour for a lag gate that never closes

playground is healthy. iad is writable, pdx and reader are read-only, and the counter app is still writing through the -primary Service with the bounded-lifetime pool you fixed in the last topic. Nothing is broken and nobody is paged — but iad’s nodes are being rebuilt on Tuesday and the primary has to move. This is the one primary move you get to schedule, and the only one where Bloodraven shuts your application’s connections down before it takes the write endpoint away.

That is why this topic sits here rather than beside emergency failover. The emergency path does try to kill application connections on the old primary — it is step 2 of the sequence — but it is best-effort, single-pass, and skipped when the old primary is unreachable, which is the failure mode that made you open this unit in the first place. An autonomous sidecar self-fence has no operator-side drain at all. Only planned failover actually drains. Nothing else does.

The trigger

One annotation, bloodraven.shipstream.io/planned-failover. Its value is a bare site name, or a site name followed by :key=value overrides; maxLagWait is the only supported key and takes the spec field’s 1s–1h range, and an unknown or repeated key, or an out-of-range value, is rejected outright (a PlannedFailoverRejected Event, no run) so a typo cannot quietly run with defaults. A valid request is given a runID and the annotation is removed as the run enters Admitting; only a Deferred request keeps it.

Try itMoving playground' primary from iad to pdx
kubectl -n bloodraven-playground annotate mysqlfailovergroup playground bloodraven.shipstream.io/planned-failover=pdx
mysqlfailovergroup.shipstream.io/playground annotated
kubectl -n bloodraven-playground get mysqlfailovergroup playground -o jsonpath='{.status.plannedFailover.phase}'
WaitingForLag
kubectl -n bloodraven-playground get mysqlfailovergroup playground -o jsonpath='{.status.plannedFailover.phase} {.status.plannedFailover.result.outcome}'
Succeeded Succeeded
Recorded output. Run reveals what is already on the page — nothing executes, and no cluster is contacted.

Leave the counter app running while you do this. That is the point of the exercise.

The phases

Every transition is one durable status write, and every side effect runs only after the record that announces it has committed, so an operator restart always lands on a record whose side effect is either done or provably not started. The complete list:

Admitting, Deferred, Fencing, Draining, WaitingForLag, WaitingForDragonflySync, PromotingDragonfly, Promoting, Publishing, RollingBack, Succeeded, Failed.

There is no idle phase: a group that never had a planned failover has no status.plannedFailover block at all. Admitting and Deferred own nothing. Deferred is entered when the cooldown blocks you and onCooldown is Defer, or when a deployment holds a failover-hold lease. From Fencing on, the run holds the group’s claim: emergency failover, split-brain resolution, ordered updates, recovery and DNS reconciliation are deferred until the promotion is confirmed, recorded and published. WaitingForDragonflySync and PromotingDragonfly are skipped entirely when Dragonfly was not enabled when the request was accepted — the next topic takes those two apart.

FlowPlanned failover phases, and where rollback stops
runID stamped, annotation removed. Owns nothing. Re-validates and waits for the claim; ends Failed{NotStarted, AdmissionTimeout} after 2 minutes.
Cooldown with onCooldown: Defer, or a deployment hold. Owns nothing; annotation kept; retried at admission.retryTime.
Claim granted. super_read_only=ON on the source, read back, GTID_EXECUTED recorded in fence.gtidSet. Primary role label stripped.
Application connections on the source killed every second. drainTimeout 30s, then proceeds regardless.
Polls the target until its GTID_EXECUTED contains fence.gtidSet. LagTimeout, DivergentTarget or InvalidGTID roll back. maxLagWait 5m.
Skipped unless Dragonfly was enabled at acceptance.
Skipped unless Dragonfly was enabled at acceptance.
Before super_read_only=OFF can reach the target, failures still roll back. AFTER IT, NO ROLLBACK: PromotedUnconfirmed or ManualRecoveryRequired.
Promotion confirmed, recorded and published to DNS; claim released. Only status bookkeeping remains.
Nothing was written to the target: unfence the source if no other site is writable and the fence is provably the run's.
Terminal. result.outcome says what the cluster was left in; result.reason says why.

Why it is RPO 0 — by construction

“Planned failover is safe” is a sentence people repeat without knowing why, so be exact. It is not luck and it is not a lag threshold. It is an ordering.

At Fencing the operator sets super_read_only = ON on iad, reads it back, and then reads iad’s GTID_EXECUTED, recording it in status.plannedFailover.fence.gtidSet. Fence first, snapshot second. A fenced primary accepts no new client writes, so the set pdx must catch up to cannot grow underneath the gate. That is the whole argument.

At WaitingForLag the operator polls pdx’s GTID_EXECUTED and asks one question: does it contain fence.gtidSet? The check is a genuine GTID-set superset test. Not seconds. And immediately before it clears super_read_only on pdx, a pre-write gate demands that pdx hold exactly the fenced set, with no channel left and every other promotable site answering read-only. On success status.lastFailover.promotionGtidExecuted equals fence.gtidSet: nothing acknowledged was lost, by construction. There is no “transactions lost” counter on this path, because a clean switchover cannot produce a number.

You met spec.replication.maxLagSeconds in Unit 2 and it looks like it belongs here. It does not. It drives exactly one thing: the ReplicationLagging Degraded condition. It is not a promotion gate anywhere — the emergency path promotes a replica that is past the threshold anyway, because no writable site at all is nearly always worse — and WaitingForLag never consults it. Seconds are a bad gate on their own merits: Seconds_Behind_Source compares last-executed against last-downloaded relay event, so it reads 0 when the IO thread has stalled.

Draining is a deadline, not a barrier

The defaults on spec.plannedFailover: maxLagWait 5m, drainTimeout 30s, onCooldown Reject. Admission bounds the two timers, maxLagWait to 1s–1h and drainTimeout to 1s–10m; a 0 is refused, not quietly replaced by the default.

Two things take the write endpoint away. At Fencing the source’s primary role label is stripped to fenced, which matches neither the -primary selector nor the -replicas selector, so the write Service sheds the endpoint and new connections stop arriving. At Draining the operator kills the connections already open, every second.

Then the sharp edge. When the budget runs out with connections still on the source, the operator logs drain budget exhausted after %s with %d connection(s) remaining on %q; proceeding — and proceeds, recording the count in drain.remainingConnections. A stuck client is not allowed to block a switchover indefinitely. That is a defensible choice, and it is a decision you have just inherited: the connections that outlive the drain are exactly the ones from the last topic, open against a demoted primary, passing every validation query, serving stale reads until something tries to write. Your drainTimeout and your pool’s maximum connection lifetime are two halves of one setting. A pool that holds connections for ten minutes against a thirty-second drain makes the drain decoration.

Rollback stops at the first possible write

result.outcome says what the cluster was left in. The line that matters is whether super_read_only=OFF may have reached the target.

OutcomeWhenSource afterwardsWho resolves it
NotStartedRefused or timed out in admission (CooldownActive, TargetNotPromotable, AdmissionTimeout)Never fencedNobody — re-annotate later
RolledBackFailed before any write to the target (LagTimeout, DivergentTarget, FenceFailed, TargetLost, PromotionAborted)Unfenced, still the primaryNobody — nothing was lost
SourceLeftFencedNothing was written to the target, but unfencing was unsafe (another site writable, a fence it cannot prove is its own, a lost source)Still fencedOften the operator’s re-assert; sometimes a human
PromotedUnconfirmedA write may have reached the target and it could not be confirmedFenced; the target may be servingA human, with the runbook
ManualRecoveryRequiredA write may have reached the target and the result cannot be explainedUnknownA human; automation is held until the hold is acknowledged

Draw the operational conclusion. A lag gate that never closes is the good failure: iad comes back writable, the counter app resumes, and you have lost nothing but the fenced window. So when maxLagWait expires against a pdx that is genuinely behind, let the rollback fire and fix the lag first. Raising maxLagWait to force the gate closed does not make pdx catch up any faster — it only lengthens the window in which your source is fenced and your writes are refused.

Two refusals worth recognising before you meet them. A planned failover aimed at reader ends Failed{NotStarted, TargetNotPromotable} with only PrimaryCandidate sites may be promoted — the promotability rule from Unit 1, enforced again at this entry point. And the anti-flap cooldown gates planned admission just as it gates the automatic path, with reason CooldownActive; because onCooldown defaults to Reject, a planned failover attempted soon after an emergency one ends NotStarted and the annotation is cleared, not queued. Set onCooldown: Defer if you would rather it wait in Deferred and retry itself at admission.retryTime. One more: planned failover is strict. If a PrimaryCandidate or DisasterRecovery site does not answer, admission waits with waitReason: PeerUnreadable and ends AdmissionTimeout after two minutes. A site it cannot read might be writable.

What you have now

You can move playground’ primary on purpose, follow status.plannedFailover.phase to Succeeded, check that status.lastFailover.promotionGtidExecuted is the fenced set, and argue the superset gate to anyone who thinks it is a lag threshold. You can also say which outcomes hand you the cluster back intact and which hand you a fenced primary, an automation hold and a pager. Everything above assumed MySQL was the only thing moving. Next: what happens when there is a Dragonfly beside it, and what Bloodraven does and does not promise about it.

Flashcards

Which annotation triggers a planned switchover, and what may its value contain?

bloodraven.shipstream.io/planned-failover. The value is a bare site name, or a site name plus :key=value overrides — maxLagWait is the only supported key, and an unknown key is rejected.

1 / 12

Quiz

Question 1 of 5

playground has been sitting in WaitingForLag for two minutes. pdx reports Seconds_Behind_Source = 0 and is well inside spec.replication.maxLagSeconds. What is the operator actually waiting for before it will promote?

Show answer

Answer: pdx's GTID_EXECUTED to contain the source's GTID_EXECUTED snapshot taken at the fence.

The gate is a true GTID-set superset test: the reconciler polls the target's GTID_EXECUTED and advances only when it contains status.plannedFailover.fence.gtidSet. maxLagSeconds is not it — that field drives exactly one thing, the ReplicationLagging Degraded condition, and is never a promotion gate on either the planned or the emergency path. A streak of Seconds_Behind_Source = 0 is not it either, and is a bad signal on its own merits: it compares last-executed against last-downloaded relay event, so it reads 0 when the IO thread has stalled. The relay-log drain belongs to the emergency sequence, not to planned failover's lag gate. (objective 8)

Question 2 of 5

drainTimeout is a barrier: when it expires with application connections still open on the source, the planned failover aborts and the source is unfenced.

Show answer

Answer: False

The reversal: the drain is a deadline, not a barrier. When the budget is exhausted with connections remaining, the operator logs drain budget exhausted after %s with %d connection(s) remaining on %q; proceeding and proceeds to promotion, so a stuck client cannot block a switchover indefinitely. Nothing is unfenced and nothing aborts. That is why your pool's maximum connection lifetime has to be shorter than the drain budget — connections that outlive the drain are the ones that go on serving stale reads against a demoted primary. (objective 7)

Question 3 of 5

Four planned failovers on playground failed in four different ways. Which one cannot be rolled back, leaving the source fenced for a human to put right?

Show answer

Answer: PromotionUnconfirmed in Promoting, after super_read_only=OFF may have reached the target and the promotion could not be confirmed.

Rollback reaches as far as the first possible write to the target. LagTimeout and FenceFailed both fail before anything was written to the target, so the run goes through RollingBack and, with no other site writable, unfences the source: outcome RolledBack, nothing lost. CooldownActive fires in admission, before the source was ever fenced: outcome NotStarted, nothing to undo. PromotionUnconfirmed is past the boundary: super_read_only=OFF may have reached the target, so unfencing the source could leave two writable primaries. The run ends PromotedUnconfirmed with the source fenced, and a human follows the runbook. A result the run cannot explain at all goes further, to ManualRecoveryRequired and an automation hold. (objective 9)

Question 4 of 5

A planned failover of playground from iad to pdx has rolled back twice with LagTimeout; pdx is genuinely behind because of a long-running batch job. A colleague proposes annotating with pdx:maxLagWait=30m so the gate has time to close. Argue the call.

Show answer

Answer:

Do not raise it — fix the lag first. The rollback is the good outcome: LagTimeout in WaitingForLag unfences iad, which stays the active primary with nothing lost, so both failed attempts cost only the fenced window. Raising maxLagWait to 30m does not make pdx apply relay logs any faster; it only extends the period during which iad is fenced with super_read_only=ON and the counter app's writes are being refused. Kill or wait out the batch job, confirm pdx is applying, then re-annotate with the default 5m and the gate will close on its own.

A full-credit answer shows: A strong answer covers: (1) rollback in WaitingForLag unfences the source, so the failure is safe and costs no data; (2) maxLagWait is a timeout, not a throttle — it does not accelerate catch-up; (3) the real cost of a longer wait is a longer fenced source with writes refused; (4) the correct action is to remove the source of the lag and retry.

The lag gate that never closes is the failure mode you want, because it is the only one that hands the cluster back intact. Lengthening maxLagWait trades a safe, reversible refusal for a longer write outage on a primary that is already fenced, without changing whether the target can catch up. (objective 9)

Question 5 of 5

Ninety seconds after an emergency failover promoted pdx, you annotate playground to plan a switchover back to iad. It lands in Failed with reason CooldownActive. What happened, and what is the correct response?

Show answer

Answer: The anti-flap cooldown gates planned admission too, and onCooldown defaults to Reject.

The same anti-flap cooldown that gates automatic promotion is evaluated in admission for planned failover, against the same durable failover record. With the default onCooldown: Reject the run ends Failed{NotStarted, CooldownActive} and the annotation is cleared — so wait out the cooldown and re-annotate, or set onCooldown: Defer to have it wait in Deferred and retry itself at admission.retryTime. The annotation was well-formed; a malformed one is rejected with a PlannedFailoverRejected Event and never becomes a run. A target that is unreachable, writable or not replicating is refused with TargetUnhealthy, a different reason. And nothing is queued by default — that is precisely what Defer opts you into. (objective 7)

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.