Cache and sessions that follow the primary
Optional Dragonfly moves with the primary on a best-effort budget. What dragonfly.sessions actually tells you, and why the version floor is a promise rather than a check.
By the end of this topic you can
- Say what Bloodraven guarantees about Dragonfly and what it explicitly does not
- Trace a Dragonfly promotion through
REPLTAKEOVERand its fallback, and readdragonfly.sessions - Explain how the active Dragonfly Service sheds an endpoint atomically during a takeover
You can move playground’ primary on purpose, and you know the phase list it walks. Two of those phases —
WaitingForDragonflySync and PromotingDragonfly — have gone unexplained. They exist because the
counter app does not only write to MySQL. It keeps a session store in Dragonfly, one pod per site, and
when the primary moves that store must move with it or every logged-in user is bounced.
The boundary, and everything that follows from it
Dragonfly here is cache and session state, never durable application data. If losing it costs you
a transaction, it was in the wrong place. The CRD says so itself: Dragonfly is “treated as non-durable
cache/session state: emergency failover never blocks on it” (api/v1alpha1/dragonfly_types.go).
Hold that sentence; the rest of this topic is derived from it. It is why the emergency budget is small,
why one promotion path may lose sessions outright, and why nothing Dragonfly does may ever affect MySQL
durability. For playground: the counter app’s session store is nice to keep across a promotion, never the
record of truth.
One Service, two labels, AND-gated
Bloodraven creates one app-facing Service, playground-dragonfly, whose selector AND-gates two labels the
operator stamps on Dragonfly pods: bloodraven.shipstream.io/dragonfly-role=master and
bloodraven.shipstream.io/dragonfly-traffic=true. A pod is an endpoint only when both match. Role says
which pod is master; traffic is the canonical “this pod serves writes” gate.
Now the part worth slowing down for. To shed an endpoint the operator deletes the traffic key. It
does not stamp a disabled value. The code says why: “Removing the label (set=false) is preferred over
stamping a ‘disabled’ value because the active-Service selector is an exists-and-equals check on
‘true’” (internal/controller/dragonfly_labels.go).
bloodraven.shipstream.io/dragonfly-traffictrueDerive the consequence yourself. The takeover sequence strips the source’s traffic key before it
promotes the target, and stamps the target’s key only after. An absent key cannot match. So there is no
window in which both instances match the active selector — no moment where the Service load-balances
your session writes across two masters. With dragonfly-traffic=disabled instead, correctness would
depend on selector and writer agreeing on a magic string. Deletion needs no agreement. The steady-state
label sweep honours the same window: mid-takeover it skips re-stamping the source, because that would
“re-attach the old master to the active Service mid-takeover, which is exactly the bug the strip is
preventing”.
Two promotion paths, and only one of them can fall back
Both paths reach for the same primitive first: REPLTAKEOVER, Dragonfly’s atomic replica takeover. What
differs is what happens when it fails.
| Emergency (after MySQL failover) | Planned (PromotingDragonfly phase) | |
|---|---|---|
| What is tried? | REPLTAKEOVER on the target with a 2 s budget, only if the old master answers a 1 s probe | REPLTAKEOVER on the target, with maxSyncWait as the timeout argument |
| What happens on failure? | Falls back to REPLICAOF NO ONE, promoting the target as an empty master | No REPLICAOF NO ONE fallback. onSyncTimeout decides: Proceed (default) promotes MySQL anyway with sessions Lost, or Fail rolls back the MySQL fence |
| What happens to sessions? | Preserved on REPLTAKEOVER; lost on the fallback — logged as 'target promoted via REPLICAOF NO ONE (sessions lost)' | Preserved after a caught-up REPLTAKEOVER; Lost under Proceed; under Fail the move does not happen. Retry it |
The asymmetry is the same boundary again. An emergency promotion cannot be retried — MySQL has already failed over and a cache that refuses to promote serves nothing — so it takes the empty master and says so in the log. A planned switchover can be retried, so it refuses to buy availability with sessions.
Bounded steps, and why MySQL never waits
MySQL does not run the emergency Dragonfly promotion at all. Once MySQL has promoted, its poll loop only
queues a request for the new active site and carries on; the group’s Dragonfly manager runs it on its
own goroutine. The contract, written in the source, is three promises: it “never returns an error to the
caller, every step is bounded, and it never leaves Dragonfly in a state that affects MySQL durability”
(internal/controller/dragonfly_topology.go).
There is no single budget; each step has its own:
- A 1 s dial and
PINGof the old master. A takeover is coordinated by the master, so if it does not answer,REPLTAKEOVERis skipped. REPLTAKEOVERwith a 2 s budget, which the client waits up to 5 s for (the budget plus 3 s for the reply).- The
REPLICAOF NO ONEfallback, on a fresh connection: up to 14 s after a failed takeover, because Dragonfly holds the replica’sREPLICAOFlock for the takeover budget plus 10 s, or 5 s when the takeover was skipped. - An outcome that stays unconfirmed (the fallback could not be sent, or its reply was lost) is settled by
reading the target’s role once 13 s have passed since the takeover was sent (1 s after
REPLICAOF NO ONEwhen no takeover was sent). - Every other step, from the preliminary reads to the label stamps, gets 5 s.
Operationally that is one sentence: MySQL failover is never delayed by cache. A wedged Dragonfly, an unreachable target, a takeover that hangs — none of it extends your measured 12.0 s emergency promotion. The attempt runs after MySQL has already succeeded, on a goroutine MySQL never waits for.
Reading dragonfly.sessions — absent is not Lost
status.plannedFailover.dragonfly.sessions has three states, and the third is load-bearing:
| Value | Meaning |
|---|---|
Preserved | REPLTAKEOVER succeeded on a caught-up replica. Sessions survived. |
Lost | Sessions were lost: a sync timeout or a source offset that could not be captured under Proceed, or a failed REPLTAKEOVER (dragonfly.reason says which). |
| absent | Unknown. Not Lost. |
Absent is not failure. Read it as failure and you will chase incidents that did not happen; read it as success and you will miss ones that did. Absent is what you get when the field was never written — Dragonfly not enabled when the request was accepted, or a run that ended before its Dragonfly phases, for instance. Note too that it lives on the planned-failover status. An emergency promotion never writes it; that outcome lives in the log line and the event.
Two settings shape which of the three you get:
maxSyncWait, default30s. It boundsWaitingForDragonflySyncand doubles as the timeout argument passed toREPLTAKEOVER, rounded up to whole seconds. The target lets its master run 10 s past that budget, and the client waits 2 s more for the reply so it cannot give up before the server has — 30 s + 12 s = a 42 s client read deadline at the default.onSyncTimeout, enumProceed;Fail(CamelCase: the CRD rejects the lower-case spellings older releases used), defaultProceed. SpellProceedout: the promotion goes ahead and the cache outcome is whatever it is.Failaborts before MySQL promotion and rolls the source fence back.
playground pins both at their defaults:
# playground/manifests/failovergroup.yaml (unchanged from earlier topics)
dragonfly:
enabled: true
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.38.0
# … port, memory, resources elided …
plannedFailover:
maxSyncWait: 30s
onSyncTimeout: Proceed
A clean switchover leaves dragonfly.takeover.method: ReplTakeover and dragonfly.sessions: Preserved.
The same move with a lagging replica under Proceed leaves dragonfly.sessions: Lost with
dragonfly.reason: DragonflySyncTimeout — and a MySQL promotion that still lands at RPO 0. The cache outcome does not touch the MySQL guarantee. That is the whole design.
Where REPLTAKEOVER actually comes from
Be honest about the primitive. It is real, and it is undocumented: an ADMIN-port,
GLOBAL_TRANS command taking a timeout in seconds, introduced in Dragonfly v1.5.0 (2023-07-03),
with no official documentation page. The reference is the Dragonfly source
(src/server/server_family.cc) and the v1.5.0 release notes — “Support atomic replica takeover” — not
a manual. If you audit this path, that is where you read.
Version discipline is yours, not the operator’s
“Dragonfly v1.38.0 or later” is a support policy, not a guardrail. Nothing in the API types, the
controller, or the chart enforces or checks a Dragonfly version. The only CEL rules on spec.dragonfly
are that an image is required when Dragonfly is enabled, and that it may not be :latest. Run a build
older than REPLTAKEOVER itself and Bloodraven will not stop you; it will fail in ways nobody has
characterised, on a path that by design never returns an error to its caller.
That is the durable half. The perishable half is which numbers are current — the repo’s pin, and upstream’s latest stable, both move — so it lives in the version appendix, row C2, along with the command that re-checks both. When this course was written those two numbers were two minors apart, which is the ordinary condition of a pin nobody is forced to update.
Where this leaves you
You can state the guarantee and its limits: best-effort cache continuity, never at the expense of MySQL,
with a shed-then-promote label sequence that has no dual-master window. You can read dragonfly.sessions
without misreading an absent value, and say which path may trade sessions for availability. Services, DNS, taints,
pools, planned moves, cache: the operator’s side of the application boundary is covered.
One thing on your side of that boundary is still unaccounted for. The deploy pipeline changes the schema on the same primary the operator moves, on its own calendar, with no idea a switchover is booked. The last topic in this unit gives it a way to ask first — and a fence for when the answer changes underneath it.
Flashcards
What class of state is Bloodraven's Dragonfly integration for?
Cache and session state only, never durable application data — the CRD calls it "non-durable cache/session state: emergency failover never blocks on it".
Which two labels does the active Dragonfly Service AND-gate?
bloodraven.shipstream.io/dragonfly-role=master AND bloodraven.shipstream.io/dragonfly-traffic=true — a pod is an endpoint only when both match.
You need to shed a Dragonfly pod from the active Service. What does the operator do to the traffic label?
Deletes the key outright. The selector is an exists-and-equals check on "true", so a missing key sheds the endpoint with no ambiguity.
You find the log line "dragonfly emergency: target promoted via REPLICAOF NO ONE (sessions lost)". Which branch ran?
The emergency fallback: REPLTAKEOVER was tried first and failed, so the target was promoted as an empty master.
What does the planned Dragonfly path do when REPLTAKEOVER fails?
There is no REPLICAOF NO ONE fallback. onSyncTimeout decides: Proceed promotes MySQL anyway with dragonfly.sessions: Lost, Fail rolls back the MySQL fence.
What bounds the emergency Dragonfly promotion, and is any of it configurable?
Per-step budgets hard-coded in the operator, none configurable: a 1 s probe of the old master, a 2 s REPLTAKEOVER (5 s client wait), up to 14 s for the REPLICAOF NO ONE fallback, and 5 s for every other step. It runs on the Dragonfly manager's goroutine, so MySQL never waits for it.
Where does status.plannedFailover.dragonfly.sessions come from after an emergency promotion?
Nowhere — emergency promotions never write the field. The outcome lives only in the log line and the Kubernetes event.
spec.dragonfly.plannedFailover.maxSyncWait — default and second job?
30s. As well as bounding WaitingForDragonflySync it is passed as the REPLTAKEOVER timeout argument.
Why does the Dragonfly client set its REPLTAKEOVER read deadline to maxSyncWait + 12 s?
The target lets its master run 10 s past the budget, and the client allows 2 s more for the reply, so it cannot give up before the server has and leave the caller unsure whether the promotion happened. At the 30 s default that is a 42 s deadline.
onSyncTimeout — allowed values and default?
An enum of Proceed or Fail (CamelCase; the lower-case spellings of older releases are rejected), defaulting to Proceed: the promotion goes ahead and the cache outcome is whatever it is.
What kind of command is REPLTAKEOVER, and when did it appear?
An ADMIN-port, GLOBAL_TRANS command taking a timeout in seconds, introduced in Dragonfly v1.5.0 and never given an official documentation page.
Which two CEL rules does the CRD actually enforce on spec.dragonfly?
An image is required when Dragonfly is enabled, and the image may not be tagged :latest. Nothing checks the Dragonfly version.
Quiz
Show answer
Answer: Nothing about the outcome — the field is unknown, and you have to look at dragonfly.takeover, the log line and the event to find out
dragonfly.sessions has three states: Preserved, Lost, and absent, which means unknown — for example because Dragonfly was not enabled when the request was accepted, or the run ended before its Dragonfly phases. Reading absent as Lost makes you chase an incident that did not happen; reading it as success makes you miss one that did. "Only written on failure" is backwards — a clean REPLTAKEOVER explicitly records Preserved. And an absent field is not evidence about routing: the traffic-label shed and the role flip are what move the active Service, and they leave their own traces. (objective 11)
dragonfly.sessions has three states: Preserved, Lost, and absent, which means unknown — for example because Dragonfly was not enabled when the request was accepted, or the run ended before its Dragonfly phases. Reading absent as Lost makes you chase an incident that did not happen; reading it as success makes you miss one that did. "Only written on failure" is backwards — a clean REPLTAKEOVER explicitly records Preserved. And an absent field is not evidence about routing: the traffic-label shed and the role flip are what move the active Service, and they leave their own traces. (objective 11)
Show answer
Answer: Falls back to REPLICAOF NO ONE, promoting the target as an empty master — sessions are lost, and it logs exactly that
The emergency path is REPLTAKEOVER first, REPLICAOF NO ONE second, and the operator logs "target promoted via REPLICAOF NO ONE (sessions lost)". It buys availability with sessions because an emergency promotion cannot be retried — that trade is only acceptable because Dragonfly holds cache, never durable data. Retrying until maxSyncWait misreads the budget: the emergency attempt uses fixed per-step bounds (a 2 s takeover, up to 14 s for the fallback), not maxSyncWait. Rolling back MySQL inverts the whole design — no Dragonfly outcome is ever permitted to affect MySQL. Leaving both as replicas would mean the cache serves nothing at all, which is worse than an empty master. (objectives 10, 11)
The emergency path is REPLTAKEOVER first, REPLICAOF NO ONE second, and the operator logs "target promoted via REPLICAOF NO ONE (sessions lost)". It buys availability with sessions because an emergency promotion cannot be retried — that trade is only acceptable because Dragonfly holds cache, never durable data. Retrying until maxSyncWait misreads the budget: the emergency attempt uses fixed per-step bounds (a 2 s takeover, up to 14 s for the fallback), not maxSyncWait. Rolling back MySQL inverts the whole design — no Dragonfly outcome is ever permitted to affect MySQL. Leaving both as replicas would mean the cache serves nothing at all, which is worse than an empty master. (objectives 10, 11)
Show answer
Answer: False
The opposite is true: MySQL failover is never delayed by cache. MySQL only queues the Dragonfly promotion after it has already been promoted; the Dragonfly manager runs it on its own goroutine, every step under its own hard-coded bound, and by explicit design it never returns an error to its caller and never leaves Dragonfly in a state that affects MySQL durability. The tempting reasoning — "co-managed subsystems must be waited on" — is exactly what the queue, the bounded steps and the best-effort contract exist to refuse. (objective 10)
The opposite is true: MySQL failover is never delayed by cache. MySQL only queues the Dragonfly promotion after it has already been promoted; the Dragonfly manager runs it on its own goroutine, every step under its own hard-coded bound, and by explicit design it never returns an error to its caller and never leaves Dragonfly in a state that affects MySQL durability. The tempting reasoning — "co-managed subsystems must be waited on" — is exactly what the queue, the bounded steps and the best-effort contract exist to refuse. (objective 10)
Show answer
Answer:
The active Service selector is an exists-and-equals check on the value "true", so removing the key removes the endpoint immediately and unambiguously. Writing something like traffic=disabled would only work if the selector and the writer agreed on what "disabled" means — the selector does not test for a disabled value, it tests for true, so any other value happens to work by accident rather than by contract. Because the sequence strips the source's key before promoting the target and stamps the target's key only afterwards, and an absent key can never match, there is no window in which both instances satisfy the active selector. No moment where the Service load-balances session writes across two masters.
A full-credit answer shows: A strong answer covers: (1) the selector is an exists-and-equals check on "true", so a missing key cannot match; (2) a written "disabled" value would depend on selector and writer agreeing on a magic string, i.e. correctness by convention rather than by mechanism; (3) the consequence — strip-before-promote plus an unmatched absent key means no window where both pods are endpoints of the active Service. Credit an answer that names the role label as the other half of the AND-gate. Do not credit an answer that only says "deletion is atomic" without saying what the selector actually tests.
The mechanism is the lesson: the AND-gate of role and traffic, plus deletion rather than devaluation, is what makes the endpoint shed atomic and gives the takeover no dual-master window. (objective 12)
Sample answer
The active Service selector is an exists-and-equals check on the value "true", so removing the key removes the endpoint immediately and unambiguously. Writing something like traffic=disabled would only work if the selector and the writer agreed on what "disabled" means — the selector does not test for a disabled value, it tests for true, so any other value happens to work by accident rather than by contract. Because the sequence strips the source's key before promoting the target and stamps the target's key only afterwards, and an absent key can never match, there is no window in which both instances satisfy the active selector. No moment where the Service load-balances session writes across two masters.
A full-credit answer shows
A strong answer covers: (1) the selector is an exists-and-equals check on "true", so a missing key cannot match; (2) a written "disabled" value would depend on selector and writer agreeing on a magic string, i.e. correctness by convention rather than by mechanism; (3) the consequence — strip-before-promote plus an unmatched absent key means no window where both pods are endpoints of the active Service. Credit an answer that names the role label as the other half of the AND-gate. Do not credit an answer that only says "deletion is atomic" without saying what the selector actually tests.
The mechanism is the lesson: the AND-gate of role and traffic, plus deletion rather than devaluation, is what makes the endpoint shed atomic and gives the takeover no dual-master window. (objective 12)
Show answer
Answer: Nothing stops you. The version floor is a support policy, not a guardrail; the only CEL rules are that an image is required and that it may not be :latest
Nothing in the API types, the controller or the chart enforces or checks a Dragonfly version, so an older build is admitted and will simply fail in ways nobody has characterised. The two CEL rules that do exist are narrower than people assume: image required when Dragonfly is enabled, and no :latest. "Admission rejects it" and "the chart refuses" are the same wrong instinct — treating a documented minimum as an enforced one. "Falls back cleanly to REPLICAOF NO ONE" is a more subtle trap: the operator has no version knowledge to make that decision with, so you get undefined behaviour rather than a graceful degradation. Note too that the shipped playground pin has itself drifted two minors behind current stable — version discipline is yours. (objective 10)
Nothing in the API types, the controller or the chart enforces or checks a Dragonfly version, so an older build is admitted and will simply fail in ways nobody has characterised. The two CEL rules that do exist are narrower than people assume: image required when Dragonfly is enabled, and no :latest. "Admission rejects it" and "the chart refuses" are the same wrong instinct — treating a documented minimum as an enforced one. "Falls back cleanly to REPLICAOF NO ONE" is a more subtle trap: the operator has no version knowledge to make that decision with, so you get undefined behaviour rather than a graceful degradation. Note too that the shipped playground pin has itself drifted two minors behind current stable — version discipline is yours. (objective 10)