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

  1. Say what Bloodraven guarantees about Dragonfly and what it explicitly does not
  2. Trace a Dragonfly promotion through REPLTAKEOVER and its fallback, and read dragonfly.sessions
  3. 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).

Anatomybloodraven.shipstream.io/dragonfly-traffic=true
prefixbloodraven.shipstream.io/
Bloodraven's own label domain; every Bloodraven key uses it. Nothing outside the operator writes this label.
keydragonfly-traffic
The gate. The active Service selector is an exists-and-equals check on this key: absent key, no match, no endpoint.
the only value that means anythingtrue
A boolean label value is always true or false, and this one is only ever true: there is no 'false' value written. Shedding deletes the key, because a deleted key cannot be misread — a written value depends on the selector agreeing what it means.

Derive 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.

CompareEmergency vs planned Dragonfly promotion
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 probeREPLTAKEOVER 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 masterNo 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:

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:

ValueMeaning
PreservedREPLTAKEOVER succeeded on a caught-up replica. Sessions survived.
LostSessions were lost: a sync timeout or a source offset that could not be captured under Proceed, or a failed REPLTAKEOVER (dragonfly.reason says which).
absentUnknown. 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:

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".

1 / 12

Quiz

Question 1 of 5

A planned switchover of playground reports Succeeded. You dump the status and dragonfly.sessions is absent — the key is not in the YAML at all. What have you learned about the counter app's session store?

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)

Question 2 of 5

Your primary dies. The Dragonfly target's REPLTAKEOVER fails. What does the operator do next, and what does it cost?

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)

Question 3 of 5

A Dragonfly pod is wedged and answers nothing. Your MySQL primary then dies. The emergency MySQL promotion will be delayed while the operator waits on Dragonfly.

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)

Question 4 of 5

During a takeover the operator sheds an endpoint from the active Dragonfly Service by deleting the bloodraven.shipstream.io/dragonfly-traffic key rather than setting it to a disabled value. Explain why deletion is the safer mechanism, and what property of the takeover it buys.

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)

Question 5 of 5

You deploy playground with a Dragonfly image older than the stated v1.38.0 minimum. What actually happens?

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)

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.