Connection pools that survive a promotion

Why your application went on reading stale data from the demoted site while its very next write failed outright, why nothing paged for either half, and the three-part fix that is not a bigger pool.

By the end of this topic you can

  1. Explain why an open pooled connection keeps serving stale reads after a correct failover
  2. Choose a failover strategy — taint-based, Service-based, or site-local warm standby — for a given application
  3. Fix a pool with bounded connection lifetime, error-class retry, and a read/write split

Unit 3 left you standing in front of a wall. You held iad down, the operator promoted pdx in 12.0 seconds measured from the kill, the DNSEndpoint A record moved, the mysql-playground-primary selector moved to the surviving site, and every status field on playground said exactly what it should. And the counter application went on serving successful, stale reads out of the demoted site — while the first write anyone attempted came back ERROR 1290 — with nothing anywhere paging.

Nothing in that list was broken. The failover was correct. Your application was broken, in two different ways at once.

Split panel. The Service selector now points at PDX. An old TCP socket is still drawn to IAD, which is stamped super_read_only. A read arrow succeeds with stale data. A write arrow hits ERROR 1290.
Promotion worked. The socket did not move.

The mechanism is small enough to state in three sentences.

Three sentences

One: a Service selector flip changes routing for new connections only. The -primary Service selects on two labels — instance and role: primary. When the operator relabels, kube-proxy starts sending new connections to the new backend. It picked the backend for your existing flows at connection establishment, and it does not re-evaluate them and does not reset them. Every pooled connection your application opened before the promotion still points at the same pod it always did.

Two: super_read_only blocks writes but closes no sockets. Fencing is SET GLOBAL super_read_only = ON — a variable change, not a disconnection. MySQL’s own manual is blunt about what the statement does: it blocks while other clients have an ongoing statement or commit, and waits for them; it never cuts a writer off. So a surviving session keeps serving reads until the site is next promoted or demoted. Those reads succeed, return plausible data, and are wrong by however far the demoted site has drifted.

Three: the operator’s mitigations all need a reachable old primary, and yours was not. Step 2 of the failover sequence calls KillAppConnections: SELECT id FROM information_schema.processlist WHERE id != CONNECTION_ID() AND command NOT IN ('Binlog Dump', 'Binlog Dump GTID'), then KILL on each id. It kills everything except itself and the binlog dump threads. After promotion, the operator keeps going: each topology poll makes one more bounded eviction pass against the fenced former primary until a pass finds no sessions or spec.connectionDrainTimeout (default 30s) expires.

You are already objecting: but the operator kills app connections. It does — and knowing the limit precisely is the difference between an assumption and a control. The limit is not the retry count. It is the word reachable: every one of those passes is a SQL statement, issued over a connection to the site being drained. In the Unit 3 scenario iad was scaled to zero, so there was nothing to connect to and every pass was a no-op. The case that really stings is the partition, where iad’s mysqld is up and answering your application perfectly well, and unreachable only from the operator.

Set spec.connectionDrainTimeout against your own pool, not against a feeling. It bounds how long the operator keeps trying; your pool’s maximum connection lifetime bounds how long a survivor can last. The larger of the two is your real stale-read window.

SequenceThe connection that never moves
counter-appmysql-playground-primaryiadpdx
  1. counter-appmysql-playground-primary
    TCP connect — once, when the pool fillsThe pool opens a handful of connections at startup and keeps them.
  2. mysql-playground-primaryiad
    kube-proxy picks a backend at establishmentThis flow is now pinned to iad. kube-proxy chose once and does not re-evaluate.
  3. counter-appiad
    SELECT value FROM counter_db.counters — on that socket
  4. mysql-playground-primarypdx
    selector role=primary now matches pdx — for NEW connections onlyMeanwhile the operator fenced iad with SET GLOBAL super_read_only = ON, which closes no sockets, and its KillAppConnections step never ran because iad is unreachable. pdx was promoted 12.0 s after the kill.
  5. counter-appiad
    SELECT ... — same socket, still iad. Succeeds. Stale.This is the dangerous one. It returns data and nothing anywhere reports a problem.
  6. counter-appiad
    UPDATE ... — ERROR 1290, the first thing that actually failsA write is the only operation that surfaces the fence.
Every message after the promotion travels a socket that was correct when it was opened and wrong ever after. Only the write says so.

This is a known gap, and it is only half closed

None of this is a lesson invented for the course. The project has tracked it as a defect, narrowed it, and left the part you just met open — the dated record is in the version appendix, row A1, which is where to look before you quote a version.

What is settled is the shape. There are exactly three connection-drain behaviours, and only one of them is unconditional:

PathDrains connections?
Planned failoverYes. Repeatedly, inside drainTimeout, before the write endpoint moves. It is the only path that drains ahead of the switch.
Emergency failoverBest-effort during the sequence, then retried per poll inside spec.connectionDrainTimeout — but only while the fenced site answers.
Autonomous sidecar self-fenceThe sidecar kills what it can and does not retry, because it cannot safely tell an application session from the operator’s own.

The observability half is worse, and no release has changed it. No shipped alert fires for “the application is still broken after a successful failover.” BloodravenFailoverOccurred watches the operator’s own counter, bloodraven_failovers_total — it tells you a promotion happened, and its “first checks” column already lists app writes as something a human goes and looks at. Nothing is watching your pool. That alert is yours to write, and Unit 6 makes you write it.

Four ecosystems, one shape

Widen the frame and the Bloodraven-specific feeling disappears.

HikariCP carries an open issue titled “got a read-only connection from the connection pool after the db failover”. Pool validation queries pass against a demoted primary — the node is alive, it is merely read-only — which is exactly why drivers grew rejectReadOnly handling, and why the first thing that actually fails is a write, with ERROR 1290 or 1792, not a health check. The JVM’s default DNS cache can be infinite for the process lifetime, which is why a short TTL alone does not save you and AWS documents forcing networkaddress.cache.ttl to 60 s or less. And a proxy in front does not move existing sessions either: with ProxySQL’s fast_forward=1, connections keep talking to the old master and hit read-only errors.

The fixes that are not fixes

Each of these is reached for first, and each is wrong for a specific reason.

CompareWhat each change actually does to an open socket
Raise the pool sizereconnect=trueBounded connection lifetime
What it changesThe number of sockets, all established the same wayBehaviour after a connection has already brokenHow long any socket may live before it is retired
Does it move an established connection?No — it creates more of themNo — nothing broke, so nothing reconnectsYes — each is closed and reopened within the bound
Effect on the stale-read windowWidens it: more connections pinned to the demoted site, held longerNone: a stale read succeeds, so the reconnect path never runsBounds it: no connection outlives the promotion by more than the lifetime

Shortening the DNS TTL belongs in the same bin. spec.dns.ttl defaults to 60 and the playground runs it at 10, and both numbers are irrelevant to an established socket — a resolver TTL governs the next lookup, and against a caching runtime it may govern nothing at all.

The real fix is three parts and none of them works alone:

  1. A bounded connection lifetime, so no connection outlives a promotion by much.
  2. Retry on the right error class — the read-only write errors, 1290 and 1792 — not blanket retry-everything, which will happily replay a statement that failed for an entirely different reason.
  3. A read/write split, so writes resolve through mysql-playground-primary and only reads go to mysql-playground-replicas.

The artifact

The playground’s counter application already carries parts one and three, and it carries the diagnostic that makes the failure visible:

// connectLoop, in playground/counter-app/main.go — part one of the fix, already applied
conn.SetMaxOpenConns(5)
conn.SetConnMaxLifetime(30 * time.Second)

// handleCounter — asked on the same connection the read came from
conn.QueryRow(`SELECT @@global.read_only`).Scan(&readOnly)
conn.QueryRow(`SELECT @@hostname`).Scan(&host)

That is why the counter’s /api/counter response carries readOnly and dbHost beside value. Hit it inside thirty seconds of a failover and you are not guessing: the read succeeds, readOnly is true, and dbHost still names the demoted site. Three fields, one connection, the whole mechanism on screen. Wait longer than thirty seconds and it has healed itself — because SetConnMaxLifetime(30s) is doing exactly what part one of the fix is supposed to do. The bug is easiest to see in an application that has already been half-fixed.

Choosing a strategy

SetConnMaxLifetime is a pool setting, not a Bloodraven feature — and so are all three of these. Bloodraven moves labels, records and taints; the strategy is yours.

StrategyHow the pool gets refreshedChoose it when
Taint-basedThe shipstream.io/db-readonly-<group> NoExecute taint from the previous topic evicts the non-tolerating app pod at the demoted site; the restart guarantees a fresh poolThe app is co-located with MySQL and restarting it is cheap
Service-basedThe app stays up; bounded lifetime plus error-class retry carries it acrossRestarts are expensive, or the app is not site-pinned
Site-local warm standbyAn instance runs at every site; only the one co-located with the current primary takes writesCross-site write latency is the binding constraint

Measure your own gap

There is no wall-clock recovery number to quote here, and the course will not invent one. Detection is 6 s, promotion lands at 12.0 s — those are Bloodraven’s, and they are measured. How long your writes fail afterwards depends on pool configuration, driver behaviour and DNS caching, none of which Bloodraven controls. Any number this course handed you would be a number about somebody else’s application.

So you measure. You can now explain the stale-read mechanism from first principles, name Bloodraven’s one mitigation and its exact limits, choose between the three strategies, and state the three-part pool fix. What you cannot yet state is your own write-gap in seconds — the interval between the last write your writer completed against iad and the first it completed against pdx. That is the unit project, and it is the only number about your application that is worth anything.

Flashcards

The -primary Service selector flips from iad to pdx. Which of your application's connections does that change?

Only new ones. kube-proxy chooses a backend at connection establishment; an established flow is never re-evaluated and never reset.

1 / 12

Quiz

Question 1 of 5

During a failover drill on playground, your application's SELECT queries keep returning rows the whole time. A colleague concludes the application must already be talking to the newly promoted pdx. Is that conclusion sound?

Show answer

Answer: False

The reversal: a successful SELECT proves the socket is open and the server is alive, and proves nothing about which site is on the other end. super_read_only blocks writes but closes no sockets, and a Service selector flip only routes new connections — so a pooled connection opened before the promotion still reaches the demoted site and still answers reads, with data that is stale by however far that site has drifted. The only cheap way to know which end you are on is to ask the connection directly, which is why the counter app reads @@hostname and @@global.read_only on the same connection it just used. (objective 4)

Question 2 of 5

Your pool runs SELECT 1 as its validation query and it never flagged a single connection during the promotion. What would the check have to do differently to catch a demoted primary?

Show answer

Answer: Ask the connection whether it is writable — @@global.read_only, or an actual write — instead of only whether it answers

A demoted primary is fully alive: it accepts connections, parses SQL and returns rows, so any read-only probe passes no matter how often you run it — that is why the HikariCP issue exists and why drivers grew rejectReadOnly. Running it more often samples the same passing check faster. TCP keepalive detects a dead peer; this peer is healthy, which is the whole problem. Raising the pool size adds more connections established the same way, all pinned to the same backend. Only a check that tests writability distinguishes primary from demoted primary. (objectives 4, 6)

Question 3 of 5

A network partition isolates iad from the operator while your application, which sits in the same site, keeps reaching iad's mysqld normally. The operator promotes pdx. Describe what KillAppConnections does here, and what that means for the application.

Show answer

Answer:

It does not run at all. KillAppConnections is step 2 of the failover sequence and, after promotion, one bounded eviction pass per topology poll until a pass finds no sessions or spec.connectionDrainTimeout expires. Every pass needs a working handle to the old primary; under a partition the operator cannot reach iad, so every drain pass is a no-op, exactly as it was when the site was held down in Unit 3. The application's established connections to iad survive intact. They keep serving reads that succeed and are stale, until iad is next promoted or demoted. Nothing alerts on it — BloodravenFailoverOccurred only reports that a promotion happened.

A full-credit answer shows: A strong answer covers: (1) it does not run, because the old primary must be reachable from the operator; (2) it is best-effort during the sequence and, after promotion, retried once per poll inside spec.connectionDrainTimeout — but every one of those passes is a SQL statement against the site being drained, so an unreachable site gets none of them; (3) the consequence — established application connections survive and serve stale reads until the next promotion or demotion; (4) bonus for noting no alert fires, or that only planned failover truly drains. An answer that says the kill 'fails' or 'will retry later' has missed the point: the limit is reachability, not the retry count.

The mitigation exists and is real, but it is precisely absent in the failure modes that produce this symptom — a held-down site or a partition. Both leave the old primary unreachable to the operator while it remains perfectly reachable to an application co-located with it. The retry window the operator does have is bounded by spec.connectionDrainTimeout, and every attempt in it is a statement issued over a connection to the site being drained — so the bound that matters is reachability, not the budget. (objective 4)

Question 4 of 5

You want to shorten the window in which your application can read stale data from a demoted site. Which change actually shortens it?

Show answer

Answer: Bound the connection lifetime so every socket is retired and reopened

Only a bounded lifetime forces an established socket to close, and the ceiling on staleness becomes the lifetime you chose. Raising the pool size makes it worse: more connections, all established the same way, all pinned to the same backend, held longer. reconnect=true acts after something has already broken — a stale read succeeds, so the reconnect path is never entered. A shorter DNS TTL governs the next lookup, not an open socket, and against a runtime that caches DNS for the process lifetime it may govern nothing at all. (objectives 4, 6)

Question 5 of 5

A batch worker runs in the same site as MySQL, is stateless, and restarts in under two seconds. Which failover strategy fits it best?

Show answer

Answer: Taint-based: let the NoExecute taint on the demoted site evict the pod, and take the fresh pool the restart gives you

Co-located plus cheap to restart is the exact case the taint is for: the shipstream.io/db-readonly-<group> NoExecute taint evicts a non-tolerating pod from the demoted site, and the restart guarantees a fresh pool with no configuration to get wrong. Service-based is the right answer for the opposite application — long-running or expensive to restart — and here it buys complexity for nothing. Warm standby addresses cross-site write latency, which a co-located worker does not have. Sending it to -replicas is not a strategy but a category error: -replicas never serves writes, so a worker that writes cannot use it. (objective 5)

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.