The poll loop and per-site state
Every two seconds, one SELECT @@read_only per site. Four states, two debounce counters that behave asymmetrically on purpose, and an adaptive backoff that quietly rewrites your detection budget.
By the end of this topic you can
- Walk a site from
unknowntowritable,read-onlyorunreachableand say what each transition needs - Work out the detection delay from
pollIntervalandfailureThreshold(2s × 3 = 6s by default) - Explain why
read-onlyis entered on a single poll whilewritableneedsrecoveryThresholdsuccesses
playground is healthy. iad is writable. pdx and reader are read-only. The counter is
clicking. This is the only calm moment you will get to ask: what is the operator doing in the
two seconds between one status read and the next?
The loop, the probe, the four states
spec.pollInterval defaults to 2s, and the operator hard-defaults to 2 s again in Go when the
field’s pointer is nil — so an omitted pollInterval is 2 s, not zero and not “never”. Every tick,
the loop does the same four things: probe every site, apply the debounce counters, compute each
site’s state, then look for transitions.
The probe is one statement. SELECT @@read_only. Not a ping, not a replication check, not a
SELECT 1 — one server variable, read over the connection the operator already holds. Every site is
probed in parallel, each under its own context.WithTimeout(ctx, 5*time.Second). Two consequences
follow, and you want both in your head: a dark site cannot cost the cycle more than 5 s, and a slow
site does not delay its healthy peers’ probes — but the cycle itself does not finish until the
slowest probe returns.
That single boolean, plus whether the query returned an error at all, is the entire input to the state machine. There are four states, and they are exactly these:
| Constant | Rendered in status | Meaning |
|---|---|---|
StateUnknown | unknown | no usable answer yet |
StateWritable | writable | read_only=0 |
StateReadOnly | read-only | read_only=1 |
StateUnreachable | unreachable | connection failed |
The artifact: the three knobs, and the function they feed
Nothing in playground changed since you stood it up. What changed is that three lines of that spec are
now load-bearing rather than boilerplate:
# playground — unchanged since you stood it up; these three lines are the subject now
spec:
pollInterval: 2s # <-- the tick
failureThreshold: 3 # <-- consecutive failures before "unreachable"
recoveryThreshold: 2 # <-- consecutive writable answers before "writable"
They land in one function, and reading it is faster than reading any prose about it:
func (tm *TopologyManager) computeState(site *siteTracker, readOnly bool, err error) state.SiteState {
if err != nil {
site.recoveryCount = 0
site.failCount++
if site.failCount >= tm.cfg.FailureThreshold {
return state.StateUnreachable
}
return site.state // not enough failures yet, keep current state
}
// Successful poll.
site.failCount = 0
if readOnly {
site.recoveryCount = 0
return state.StateReadOnly
}
// read_only=0 (writable)
if site.state != state.StateWritable {
site.recoveryCount++
if site.recoveryCount >= tm.cfg.RecoveryThreshold {
return state.StateWritable
}
return site.state // not enough recoveries yet
}
return state.StateWritable
}
Both counters reset on the opposite outcome, so “three failures” means three consecutive failures.
One good answer in the middle puts failCount back to zero and you start again.
The asymmetry, and the number people get wrong
Now look at what that function believes instantly and what it makes prove itself. A read_only=1
answer becomes read-only on a single poll — no counter, no waiting — and it zeroes
recoveryCount on the way through. A read_only=0 answer from a site that is not already writable
becomes writable only after recoveryThreshold consecutive successes. A connection error becomes
unreachable only after failureThreshold consecutive failures.
| → read-only | → writable | |
|---|---|---|
| How many polls? | 1 — believed on the answer itself | recoveryThreshold consecutive polls (default 2) |
| Which counter? | None. It resets recoveryCount to 0. | recoveryCount, incremented then tested against the threshold |
| Why that choice? | Read-only is the safe direction: a site that cannot take writes cannot cause divergence, so believing it early costs nothing. | Writable is the dangerous direction: a site wrongly believed writable is a second authority. Make it prove itself. |
One logic, two directions. Read-only is cheap to be wrong about; writable is expensive.
Which gives you the derivation everyone reaches for and half of everyone gets wrong. Detection delay
for a dead site is pollInterval × failureThreshold = 2 s × 3 = 6 s. Three probes, one every
2 s, and the third one returns unreachable. recoveryThreshold is not a term in that sum. It
never was. It gates the opposite transition — the way back to writable — and adding it to get 10 s
is the single most common wrong answer about this operator. The 5 s probe ceiling is not a term
either; it bounds one probe, it does not pace the loop.
The backoff that changes your detection budget
Here is the part that surprises people at 3 a.m., because the 6 s figure everyone memorises is only
the first fault’s. The poll interval is adaptive. Once any site’s failCount climbs past failureThreshold, the interval doubles per
extra failure — interval := base * time.Duration(1<<uint(backoffFails)) — with the exponent capped
at maxPollBackoffExponent = 4 and a 30 s hard cap on top.
Walk it at defaults. failCount 3 gives no backoff; 4 → 2 s × 2 = 4 s; 5 → 8 s; 6 → 16 s; 7 → 2 s ×
2⁴ = 32 s, which the 30 s cap clips to 30 s. So roughly half a minute into a site being down, the
loop has settled at 30 s.
And it is one loop, driven by the worst failCount across all sites. That is the operational
consequence you came for. pdx has been down for five minutes, so the loop is polling every 30 s —
including its probes of iad, which is fine. Now iad dies. Detection needs three consecutive
failures, and they now arrive 30 s apart: 30 s × 3 = 90 s, not 6 s. Fifteen times slower, on the
site that matters, at exactly the moment you have no spare site left. The backoff is trading
detection latency for polling waste, and one existing outage spends that trade on every other site.
This is design rather than defect, and worth putting in your runbook beside the 6 s figure — the
failover.mdx page states the same bound and tells you to use it for alerting and compound-failure
recovery estimates. Take the pair: 6 s for the first fault, up to 90 s for a second one while the
first is still down. A single number is the wrong shape for this answer.
One bypass
Last thing. A writable observation on a non-promotable site — anything whose role is not
primary-candidate, so reader in playground — skips recoveryThreshold entirely and records
writable on the first poll. The comment in the code is the whole argument: a writable
non-promotable site is an immediate safety fact, and authority invalidation must not be debounced
behind the normal recovery threshold. Being slow to believe a new primary is prudence. Being slow to
notice a reader taking writes is not.
You can now take any sequence of probe results for one site and say which state it lands in and
after how many seconds, including under backoff. What you cannot yet say is what the operator does
with three states at once — because iad unreachable while pdx and reader are read-only is not
three independent facts, it is one row in a table. That table is next.
Flashcards
spec.pollInterval — default, and what the operator does when the field is unset
Default 2s; the operator also hard-defaults to 2 s in Go when the pointer is nil, so an omitted pollInterval still ticks every 2 s.
spec.failureThreshold — what it counts, and its default
Consecutive failed probes before a site becomes unreachable. Default 3 — and one successful probe resets failCount to 0, so they must be consecutive.
spec.recoveryThreshold — which transition it gates, and its default
Only the transition to writable: that many consecutive read_only=0 answers. Default 2.
You need a dead primary noticed faster. Which of failureThreshold and recoveryThreshold do you touch?
failureThreshold — it is the only one of the two in the detection-delay sum. recoveryThreshold gates the opposite transition, back to writable.
The SQL statement the poll runs against each site
SELECT @@read_only — one server variable, nothing else.
The per-site probe timeout, and how sites are probed
5 s (context.WithTimeout(ctx, 5*time.Second)), with every site probed in parallel.
The four per-site state constants
StateUnknown, StateWritable (read_only=0), StateReadOnly (read_only=1), StateUnreachable (connection failed).
When does the adaptive poll backoff start, and what is the doubling rule?
Once a site's failCount goes past failureThreshold; the interval then doubles per extra failure via interval := base * time.Duration(1<<uint(backoffFails)).
The two ceilings on the adaptive poll backoff
maxPollBackoffExponent = 4 on the exponent, and a 30 s hard cap on the resulting interval.
Whose failCount sets the poll interval — the failing site's, or the whole group's?
There is one loop, and its interval comes from the worst failCount across all sites, so one site's outage slows the probing of every healthy site too.
Quiz
Show answer
Answer: 6 s
Detection delay is pollInterval × failureThreshold = 2 s × 3 = 6 s: three probes 2 s apart, and the third returns unreachable. 10 s is the classic wrong derivation — it adds recoveryThreshold into the sum (2 s × (3 + 2)); recoveryThreshold gates the opposite transition, back to writable, and is not a term here. 4 s uses recoveryThreshold instead of failureThreshold (2 s × 2), the same confusion in the other direction. 15 s comes from mistaking the 5 s per-site context.WithTimeout for the loop's pace — that ceiling bounds one probe, it does not set the interval between polls. (objective 2)
Detection delay is pollInterval × failureThreshold = 2 s × 3 = 6 s: three probes 2 s apart, and the third returns unreachable. 10 s is the classic wrong derivation — it adds recoveryThreshold into the sum (2 s × (3 + 2)); recoveryThreshold gates the opposite transition, back to writable, and is not a term here. 4 s uses recoveryThreshold instead of failureThreshold (2 s × 2), the same confusion in the other direction. 15 s comes from mistaking the 5 s per-site context.WithTimeout for the loop's pace — that ceiling bounds one probe, it does not set the interval between polls. (objective 2)
Show answer
Answer: 1 poll to read-only; 2 consecutive polls to writable
read_only=1 returns StateReadOnly on the answer itself with no counter involved, while read_only=0 from a non-writable site increments recoveryCount and only returns StateWritable at recoveryThreshold (default 2). Option 2 inverts the asymmetry — it treats the dangerous direction as the cheap one, which is exactly backwards: read-only is the safe direction, so it is believed instantly. Option 3 borrows failureThreshold: 3 for the read-only transition, but 3 gates unreachable, not read-only. Option 4 drops the debounce entirely, which would let one stale or mid-restart read_only=0 mint a second authority. (objective 3)
read_only=1 returns StateReadOnly on the answer itself with no counter involved, while read_only=0 from a non-writable site increments recoveryCount and only returns StateWritable at recoveryThreshold (default 2). Option 2 inverts the asymmetry — it treats the dangerous direction as the cheap one, which is exactly backwards: read-only is the safe direction, so it is believed instantly. Option 3 borrows failureThreshold: 3 for the read-only transition, but 3 gates unreachable, not read-only. Option 4 drops the debounce entirely, which would let one stale or mid-restart read_only=0 mint a second authority. (objective 3)
Show answer
Answer: writable — failCount is 2, and computeState returns the site's current state until the threshold is met
Below the threshold, computeState increments failCount and returns site.state unchanged — so pdx is still writable, and a third consecutive failure is what flips it. Option 2 is the debounce misread: the threshold decides the state, it is not a reporting filter. Option 3 is wrong because unknown means no usable answer has ever been obtained, not that the last one failed; the operator holds its previous belief instead. Option 4 confuses two different facts — read-only is only ever recorded from a successful probe returning read_only=1, and a failed probe carries no @@read_only value at all. Note the third probe would also reset recoveryCount, which each failure has already been doing. (objective 1)
Below the threshold, computeState increments failCount and returns site.state unchanged — so pdx is still writable, and a third consecutive failure is what flips it. Option 2 is the debounce misread: the threshold decides the state, it is not a reporting filter. Option 3 is wrong because unknown means no usable answer has ever been obtained, not that the last one failed; the operator holds its previous belief instead. Option 4 confuses two different facts — read-only is only ever recorded from a successful probe returning read_only=1, and a failed probe carries no @@read_only value at all. Note the third probe would also reset recoveryCount, which each failure has already been doing. (objective 1)
Show answer
Answer: 90 s
The adaptive backoff is driven by the worst failCount across all sites, so pdx's sustained outage has pushed the single loop to its 30 s hard cap. iad still needs failureThreshold = 3 consecutive failures, and they now arrive 30 s apart: 30 s × 3 = 90 s. 6 s is the healthy-cluster answer (2 s × 3) and assumes the interval is still the base — the trap the backoff sets, because it is undocumented and cluster-wide rather than per-site. 30 s is the interval itself, mistaken for the detection delay; the threshold still needs three of them. 12 s reads the 30 s cap as if only the failing site backed off while iad kept some faster cadence — there is one loop, not one per site. (objective 2)
The adaptive backoff is driven by the worst failCount across all sites, so pdx's sustained outage has pushed the single loop to its 30 s hard cap. iad still needs failureThreshold = 3 consecutive failures, and they now arrive 30 s apart: 30 s × 3 = 90 s. 6 s is the healthy-cluster answer (2 s × 3) and assumes the interval is still the base — the trap the backoff sets, because it is undocumented and cluster-wide rather than per-site. 30 s is the interval itself, mistaken for the detection delay; the threshold still needs three of them. 12 s reads the 30 s cap as if only the failing site backed off while iad kept some faster cadence — there is one loop, not one per site. (objective 2)
Show answer
Answer: False
The reversal: a non-promotable site is the one case that skips recoveryThreshold entirely and is recorded writable on the first successful writable observation. The debounce exists to stop a flapping candidate being wrongly believed writable, which is a promotion-safety concern; a writable reader is not a candidate for anything, it is an immediate safety fact — authority is now ambiguous — and the code refuses to debounce authority invalidation. The tempting wrong model is 'one debounce rule, applied uniformly'; role is a term in this transition. (objective 1)
The reversal: a non-promotable site is the one case that skips recoveryThreshold entirely and is recorded writable on the first successful writable observation. The debounce exists to stop a flapping candidate being wrongly believed writable, which is a promotion-safety concern; a writable reader is not a candidate for anything, it is an immediate safety fact — authority is now ambiguous — and the code refuses to debounce authority invalidation. The tempting wrong model is 'one debounce rule, applied uniformly'; role is a term in this transition. (objective 1)