Cooldown, history, and the one exception
failoverCooldown suppresses far less than its name suggests. Two durable copies of lastFailover, one deliberate duplication, and the single path that restores writability inside the cooldown window.
By the end of this topic you can
- Say what
failoverCooldownsuppresses and, more importantly, what it does not - Find
lastFailoverin both places the operator writes it and say why there are two - Name the conditions that let the operator re-assert a fenced promoted primary instead of alerting
playground has just failed over. iad is gone, pdx is primary, the counter is writing again.
The table will happily select another promotion the second pdx looks unreachable. The thing that
stops that flap is memory: spec.failoverCooldown. Almost everyone overestimates its reach.
One guard, and only one
spec.failoverCooldown defaults to 5m, both in the CRD and in the operator’s own fallback when the
pointer is nil. The playground overrides it to 30s so you can experiment without waiting.
It is enforced in exactly one place: immediately before the promotion call, after the table has already chosen a candidate.
if !lastFailover.IsZero() && tm.clock.Since(lastFailover) < tm.failoverCooldown {
tm.logger.Info("failover blocked by anti-flap cooldown",
"lastFailover", lastFailover, "cooldown", tm.failoverCooldown)
return
}
One if, one return, one msg with two fields. That is the entire mechanism. The table still
evaluated, the candidate was still ranked, the status condition still says Degraded — the operator
simply declined to promote.
What it does not suppress
Here is where wrong mental models get built. Operators read “cooldown” and picture a five-minute
freeze on the group. It is not that. Every other mutating action in the poll cycle runs from its own
call site, and none of them consults tm.failoverCooldown.
| Still runs during the cooldown | Consequence for playground |
|---|---|
| Split-brain fencing | A second writable site is fenced on the next poll, not five minutes later |
| Non-promotable fencing | A writable reader is fenced every poll, unconditionally |
| Source convergence | iad, when it returns, is repointed at pdx immediately |
| Old-primary recovery | The STOP REPLICA / RESET REPLICA ALL / CHANGE REPLICATION SOURCE rejoin proceeds |
| Reclone | A reclone-site annotation is honoured while the cooldown ticks |
| DNS reconcile | The DNSEndpoint is re-applied every poll regardless |
Then the sharp edge. The ordered-update handoff promotion is not cooldown-gated at all — grep
updater.go for cooldown and you get nothing. Yet its completion callback calls recordFailover
and increments bloodraven_failovers_total. So during a rolling spec change your failover counter
can move, your dashboards can page, and the cooldown was never consulted, never logged, never
relevant. If you alert on that counter, alert on the ordered-update log lines too.
The history, written twice
lastFailover and lastFailoverTarget go to two durable places on every promotion: the CR status
subresource, and two annotations on the object’s own metadata, written together in a single JSON
merge patch.
bloodraven.shipstream.io/last-failover-targetThe duplication is deliberate, and the code says so in a comment. Status is a subresource: writes
to it travel a separate API path, with their own RBAC rule and their own admission plugins. A broken
webhook or a missing mysqlfailovergroups/status grant can silence status writes for hours while
ordinary object patches keep succeeding — and vice versa. Two independently-failing paths mean a
promotion has to lose both before the cooldown forgets it happened.
| status.lastFailover | bloodraven.shipstream.io/last-failover | |
|---|---|---|
| Which API path writes it? | The status subresource — a separate endpoint from the object itself | The object's own metadata, via a JSON merge patch on the parent resource |
| What RBAC does that need? | mysqlfailovergroups/status | mysqlfailovergroups (patch on the resource itself) |
| Which wins on rehydration? | Wins a tie — equal timestamps mean the same promotion | Wins only when strictly later than the status copy |
On restart the operator reads both and installs the later one, guarded by
FailoverClockSkewGrace = 5 * time.Minute: a copy stamped more than five minutes ahead of local time
is discarded rather than installed, because the cooldown gate treats negative elapsed time as still
active and a future-dated record would wedge promotion indefinitely. Ties go to status. That tie rule
is why the annotation is written at second precision — matching what metav1.Time serialises — so
the same promotion produces an exact tie rather than an annotation that always looks newer.
The one exception: re-asserting a fenced primary
There is a wedge the pure table cannot escape. Every site is read-only, none is unreachable, so the
table refuses to elect and raises NoPrimary. But the operator holds history the table refuses to
consult: lastFailoverTarget names the site it already made authoritative. If that site is still
GTID-complete, restoring its writability cannot lose a transaction or create a second primary.
Every one of these must hold:
- No subsystem gate active — bootstrap blocking cross-site, ordered update, topology frozen, planned failover in flight.
- The re-assert rate limit is satisfied, and no promotion is still pending confirmation.
- Every non-target peer is
read-only— not writable, not unreachable, not unknown. - The target is
read-onlyand promotable (role: primary-candidate). - The target’s
GTID_EXECUTEDcontains the recorded promotion GTID set and every peer’sGTID_EXECUTED.
Fail the GTID parse and the operator does not fall through — it refuses:
primary re-assert refused: recorded promotion GTID set failed to parse — status corrupted or manually edited?
The safety argument rests on that recorded invariant being trustworthy. An unreadable invariant is not a satisfied one, so skipping the gate would be exactly backwards. If you see this line, someone edited status by hand.
On success, verbatim, at WARN:
re-asserting fenced promoted primary: no site is writable and the last failover target is GTID-complete; restoring writability
with field site, followed by bloodraven_primary_reassert_total{site}. A steadily climbing counter
means something keeps fencing your primary — look at sidecar connectivity, not at the operator.
The timing that surprises people
The re-assert rate limit reuses the failoverCooldown duration, but measures it against a
separate timer, lastReassert, which is never compared against lastFailover. Concretely: on
the shipped 5 m default, pdx is promoted at T+0, the promotion takes the measured 12.0 s, and at
T+2m the whole group goes read-only. A second automatic promotion is blocked — 2 m is less than 5 m.
A re-assert is not, because lastReassert is still zero. On the playground’s 30s cooldown the
same asymmetry compresses: automatic promotion is blocked until T+30s (leaving 30 − 12.0 = 18.0 s of
block after a 12.0 s failover), while the first re-assert is available immediately.
One last field while you are here, because its history is a lesson in itself. spec.updateStrategy
takes OrderedUpdate (the default) or Recreate, and it does two different things to the same spec
change. Under OrderedUpdate the reconciler deliberately leaves existing site Deployments alone; the
runner then notices the drift between the desired spec hash and the live Deployment annotation and
hands the rollout to the ordered updater, one site at a time. Under Recreate the runner clears the
drift list outright and the reconciler patches every site Deployment in one pass, so their pod
restarts may overlap — which is exactly the both-sites-down window OrderedUpdate exists to avoid.
Reach for Recreate only when you are certain you can afford to lose the primary and the standby at
the same moment.
Where that leaves you
You can now look at a decision the table selected and say whether it will actually execute right now:
promotion is gated, everything else is not, and the ordered-update handoff sidesteps the gate while
still moving the counter. You can find the failover history in both durable places and say which one
a restarted operator believed. What you cannot yet do is watch it happen — the next topic follows one
poll cycle through the structured log, using the msg strings the operator promises not to change.
Flashcards
spec.failoverCooldown — default, and what the playground uses
5m by default (CRD default and the operator's own fallback when the field is nil). The playground manifest overrides it to 30s for fast experimentation.
Where in the poll cycle is the anti-flap cooldown actually enforced?
In exactly one place: an if immediately before the promotion call, after the cross-site table has already chosen a candidate. Nothing else in the poll consults it.
The log line the operator emits when the cooldown blocks a promotion
failover blocked by anti-flap cooldown, at INFO, with fields lastFailover and cooldown.
Name three mutating subsystems that keep running while the cooldown is ticking
Source convergence, old-primary recovery, and reclone — each runs from its own poll call site and never reads failoverCooldown. (DNS reconcile and both fencing paths are in the same group.)
The two annotation keys that carry the failover history on the object itself
bloodraven.shipstream.io/last-failover (the instant, RFC3339 UTC at second precision) and bloodraven.shipstream.io/last-failover-target (the promoted site name), written together in one JSON merge patch.
Why is the failover record written to both status and annotations?
Status is a subresource: its writes travel a separate API path with their own RBAC rule (mysqlfailovergroups/status) and admission chain, so one path can be broken or denied while the other still records the promotion.
FailoverClockSkewGrace
5 * time.Minute — a durable copy stamped more than five minutes ahead of local time is discarded rather than installed, because the cooldown gate reads negative elapsed time as still active.
The verbatim msg the operator logs when it restores writability on a fenced promoted primary
re-asserting fenced promoted primary: no site is writable and the last failover target is GTID-complete; restoring writability, at WARN, with field site.
Which metric counts primary re-asserts, and what does a climbing value mean?
bloodraven_primary_reassert_total{site} — a steadily increasing counter means something keeps fencing the promoted primary, so investigate sidecar connectivity to the operator.
spec.updateStrategy
OrderedUpdate (default) leaves existing site Deployments untouched so the runner sees spec drift and rolls one site at a time; Recreate clears the drift list and patches every site Deployment in one pass, so pod restarts may overlap.
Quiz
Show answer
Answer: False
The reversal: the cooldown gates promotion and nothing else, so split-brain fencing is not delayed by it at all. iad is fenced on the next poll, from its own cross-site call site, which never reads failoverCooldown. The tempting model — cooldown as a five-minute freeze on the whole group — also predicts wrongly for source convergence, old-primary recovery, reclone and DNS reconcile, all of which keep running. (objective 7)
The reversal: the cooldown gates promotion and nothing else, so split-brain fencing is not delayed by it at all. iad is fenced on the next poll, from its own cross-site call site, which never reads failoverCooldown. The tempting model — cooldown as a five-minute freeze on the whole group — also predicts wrongly for source convergence, old-primary recovery, reclone and DNS reconcile, all of which keep running. (objective 7)
Show answer
Answer: The ordered-update handoff promoted pdx; that path calls recordFailover and increments the counter but is never cooldown-gated, so the guard was never reached
The ordered-update handoff is a separate promotion path with no cooldown check anywhere in it, yet its completion callback stamps the durable failover record and increments bloodraven_failovers_total — so the counter moves without the guard ever being consulted, which is also why there is no log line. Option 2 would produce a rehydration warning and requires a restart you did not observe; rehydration also prefers the later of the two durable copies rather than emptying them. Option 3 is wrong: the counter is incremented after a successful promotion, not on attempts. Option 4 invents a coupling that does not exist: spec.updateStrategy decides whether a spec change rolls one site at a time or all at once, and touches nothing about anti-flap. (objective 7)
The ordered-update handoff is a separate promotion path with no cooldown check anywhere in it, yet its completion callback stamps the durable failover record and increments bloodraven_failovers_total — so the counter moves without the guard ever being consulted, which is also why there is no log line. Option 2 would produce a rehydration warning and requires a restart you did not observe; rehydration also prefers the later of the two durable copies rather than emptying them. Option 3 is wrong: the counter is incremented after a successful promotion, not on attempts. Option 4 invents a coupling that does not exist: spec.updateStrategy decides whether a spec change rolls one site at a time or all at once, and touches nothing about anti-flap. (objective 7)
Show answer
Answer: The annotation copy at 10:06:00Z, because rehydration takes whichever copy is stamped later
Rehydration takes the later of the two, because the two paths fail independently: the annotation is ahead precisely when the status write was rejected. 10:06:00Z is only one minute ahead of local time, well inside FailoverClockSkewGrace of 5m, so it is plausible and gets installed. Option 1 inverts the design — neither copy outranks the other, status only wins an exact tie. Option 3 describes the failure the duplication exists to prevent: discarding history is what resets a cooldown and lets a promotion happen inside the window. Option 4 is invented; there is no averaging, only a later-wins comparison with a future-date guard. (objective 8)
Rehydration takes the later of the two, because the two paths fail independently: the annotation is ahead precisely when the status write was rejected. 10:06:00Z is only one minute ahead of local time, well inside FailoverClockSkewGrace of 5m, so it is plausible and gets installed. Option 1 inverts the design — neither copy outranks the other, status only wins an exact tie. Option 3 describes the failure the duplication exists to prevent: discarding history is what resets a cooldown and lets a promotion happen inside the window. Option 4 is invented; there is no averaging, only a later-wins comparison with a future-date guard. (objective 8)
Show answer
Answer:
It refuses the re-assert. It logs primary re-assert refused: recorded promotion GTID set failed to parse — status corrupted or manually edited? at WARN and returns without touching MySQL, leaving the group on its NoPrimary alert for a human. It does not treat the unparseable value as absent and skip the GTID gate. The whole safety argument for re-asserting a fenced primary is that the target's GTID_EXECUTED provably contains the recorded promotion GTID set and every peer's set; if the recorded invariant cannot be read, it cannot have been verified, so proceeding would restore writability on a site that might be missing transactions.
A full-credit answer shows: A strong answer covers: (1) refuse, not skip — the operator returns without mutating MySQL; (2) the reason is that the operator itself wrote that value from MySQL, so a parse failure means corruption or manual tampering; (3) the safety argument depends on the recorded invariant being trustworthy, so an unreadable invariant is not a satisfied one; (4) the consequence is that the group stays wedged and alerting until a human intervenes. Bonus: naming the log msg, or noting that skipping the gate would be the dangerous inversion.
The refusal is the point. A parse failure is evidence that status has been corrupted or edited, and the re-assert is only safe because the recorded promotion GTID set can be checked against the target's live GTID_EXECUTED. Treating an unreadable value as 'no constraint' would convert the strongest gate into no gate at exactly the moment the data is least trustworthy. (objective 9)
Sample answer
It refuses the re-assert. It logs primary re-assert refused: recorded promotion GTID set failed to parse — status corrupted or manually edited? at WARN and returns without touching MySQL, leaving the group on its NoPrimary alert for a human. It does not treat the unparseable value as absent and skip the GTID gate. The whole safety argument for re-asserting a fenced primary is that the target's GTID_EXECUTED provably contains the recorded promotion GTID set and every peer's set; if the recorded invariant cannot be read, it cannot have been verified, so proceeding would restore writability on a site that might be missing transactions.
A full-credit answer shows
A strong answer covers: (1) refuse, not skip — the operator returns without mutating MySQL; (2) the reason is that the operator itself wrote that value from MySQL, so a parse failure means corruption or manual tampering; (3) the safety argument depends on the recorded invariant being trustworthy, so an unreadable invariant is not a satisfied one; (4) the consequence is that the group stays wedged and alerting until a human intervenes. Bonus: naming the log msg, or noting that skipping the gate would be the dangerous inversion.
The refusal is the point. A parse failure is evidence that status has been corrupted or edited, and the re-assert is only safe because the recorded promotion GTID set can be checked against the target's live GTID_EXECUTED. Treating an unreadable value as 'no constraint' would convert the strongest gate into no gate at exactly the moment the data is least trustworthy. (objective 9)
Show answer
Answer: False
The reversal: the re-assert can fire right now. It reuses the failoverCooldown duration as its rate limit, but measures it against a separate timer, lastReassert, which is never compared against lastFailover. Two minutes after a failover, lastReassert is still zero, so the rate limit is satisfied even though an automatic promotion would be blocked for another three minutes. Assuming one shared clock is the common wrong model, and it makes the re-assert look impossible exactly when it is the thing rescuing the group. (objectives 7, 9)
The reversal: the re-assert can fire right now. It reuses the failoverCooldown duration as its rate limit, but measures it against a separate timer, lastReassert, which is never compared against lastFailover. Two minutes after a failover, lastReassert is still zero, so the rate limit is satisfied even though an automatic promotion would be blocked for another three minutes. Assuming one shared clock is the common wrong model, and it makes the re-assert look impossible exactly when it is the thing rescuing the group. (objectives 7, 9)