Cache and sessions that follow the primary
Optional Dragonfly moves with the primary on a best-effort budget. What sessionsPreserved 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 readsessionsPreserved - 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: shipstream.io/dragonfly-role=master and
shipstream.io/dragonfly-traffic=enabled. 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
‘enabled’” (internal/controller/dragonfly_labels.go).
shipstream.io/dragonfly-trafficenabledDerive 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, inside a hard-coded 10 s budget | 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 fallback. onSyncTimeout decides: proceed flagged unpreserved, or fail and roll 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, or the move did 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.
The 10 s budget, and why MySQL never waits
The emergency path wraps everything in const budget = 10 * time.Second. Its contract, written in the
source, is three nevers: it “never returns an error to the caller, never blocks longer than a small
bounded budget, and never leaves Dragonfly in a state that affects MySQL durability”
(internal/controller/dragonfly_topology.go).
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, and its deadline fires at 10 s regardless of what timeout the takeover was handed.
Reading sessionsPreserved — it is tri-state
status.plannedFailover.dragonfly.sessionsPreserved is a *bool, and the pointer is load-bearing:
| Value | Meaning |
|---|---|
true | Promotion completed cleanly, replica caught up. Sessions survived. |
false | Sessions were lost: sync timeout with proceed, REPLTAKEOVER failure, or empty-master fallback. |
| absent (nil) | Unknown. Not false. |
Nil 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. Nil is what you get when the field was never written — Dragonfly disabled for that attempt, 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. The client adds 5 s of I/O grace on top so it cannot give up before the server has spent its full drain budget — 30 s + 5 s = a 35 s client read deadline at the default.onSyncTimeout, enumproceed;fail, 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 promotionMethod: REPLTAKEOVER and sessionsPreserved: true. The same move
with a lagging replica under proceed leaves sessionsPreserved: false — 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 sessionsPreserved
without misreading nil, and say which path may trade sessions for availability. The application half of
failover is complete — services, DNS, taints, pools, planned moves, cache.
What you cannot yet do is see any of it from outside. Everything in this unit was checked by reading status and logs by hand. The next unit asks what the operator exports on its own, where it goes blind, and what you should be alerting on before an incident makes you look.
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?
shipstream.io/dragonfly-role=master AND shipstream.io/dragonfly-traffic=enabled — 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 "enabled", 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 continues flagged unpreserved, fail rolls back the MySQL fence.
How long is the emergency Dragonfly budget, and where is it set?
10 seconds, hard-coded in the operator (const budget = 10 * time.Second). It is not configurable.
Where does status.plannedFailover.dragonfly.sessionsPreserved 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 read deadline to maxSyncWait + 5 s?
So the client cannot give up before the server has spent its full drain budget and leave the caller unsure whether the promotion happened. At the 30 s default that is a 35 s deadline.
onSyncTimeout — allowed values and default?
An enum of proceed or fail, 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 the promotion method, the log line and the event to find out
sessionsPreserved is a *bool and its three states are true, false and nil — nil means unknown, for example because Dragonfly was disabled for that attempt. Reading absent as false makes you chase an incident that did not happen; reading it as success makes you miss one that did. "Sessions were lost" is the classic misread of a nil pointer as its zero value: omitempty drops nil, not false. "Only written on failure" is backwards — a clean REPLTAKEOVER explicitly stamps true. 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)
sessionsPreserved is a *bool and its three states are true, false and nil — nil means unknown, for example because Dragonfly was disabled for that attempt. Reading absent as false makes you chase an incident that did not happen; reading it as success makes you miss one that did. "Sessions were lost" is the classic misread of a nil pointer as its zero value: omitempty drops nil, not false. "Only written on failure" is backwards — a clean REPLTAKEOVER explicitly stamps true. 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 is bounded at 10 s regardless. 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 is bounded at 10 s regardless. 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. The Dragonfly attempt runs after MySQL has already been promoted, under a hard-coded 10 s budget, and by explicit design it never returns an error to its caller, never blocks longer than that budget, 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 10 s bound and the best-effort contract exist to refuse. (objective 10)
The opposite is true: MySQL failover is never delayed by cache. The Dragonfly attempt runs after MySQL has already been promoted, under a hard-coded 10 s budget, and by explicit design it never returns an error to its caller, never blocks longer than that budget, 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 10 s bound 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 "enabled", 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 enabled, 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 "enabled", 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 "enabled", 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 enabled, 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 "enabled", 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)