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

  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 sessionsPreserved
  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: 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).

Anatomyshipstream.io/dragonfly-traffic=enabled
prefixshipstream.io/
Bloodraven's own label namespace. Nothing outside the operator writes it.
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 anythingenabled
There is no 'disabled' value. 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, inside a hard-coded 10 s budgetREPLTAKEOVER 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 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:

ValueMeaning
truePromotion completed cleanly, replica caught up. Sessions survived.
falseSessions 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:

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

1 / 12

Quiz

Question 1 of 5

A planned switchover of playground reports Succeeded. You dump the status and sessionsPreserved 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 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)

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 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)

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

Question 4 of 5

During a takeover the operator sheds an endpoint from the active Dragonfly Service by deleting the 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 "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)

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.