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

  1. Say what failoverCooldown suppresses and, more importantly, what it does not
  2. Find lastFailover in both places the operator writes it and say why there are two
  3. 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 cooldownConsequence for playground
Split-brain fencingA second writable site is fenced on the next poll, not five minutes later
Non-promotable fencingA writable reader is fenced every poll, unconditionally
Source convergenceiad, when it returns, is repointed at pdx immediately
Old-primary recoveryThe STOP REPLICA / RESET REPLICA ALL / CHANGE REPLICATION SOURCE rejoin proceeds
RecloneA reclone-site annotation is honoured while the cooldown ticks
DNS reconcileThe 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.

Anatomybloodraven.shipstream.io/last-failover-target
domain prefixbloodraven.shipstream.io/
Namespaces the key to this operator. Everything Bloodraven stamps on an object — planned failover, reclone, chaos markers — shares it.
record nounlast-failover
Names the promotion event. On its own this is the sibling key bloodraven.shipstream.io/last-failover, whose value is the promotion instant as RFC3339 UTC at second precision.
target qualifier-target
Switches the value from the instant to the promoted site name, verbatim. Never written alone — both keys go in one JSON merge patch, so a reader never sees a timestamp without its target.

The 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.

CompareTwo durable copies of the same fact
status.lastFailoverbloodraven.shipstream.io/last-failover
Which API path writes it?The status subresource — a separate endpoint from the object itselfThe object's own metadata, via a JSON merge patch on the parent resource
What RBAC does that need?mysqlfailovergroups/statusmysqlfailovergroups (patch on the resource itself)
Which wins on rehydration?Wins a tie — equal timestamps mean the same promotionWins 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:

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.

1 / 10

Quiz

Question 1 of 5

playground failed over to pdx ninety seconds ago and spec.failoverCooldown is the default 5m. A stale iad now comes back writable, giving two writable sites. The operator will leave both writable until the cooldown expires.

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)

Question 2 of 5

During a rolling image change on playground, bloodraven_failovers_total increments for pdx. The last emergency failover was ninety seconds earlier, spec.failoverCooldown is 5m, and no failover blocked by anti-flap cooldown line appears anywhere in the operator log. What happened?

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)

Question 3 of 5

The operator restarts. status.lastFailover reads 10:04:00Z, the bloodraven.shipstream.io/last-failover annotation reads 10:06:00Z, and the local clock is 10:07:00Z. Which record does the restarted operator install as its cooldown baseline?

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)

Question 4 of 5

Every site in playground is read-only, no site is unreachable, and lastFailoverTarget names pdx. All the re-assert preconditions hold except one: the promotion GTID recorded in status was hand-edited during an incident and no longer parses. What does the operator do, and why is that the right behaviour?

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)

Question 5 of 5

With spec.failoverCooldown at the shipped 5m, playground failed over to pdx two minutes ago and every site is now read-only. A primary re-assert cannot fire until the five minutes are up.

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)

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.