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

  1. Walk a site from unknown to writable, read-only or unreachable and say what each transition needs
  2. Work out the detection delay from pollInterval and failureThreshold (2s × 3 = 6s by default)
  3. Explain why read-only is entered on a single poll while writable needs recoveryThreshold successes

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:

ConstantRendered in statusMeaning
StateUnknownunknownno usable answer yet
StateWritablewritableread_only=0
StateReadOnlyread-onlyread_only=1
StateUnreachableunreachableconnection failed
FlowOne poll cycle
SELECT @@read_only per site, each under a 5 s context.WithTimeout. The cycle waits for the slowest probe.
An error increments failCount and zeroes recoveryCount. A success zeroes failCount.
unknown / writable / read-only / unreachable, from the counters plus this poll's answer.
Compare the new state against the previous one. Only a change is a transition.
Site state machine: unknown branches to writable, read-only, or unreachable. Read-only takes one poll, unreachable takes three, writable takes two. A callout says a second fault while the first is down takes 90 seconds, not 6.
One probe, four states. The arrows are not symmetric on purpose.

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.

CompareTwo transitions, opposite treatment
→ read-only→ writable
How many polls?1 — believed on the answer itselfrecoveryThreshold 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.

1 / 10

Quiz

Question 1 of 5

playground runs at defaults: pollInterval: 2s, failureThreshold: 3, recoveryThreshold: 2. All three sites have been healthy for hours. iad stops answering entirely. How long until the operator records iad as unreachable?

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)

Question 2 of 5

At defaults, how many polls does a healthy site need to be recorded as read-only, and how many to be recorded as writable?

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)

Question 3 of 5

pdx was writable. Its last two probes both failed with connection errors; failureThreshold is 3. What does the operator record as pdx's state right now?

Show answer

Answer: writablefailCount 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)

Question 4 of 5

pdx has been down for five minutes, so the poll loop has settled at its cap. iad now fails too. At defaults, how long until the operator records iad as unreachable?

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)

Question 5 of 5

reader in playground has role: read-only. It answers read_only=0 on one probe. The operator waits for recoveryThreshold consecutive writable answers before recording it as writable, exactly as it would for a primary-candidate site.

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)

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.