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

  1. Walk a site from Unknown to Writable, ReadOnly 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 ReadOnly 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
StateReadOnlyReadOnlyread_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 / ReadOnly / 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 still down is still detected in 6 seconds, because backoff is per site.
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 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.

CompareTwo transitions, opposite treatment
→ ReadOnly→ 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, 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.

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 ReadOnly, and how many to be recorded as Writable?

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)

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

Question 4 of 5

pdx has been down for five minutes, so its probes have backed off to the 30 s cap. iad now fails too. At defaults, how long until the operator records iad as Unreachable?

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)

Question 5 of 5

reader in playground has role: ReadOnly. 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 PrimaryCandidate 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.