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
- Trigger a planned failover with the annotation and follow the phases through to
Succeeded - Explain why planned failover is RPO 0 by construction and what the lag gate actually compares
- 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.
kubectl -n bloodraven-playground annotate mysqlfailovergroup playground bloodraven.shipstream.io/planned-failover=pdxmysqlfailovergroup.shipstream.io/playground annotatedkubectl -n bloodraven-playground get mysqlfailovergroup playground -o jsonpath='{.status.plannedFailover.phase}'WaitingForLagkubectl -n bloodraven-playground get mysqlfailovergroup playground -o jsonpath='{.status.plannedFailover.phase} {.status.plannedFailover.result.outcome}'Succeeded SucceededLeave 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.
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.
| Outcome | When | Source afterwards | Who resolves it |
|---|---|---|---|
NotStarted | Refused or timed out in admission (CooldownActive, TargetNotPromotable, AdmissionTimeout) | Never fenced | Nobody — re-annotate later |
RolledBack | Failed before any write to the target (LagTimeout, DivergentTarget, FenceFailed, TargetLost, PromotionAborted) | Unfenced, still the primary | Nobody — nothing was lost |
SourceLeftFenced | Nothing was written to the target, but unfencing was unsafe (another site writable, a fence it cannot prove is its own, a lost source) | Still fenced | Often the operator’s re-assert; sometimes a human |
PromotedUnconfirmed | A write may have reached the target and it could not be confirmed | Fenced; the target may be serving | A human, with the runbook |
ManualRecoveryRequired | A write may have reached the target and the result cannot be explained | Unknown | A 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.
Recite the planned-failover phases in order.
Admitting, Deferred, Fencing, Draining, WaitingForLag, WaitingForDragonflySync, PromotingDragonfly, Promoting, Publishing, RollingBack, Succeeded, Failed. There is no idle phase: a group with no run has no block.
status.plannedFailover.fence.gtidSet — what is it?
The source primary's GTID_EXECUTED, recorded immediately after super_read_only=ON took effect on it and was read back (fence.state: Applied).
Why does the operator fence the source before snapshotting its GTID, rather than after?
Because a fenced primary accepts no new client writes, so the GTID set the target must catch up to cannot grow underneath the gate.
After a successful planned switchover, what shows that nothing acknowledged was lost?
result.outcome: Succeeded, and status.lastFailover.promotionGtidExecuted equal to status.plannedFailover.fence.gtidSet. There is no lost-transaction counter on this path: the gate makes the number zero by construction.
spec.plannedFailover.maxLagWait — default value and what it bounds.
5m. It bounds time spent in WaitingForLag before the state machine rolls back to the source. Admission accepts 1s to 1h, for the field and for the annotation's maxLagWait= override alike.
spec.plannedFailover.drainTimeout — default value and what it bounds.
30s. It bounds how long the fenced source gets to shed application connections during Draining.
spec.plannedFailover.onCooldown — default value and its effect.
Reject: a cooldown hit in admission ends the run Failed{NotStarted, CooldownActive} and clears the annotation, so an admin must re-annotate after the cooldown expires.
What changes if you set onCooldown: Defer?
The request enters Deferred with admission.deferReason: CooldownActive and admission.retryTime stamped, keeps the annotation, and admission is retried automatically at cooldown expiry.
Verbatim refusal when a planned failover targets a role: ReadOnly site.
only PrimaryCandidate sites may be promoted.
What happens to the source's -primary Service endpoint once the run fences it?
From Fencing on, the source's role label is stripped to fenced, which matches neither the -primary nor the -replicas selector, so the write Service sheds its endpoint. Established connections are left to Draining.
Confusable pair: maxLagSeconds versus maxLagWait.
spec.replication.maxLagSeconds (default 300) drives only the ReplicationLagging Degraded condition; spec.plannedFailover.maxLagWait (default 5m) is the timeout on the GTID superset gate.
Quiz
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)
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)
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)
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)
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)
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)
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)
Sample 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)
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)
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)