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 a per-site backoff that slows only the site that stays down.
By the end of this topic you can
- Walk a site from
UnknowntoWritable,ReadOnlyorUnreachableand say what each transition needs - Work out the detection delay from
pollIntervalandfailureThreshold(2s × 3 = 6s by default) - Explain why
ReadOnlyis 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 | ReadOnly | 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 ReadOnly 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.
| → ReadOnly | → 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, and what it costs
A site that stays down is not probed at full rate forever. Once its failCount climbs past
failureThreshold, the wait before its next probe 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, it is
probed every 30 s. On the polls in between it is skipped, and it keeps the state it had.
The backoff is per site, and that is the fact to carry. The loop itself still ticks every
pollInterval and probes every other site on every tick. pdx has been down for five minutes and is
probed every 30 s; iad is still probed every 2 s. Now iad dies. Detection needs three consecutive
failures, 2 s apart: 6 s, exactly as if nothing else were wrong. A second fault, at the moment you
have no spare site left, is detected as fast as the first.
What the backoff costs is the way back. When pdx returns, its next probe can be up to 30 s away, so
the operator can take that long to notice — to rejoin it, or to see that it came back writable. The
sidecar’s fencing rules cover a returning old primary in that window, not the operator’s poll. The
skip also saves the loop real time: every poll waits for its slowest probe, so a site that hangs
rather than refusing costs each poll its full 5 s ceiling — but only until it has been declared
Unreachable and its probes start backing off.
Put the pair in your runbook: 6 s to detect any site’s failure, up to 30 s to notice a long-down site has come back. A single number is the wrong shape for this answer. If an older runbook says a second fault takes up to 90 s, it describes a shared backoff the operator no longer uses — see the version appendix, row A2.
One bypass
Last thing. A writable observation on a non-promotable site — anything whose role is not
PrimaryCandidate, 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 a site's probe backoff start, and what is the doubling rule?
Once that site's failCount goes past failureThreshold; the wait before its next probe then doubles per extra failure via interval := base * time.Duration(1<<uint(backoffFails)). The loop and every other site keep pollInterval.
The two ceilings on a site's probe backoff
maxPollBackoffExponent = 4 on the exponent, and a 30 s hard cap on the resulting interval.
Whose failCount sets the probe interval — the failing site's, or the whole group's?
The failing site's own. The loop ticks every pollInterval, and a site's failCount backs off only that site's probes, so every healthy site is still probed every 2 s and a second fault is still detected in 6 s.
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 ReadOnly; 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 ReadOnly. 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 ReadOnly. 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 — ReadOnly 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 — ReadOnly 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: 6 s
The backoff is per site. pdx's sustained outage has backed off only pdx's probes, to the 30 s cap; the loop still ticks every 2 s and probes iad on every tick. iad needs failureThreshold = 3 consecutive failures, 2 s apart: 2 s × 3 = 6 s, the same as in a healthy group. 90 s is 30 s × 3, the answer for a shared backoff that slows every site's probes; the operator does not work that way. 30 s is pdx's probe interval, mistaken for iad's detection delay. 12 s adds a backoff step to iad, which has no failures yet and so no backoff. (objective 2)
The backoff is per site. pdx's sustained outage has backed off only pdx's probes, to the 30 s cap; the loop still ticks every 2 s and probes iad on every tick. iad needs failureThreshold = 3 consecutive failures, 2 s apart: 2 s × 3 = 6 s, the same as in a healthy group. 90 s is 30 s × 3, the answer for a shared backoff that slows every site's probes; the operator does not work that way. 30 s is pdx's probe interval, mistaken for iad's detection delay. 12 s adds a backoff step to iad, which has no failures yet and so no backoff. (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)