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
- Explain why an open pooled connection keeps serving stale reads after a correct failover
- Choose a failover strategy — taint-based, Service-based, or site-local warm standby — for a given application
- 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.
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.
- counter-appmysql-playground-primaryTCP connect — once, when the pool fillsThe pool opens a handful of connections at startup and keeps them.
- mysql-playground-primaryiadkube-proxy picks a backend at establishmentThis flow is now pinned to
iad. kube-proxy chose once and does not re-evaluate. - counter-appiad
SELECT value FROM counter_db.counters— on that socket - mysql-playground-primarypdxselector
role=primarynow matchespdx— for NEW connections onlyMeanwhile the operator fencediadwithSET GLOBAL super_read_only = ON, which closes no sockets, and itsKillAppConnectionsstep never ran becauseiadis unreachable.pdxwas promoted 12.0 s after the kill. - counter-appiad
SELECT ...— same socket, stilliad. Succeeds. Stale.This is the dangerous one. It returns data and nothing anywhere reports a problem. - counter-appiad
UPDATE ...— ERROR 1290, the first thing that actually failsA write is the only operation that surfaces the fence.
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:
| Path | Drains connections? |
|---|---|
| Planned failover | Yes. Repeatedly, inside drainTimeout, before the write endpoint moves. It is the only path that drains ahead of the switch. |
| Emergency failover | Best-effort during the sequence, then retried per poll inside spec.connectionDrainTimeout — but only while the fenced site answers. |
| Autonomous sidecar self-fence | The 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.
| Raise the pool size | reconnect=true | Bounded connection lifetime | |
|---|---|---|---|
| What it changes | The number of sockets, all established the same way | Behaviour after a connection has already broken | How long any socket may live before it is retired |
| Does it move an established connection? | No — it creates more of them | No — nothing broke, so nothing reconnects | Yes — each is closed and reopened within the bound |
| Effect on the stale-read window | Widens it: more connections pinned to the demoted site, held longer | None: a stale read succeeds, so the reconnect path never runs | Bounds 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:
- A bounded connection lifetime, so no connection outlives a promotion by much.
- 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.
- A read/write split, so writes resolve through
mysql-playground-primaryand only reads go tomysql-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.
| Strategy | How the pool gets refreshed | Choose it when |
|---|---|---|
| Taint-based | The 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 pool | The app is co-located with MySQL and restarting it is cheap |
| Service-based | The app stays up; bounded lifetime plus error-class retry carries it across | Restarts are expensive, or the app is not site-pinned |
| Site-local warm standby | An instance runs at every site; only the one co-located with the current primary takes writes | Cross-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.
What does SET GLOBAL super_read_only = ON do to the sockets already open on that instance?
Nothing. Fencing is a variable change, not a disconnection — every existing session stays connected.
How long does a session that survived a fence keep serving stale reads?
Until that site is next promoted or demoted.
Which sessions does KillAppConnections deliberately spare?
Its own connection and the binlog dump threads — everything else in information_schema.processlist is killed.
Which of Bloodraven's three paths actually drains application connections?
Only planned failover, and it drains ahead of the switch, repeatedly, inside drainTimeout. Emergency failover is best-effort during the sequence and then retried per poll inside spec.connectionDrainTimeout (default 30s) — but only while the fenced site answers. An autonomous sidecar self-fence does not retry at all. See the version appendix, row A1, for the dated state of the gap.
Which Bloodraven path actually drains application connections rather than making one best-effort pass?
Planned failover. Nothing else does.
What does the BloodravenFailoverOccurred alert observe?
The operator's own bloodraven_failovers_total counter — that a promotion happened. It observes nothing about your application or its pool.
Why does a pool's validation query succeed against a demoted primary?
Because the node is alive and answering — it is merely read-only, and a validation SELECT is not a write.
Which MySQL errors does a write hit on a demoted primary?
ERROR 1290 (server running with the --read-only option) and ERROR 1792.
rejectReadOnly — what problem did drivers add it to solve?
A demoted primary looks healthy to every liveness check, so the driver has to treat a read-only connection as unusable itself.
Why is a short DNS TTL not enough on the JVM?
The JVM's default DNS cache can be infinite for the process lifetime; AWS documents forcing networkaddress.cache.ttl to 60 s or less.
Name the three parts of the pool fix that only work together.
A bounded connection lifetime, retry scoped to the read-only error class, and a read/write split across the -primary and -replicas Services.
Quiz
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)
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)
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)
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)
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)
Sample 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)
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)
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)
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)
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)