Sources

Every factual claim in this course, the exact wording it came from, and where to check it.

Flagged as ungrounded

These could not be traced to a primary source. They are listed here rather than quietly dropped.

Grounding ledger for Bloodraven in Production. Every load-bearing number, API, and claim in this course traces to a row here. Rows were produced by a six-angle grounding expedition against the Bloodraven repository at v1.0.0 (v1.0.0 @ bbcfcc0, git describe = v1.0.0), the shipped CRDs and Helm chart, the recorded chaos-run forensics, the public GitHub issue tracker, and current upstream documentation.

Repo paths are relative to the Bloodraven repository root.

A number that does not appear below may not appear in the course. Arithmetic derived from these figures is allowed where the derivation is shown.

Primary

Version appendix

Current release: Bloodraven v1.0.0 (v1.0.0 @ bbcfcc0). Every row in this section was last re-verified on 2026-08-14.

The units are written to survive a release. This section is where the things that cannot live: issue numbers, unmerged pull requests, "the published page currently says X", upstream version pins, and licence status. Each of those is correct on the date above and has a decent chance of being wrong a release or two later — so the topics link here instead of restating any of it, and one edit tracks upstream rather than a dozen.

If a row disagrees with what you observe, believe your cluster: the row is stale, the mechanism it describes is not. Run the re-check command before you quote any of it in a meeting.

A. Known gaps — what the course teaches, and whether it has been reported

#The gapStatus on 2026-08-13Re-check with
A1Stale application connections survive a correct failover. super_read_only closes no sockets, and the operator's drain cannot reach an unreachable host. Taught in Unit 3 topic 1 and Unit 4 topic 2.Narrowed, not closed. After promotion each topology poll makes one bounded eviction pass against the fenced former primary until a pass finds no sessions or spec.connectionDrainTimeout (default 30s) expires. The Unit 3 scenario is unchanged — a site held at zero replicas answers no eviction pass either. What the drain closes is the narrower case where the demoted primary is alive and reachable. An autonomous sidecar self-fence still has no operator-side drain.grep -n connectionDrainTimeout api/v1alpha1/types.go
A2One existing outage slows detection everywhere else. failCount past failureThreshold doubles the whole loop's interval to a 30 s cap, so a second fault takes 30 s × 3 = 90 s to detect instead of 6 s. Unit 2 topic 1.By design. site/content/docs/6.operations/6.failover.md describes the 2→4→8→16→30 s progression and the same ~90 s compound-failure bound this course derives — a number to plan around, not a defect to report.grep -n maxPollBackoffExponent internal/controller/topology.go
A4Partition shape D — asymmetric peer reachability — has no test of any kind. The simulator keys link state by a sorted pairKey(a, b), so a one-way link is unrepresentable. Unit 5 topic 3.Present, and reported as issue #140. Everything the course says about shape D is argued from the code and has never been observed under injection. That is stated in the topic on purpose.grep -n 'func pairKey' internal/dst/
A5Dragonfly version support is a policy, not a guardrail. Nothing in the API types, the controller or the chart checks a Dragonfly version. Unit 4 topic 4.Present, and reported as issue #141. The only CEL rules on spec.dragonfly are that an image is required when it is enabled and that the tag may not be latest.grep -n 'x-kubernetes-validations' -A3 config/crd/bases/shipstream.io_mysqlfailovergroups.yaml | grep -i dragonfly
A6bloodraven_replication_lag_seconds carries no role label, so a lag alert has to exclude reader sites by name and grows a maintenance burden with every reader added. Unit 6 topic 4.Present, and reported as issue #142.curl -s localhost:8080/metrics | grep bloodraven_replication_lag_seconds
A7Recloning an encrypted site livelocks. UnsealReason=Clone is not sticky, so the recipient re-seals before CLONE INSTANCE runs. Unit 6 topic 3.Fixed. Clone unseal is sticky until bootstrap finishes; the gate is always wired. Scenario 51-encrypted-reclone hard-asserts the unseal.gh issue view 144

B. Where the published docs drifted from the code

The durable lesson is in the units: the CRD is the contract and the code is the behaviour; a documentation page is neither. kubectl explain renders what the API server validates against, and grep over config/crd/bases/ tells you what shipped. These rows are the dated instances that lesson was built from, not a running defect list — and three of the five have already been fixed, which is the same argument in a different direction.

#What a published page saidStatus on 2026-08-14
B1site/content/docs/6.operations/6.failover.md documented spec.splitBrainPolicy.preferSite, including a copy-pasteable YAML block. The CRD has no such field, so the API server prunes it silently.Fixed. The page documents sitePriorities. The habit the topic teaches — ask the cluster, not the page — is what generalises.
B2The cross-site evaluation table named an outcome Failover. The code emits Reason = "Degraded", so a rule matching reason=Failover never fires.Fixed. The table now gives Degraded, and the page carries an explicit paragraph saying a reason=Failover alert never fires.
B3The failover sequence was published as ten steps that misstated fatality and counted node taints and source convergence — poll-driven neighbours — as links in the chain.Fixed. Fatality now matches internal/controller/failover.go step for step, and the page states plainly that taints, the -primary selector and source convergence are poll-driven consequences rather than steps. The remaining difference is framing: the page's ten steps are this course's nine plus "record and publish the promotion", which the course teaches as the ordering fact that the durable record is stamped before DNS.
B4The cross-site table had no row for "the primary is fine and a peer is down", and no mention that the fence-first early return preempts every row below it.Still absent. Both are real rows in EvalCrossSite and playground sits in the first one for the whole recovery window after a site failure.
B5site/content/docs/6.operations/6.failover.md quoted 30–45 s for a failover. The recorded runs give 12.0 s typical and 36.0 s with a full relay-log drain.Superseded rather than fixed — treat any published wall-clock figure as an estimate and your own chaos-results/ timings as the measurement.

C. Upstream and third-party pins

#PinValue on 2026-08-13Re-check with
C1MySQL baselinemysql:9.7 — Bloodraven's single supported baseline, and the current MySQL LTS (Oracle's July 2026 GA announcement lists 9.7.2 LTS beside 8.4.11 LTS and 26.7.0 Innovation). 8.0, 8.4, 9.8+ and 5.7 are all unsupported by the operator.grep -n 'default=' api/v1alpha1/types.go | grep -i image; https://dev.mysql.com/doc/relnotes/mysql/9.7/en/
C2DragonflyThe repo pins v1.38.0 (2026-04-14) against a current stable of v1.40.1 (2026-08-06) — two minors of drift, and nothing enforces either number.grep -rn 'dragonflydb/dragonfly:v' playground/manifests/
C3Operator chartChart 1.0.0, appVersion 1.0.0, kubeVersion: ">=1.26.0".grep -n 'version|kubeVersion' charts/bloodraven/Chart.yaml
C4external-dns APIexternaldns.k8s.io/v1alpha1 is still the current group version. An approved proposal targets v1beta1 with no date attached.kubectl api-resources | grep dnsendpoint
C5MySQL keyringcomponent_keyring_file. The keyring_file plugin and keyring_file_data were removed in MySQL 8.4.0; any runbook naming the plugin describes something that no longer exists.SELECT * FROM performance_schema.keyring_component_status

D. Licence and distribution

Settle this before you plan a dependency on Bloodraven, because it is not a technical question.

This is the fastest-moving row on the page, so treat the command as the authority and the prose as context.

v1.0.0 ships the Business Source License 1.1: source-available, not OSI open source. Production use by a company over $1M annual revenue requires a commercial license; individuals, non-profits, education, and companies under that threshold stay free, as does non-production use at any scale. Settle current terms from the repository, not from remembered prose:

ls LICENSE*
gh repo view ShipStream/bloodraven --json licenseInfo

and read the project's own licensing page for current terms and pricing. If your organisation is planning a production dependency, that is the conversation to have before the migration plan, not after.

This course is separately licensed; see the notice in the page footer.

Ledger

Poll loop, state machine, and the decision matrix

#ClaimValue / verbatim quoteSourceAngle
1spec.pollInterval default// +kubebuilder:default="2s"api/v1alpha1/types.go:86-87A1
2Operator hard-defaults 2s when the pointer is nilpollInterval := int64(2 * time.Second)internal/controller/reconciler.go:1962-1965A1
3spec.failureThreshold default 3site.failCount++; if site.failCount >= tm.cfg.FailureThreshold { return state.StateUnreachable }internal/controller/topology.go:1536-1540; default api/v1alpha1/types.go:89-92A1/A2
4spec.recoveryThreshold default 2 — gates the transition to writable, not failure detectionsite.recoveryCount++; if site.recoveryCount >= tm.cfg.RecoveryThreshold { return state.StateWritable }internal/controller/topology.go:1553-1557; default api/v1alpha1/types.go:94-97A1/A2
5Detection delay = pollInterval × failureThreshold = 2 s × 3 = 6 s. recoveryThreshold is not a term in this sumcomputeState at topology.go:1532-1559; loop at :878-901derived, A2A2
6Transition to read-only is immediate (single poll) and resets the recovery counterif readOnly { site.recoveryCount = 0; return state.StateReadOnly }internal/controller/topology.go:1546-1549A1
7Four per-site statesStateUnknown / StateWritable // read_only=0 / StateReadOnly // read_only=1 / StateUnreachable // connection failedinternal/state/machine.go:7-11A1
8The poll's SQLerr := m.db.QueryRowContext(ctx, "SELECT @@read_only").Scan(&readOnly)internal/mysql/checker.go:178A1
9Per-site probe ceiling 5 s, all sites polled in parallelpollCtx, cancel := context.WithTimeout(ctx, 5*time.Second)internal/controller/topology.go:943-960A1
10Poll interval is adaptive and undocumented: doubles per failure past failureThreshold, exponent capped at 4, hard cap 30 sinterval := base * time.Duration(1<<uint(backoffFails))if cap := 30 * time.Second; interval > cap { return cap }; const maxPollBackoffExponent = 4internal/controller/topology.go:876, 918-929A1
11A writable observation on a non-promotable site bypasses recoveryThreshold entirely// A successful writable observation on a non-promotable site is an immediate safety fact. Do not debounce authority invalidation or fencing behind the normal recovery threshold.internal/controller/topology.go:972-977A1
12Replication health has its own separate 2-tick debounceconst replicatingStreakThreshold = 2internal/controller/topology.go:1071, 1097-1101A1
13Matrix: fence-first early return preempts every other rowif len(action.FenceSites) > 0 { action.Alert = fmt.Sprintf("writable non-promotable site requires fencing (%s)", …); action.Reason = "Degraded"; return action }internal/state/matrix.go:107-114A1
14Matrix: TotalLossif len(unreachable) == coreCount { action.Alert = "TOTAL LOSS: all sites are unreachable"; action.Reason = "TotalLoss" }internal/state/matrix.go:117-121A1
15Matrix: SplitBrain is strictly len(writable) > 1 among core sitesif len(writable) > 1 { action.SplitBrain = true; action.Alert = fmt.Sprintf("SPLIT BRAIN: %d sites are writable (%s)", …) }internal/state/matrix.go:124-130A1
16Matrix: Failover requires zero writable and ≥1 unreachable and ≥1 read-only; its Reason is "Degraded", not "Failover"if len(unreachable) > 0 && len(readOnly) > 0 { candidates := RankPromotionCandidates(readOnly, sitePriorities); … action.Reason = "Degraded" }internal/state/matrix.go:137-144A1
17All-read-only with no unreachable peer refuses to elect, by design// Without any unreachable peer we refuse to auto-elect a primary (all-read-only is a startup or recovery state that needs human input).internal/state/matrix.go:132-136A1
18Matrix: NoPrimary, with a two-site-specific messageif len(readOnly) == 2 && len(unreachable) == 0 { action.Alert = "NO PRIMARY: both sites are read-only" } else { action.Alert = "NO PRIMARY: no writable site available" }internal/state/matrix.go:149-154A1
19Matrix: Degraded (primary up, peer down) — a real row absent from the docs tableif len(unreachable) > 0 { action.Alert = fmt.Sprintf("%s unreachable while %s is primary", …); action.Reason = "Degraded" }internal/state/matrix.go:159-163A1
20Matrix: Healthy = exactly one writable, zero unreachableaction.Reason = "Healthy"internal/state/matrix.go:165-166A1
21EvalCrossSite is pure; split-brain auto-resolution is layered on by the caller// The function is pure: it never considers history or policy beyond the supplied priorities.internal/state/matrix.go:63-67A1
22The matrix is evaluated every poll; mutating actions only on a transition// Evaluate every poll so all status snapshots carry the current topology condition. Mutating cross-site actions remain transition-driven.internal/controller/topology.go:1023-1029A1
23Status condition reasons come straight from the matrix ReasonDegradedReason string // one of "Healthy", "Degraded", "SplitBrain", "NoPrimary", "TotalLoss", or ""internal/controller/topology.go:166; internal/controller/runner.go:910, 1298-1301A1

The failover sequence as implemented

#ClaimValue / verbatim quoteSourceAngle
24Step 1 — fence old primary; error only warnsif err := oldPrimary.SetSuperReadOnly(ctx, true); err != nil { f.logger.Warn("failed to fence old primary (may be unreachable)", "error", err) }; SQL SET GLOBAL super_read_only = ONinternal/controller/failover.go:25-30; internal/mysql/replication.go:31-35A1
25Step 2 — undocumented: kill application connections on the old primarySELECT id FROM information_schema.processlist WHERE id != CONNECTION_ID() AND command NOT IN ('Binlog Dump', 'Binlog Dump GTID') then KILL %dinternal/controller/failover.go:32-37; internal/mysql/replication.go:44-58A1
26Step 3 — relay-log drain, 30 s timeout, non-fatal on failureif err := candidate.WaitForRelayLogDrain(ctx, 30*time.Second); err != nil { f.logger.Warn("relay log drain did not complete cleanly, proceeding with promotion", …) }internal/controller/failover.go:41-45A1/A2
27Drain exits early when the SQL thread is running and caught upif rs.SQLRunning { if rs.SecondsBehindSource != nil && *rs.SecondsBehindSource == 0 { return nil } }internal/mysql/replication.go:263-284A1
28Drain internals: 500 ms first wait, doubling to a 4 s ceiling, one SQL-thread restart with retryinterval := 500 * time.Millisecond; const maxInterval = 4 * time.Secondinternal/mysql/replication.go:251-302A1
29Steps 4–5 — STOP REPLICA then RESET REPLICA ALL, both fatalif err := candidate.StopReplica(ctx); err != nil { return "", err }internal/controller/failover.go:48-55; SQL internal/mysql/replication.go:67, 75A1
30Step 6 — record promotion GTID, non-fatalpromotionGtid, err := candidate.GetGtidExecuted(ctx); if err != nil { f.logger.Warn("failed to record promotion GTID", "error", err) }; SQL SELECT @@global.gtid_executedinternal/controller/failover.go:58-61; internal/mysql/replication.go:190A1
31Steps 7–8 — promotion is two statements, both fatalSetSuperReadOnly(ctx, false) then SetReadOnly(ctx, false)SET GLOBAL super_read_only = OFF, SET GLOBAL read_only = OFFinternal/controller/failover.go:63-71; internal/mysql/replication.go:31-35, 82-87A1
32Completion logf.logger.Info("failover complete", "promotedSite", candidateSite, "promotionGtid", promotionGtid)internal/controller/failover.go:73A1
33Writable confirmation is synchronous, in the same call stack — not the next pollif err := tm.confirmWritable(ctx, candidate); err != nil { tm.logger.Error("promotion succeeded but writable confirmation failed; DNS not flipped", …); return }internal/controller/topology.go:1778-1782, 851-862A1
34The durable failover record and counter are stamped before the DNS flip, deliberately// … so a DNS-provider outage cannot erase the fact that a promotion happened.internal/controller/topology.go:1784-1802A1
35Node taints are not a step of the failover sequence — they are a pure function of per-site transitions applied earlier in the same polltm.applyPerSiteAction(...) at topology.go:1006 vs tm.applyCrossSiteAction(...) at :1028internal/controller/topology.go:1000-1008, 1027-1029A1
36Taint action is a pure function of the transitioncase curr == StateWritable: … a.Taint = &f / case curr == StateReadOnly || curr == StateUnreachable: if prev == StateWritable || prev == StateUnknown { … a.Taint = &t } / // read-only <-> unreachable: no new actioninternal/state/machine.go:44-56A1/A6
37Source convergence is not part of the promotion sequence — independent poll stage, own 20 s budgetconst sourceConvergenceOperationTimeout = 20 * time.Secondinternal/controller/topology.go:1112-1118; internal/controller/source_convergence.go:14A1
38Convergence demands a direct source; replication chains are not acceptedif current == expected && repl.IORunning && repl.SQLRunning { … sourceConvergenceConverged … } else tm.repointReplica(...)internal/controller/source_convergence.go:72-90A1
39GTID freshness is the primary promotion selector; the priority list is only a tiebreakerpromote = tm.pickFreshestCandidate(ctx, action.PromotionCandidates); // GTID freshness is the primary selector (minimise data loss on promotion); ties or incomparable sets fall back to candidate orderinternal/controller/topology.go:1726, 2010-2016A1
40Measured emergency-failover time, clean primary kill: 12.0 s to activeSite flip, reproducible across ≥9 independent runs (12.004, 12.005 ×3, 12.006 ×2, 12.007, 12.011, 12.02 s; one at 13.008 s). Conditions: k3d playground, 2 s poll / 3 failures, candidate caught up, injection = scale-to-0 or force-deleteplayground/chaos-results/live-20260502T051703Z/20260502T051703Z/01-clean-primary-kill/scenario.logA2
41Measured failover time with unapplied relay logs: 36.005 s — scenario 14 pauses the replica SQL applier and seeds 5 s of writes, then kills the primary. This is when the 30 s drain budget is actually spentplayground/chaos-results/20260430T203117Z/14-failover-with-replication-lag/scenario.log; internal/playground/scenarios/s14_failover_with_replication_lag.goA2

Cooldown, history, and the re-assert exception

#ClaimValue / verbatim quoteSourceAngle
42spec.failoverCooldown default 5m// +kubebuilder:default="5m"; cooldown := time.Duration(cfg.FailoverCooldown); if cooldown == 0 { cooldown = 5 * time.Minute }api/v1alpha1/types.go:100; internal/controller/topology.go:491-494A1/A2
43Cooldown enforcement point and log msgif !lastFailover.IsZero() && tm.clock.Since(lastFailover) < tm.failoverCooldown { tm.logger.Info("failover blocked by anti-flap cooldown", "lastFailover", lastFailover, "cooldown", tm.failoverCooldown); return }internal/controller/topology.go:1741-1745A1
44Cooldown does not suppress: split-brain fencing, non-promotable fencing, source convergence, old-primary recovery, reclone, DNS reconcilePoll call sites at topology.go:1012, 1043, 1114, 1121, 1133, 1136 — none consults tm.failoverCooldowninternal/controller/topology.goA1
45The ordered-update handoff promotion is not cooldown-gated, yet still records a failover and increments the countertm.recordFailover(...) / metrics.FailoversTotal.WithLabelValues(target).Inc() inside the handoff callback; grep -n cooldown internal/controller/updater.go → no matchinternal/controller/topology.go:2483-2492A1
46Durable location #1 — CR status subresourcerec := FailoverRecord{LastFailoverTarget: fg.Status.LastFailoverTarget}internal/controller/failover_state.go:200-206A1
47Durable location #2 — object annotations, RFC3339 second precision, written as a pair by JSON merge patchLastFailoverAnnotation = "bloodraven.shipstream.io/last-failover"; LastFailoverTargetAnnotation = "bloodraven.shipstream.io/last-failover-target"internal/controller/failover_state.go:40-41, 134, 163-165A1
48The duplication is deliberate — status is a subresource with its own RBAC and admission chain// The duplication is the point. status is a subresource: writes to it travel a separate API path with its own RBAC rule (mysqlfailovergroups/status) …internal/controller/failover_state.go:17-39A1
49Restart rehydration takes the later copy, with a 5 m future-clock guard; ties go to statusFailoverClockSkewGrace = 5 * time.Minute; winner := NewerFailoverRecord(statusRecord, oobRecord)internal/controller/failover_state.go:46, 252, 288-292A1
50Re-assert preconditions: subsystem gates, rate limit, every non-target peer read-only, target read-only and promotable, target GTID contains the recorded promotion GTID and every peer's GTIDif tm.bootstrapBlocksCrossSite() || tm.isUpdating() || tm.isTopologyFrozen() || tm.isPlannedFailoverActive() { return false }; if !targetGtid.Contains(promotionGtid) { … return false }internal/controller/topology.go:3262-3284, 3303-3312, 3337-3341, 3360-3364A1
51An unparseable recorded promotion GTID refuses rather than skipping the gatetm.logger.Warn("primary re-assert refused: recorded promotion GTID set failed to parse — status corrupted or manually edited?", …)internal/controller/topology.go:3328-3336A1
52Re-assert log msg, verbatimtm.logger.Warn("re-asserting fenced promoted primary: no site is writable and the last failover target is GTID-complete; restoring writability", "site", target)internal/controller/topology.go:3373-3374A1
53Re-assert metricmetrics.PrimaryReassertTotal.WithLabelValues(target).Inc()Name: "bloodraven_primary_reassert_total", labels []string{"site"}internal/controller/topology.go:3388; internal/metrics/metrics.go:82-84A1
54The re-assert rate limit uses a separate timer, so a re-assert can fire inside the failover cooldown windowlastReassert := tm.lastReassert … never compared against tm.lastFailoverinternal/controller/topology.go:3269, 3282A1
55Cross-site mutation is suppressed wholesale during in-place restore, planned failover, topology-relevant bootstrap, or ordered updateif tm.isTopologyFrozen() { … return } / if tm.isPlannedFailoverActive() { … return }internal/controller/topology.go:1619-1645A1

Site roles

#ClaimValue / verbatim quoteSourceAngle
56Three roles, enum-validated, default primary-candidate// +kubebuilder:validation:Enum=primary-candidate;dr-only;read-only; // +kubebuilder:default="primary-candidate"api/v1alpha1/types.go:296-311, 323-327A1
57Promotability is exactly role == primary-candidatefunc (t *siteTracker) isPromotable() bool { return t.role == state.SiteRolePrimaryCandidate }internal/controller/topology.go:221-223A1
58role: read-only sites are excluded from coreCount and all three tallies; dr-only sites are not excludedif obs.Role != SiteRoleReadOnly { coreCount++ }internal/state/matrix.go:77-86A1
59Any writable site that is not primary-candidate is routed to FenceSitesif obs.State == StateWritable && obs.Role != SiteRolePrimaryCandidate { action.FenceSites = append(action.FenceSites, obs.Name); continue }internal/state/matrix.go:80-83A1
60A writable reader/dr-only site is fenced every poll, without debounce, independent of any transition// A writable non-promotable site is never authoritative. Enforce this on every poll so a failed fence is retried without waiting for a transition.internal/controller/topology.go:1010-1012, 1586-1604A1
61Fence log stringstm.logger.Warn("fenced writable non-promotable site", "site", site.name, "role", site.role)internal/controller/topology.go:1598-1601A1
62A single writable reader invalidates activeSite immediately (authority is ambiguous), except during its own cloneif tm.sites[i].state == state.StateWritable { if active != nil { return "" } … }internal/controller/topology.go:722-753A1
63Final belt-and-braces refusal at the promotion call siteif !candidate.isPromotable() { tm.logger.Error("promotion target is not a primary-candidate — refusing", "site", candidate.name, "role", candidate.role); return }internal/controller/topology.go:1752-1756A1
64Readers never taintif site.role == state.SiteRoleReadOnly || site.taintSelector == "" { return }internal/controller/topology.go:1569-1571A1
65A planned failover targeting a reader is hard-refused: only primary-candidate sites may be promotedinternal/controller/planned_failover.go:177; chaos scenario 43A4

Split brain

#ClaimValue / verbatim quoteSourceAngle
66spec.splitBrainPolicy.preferSite does not exist. The real field is sitePriorities, an ordered listSitePriorities []string is the only member of SplitBrainPolicySpec, with the JSON tag sitePriorities,omitempty. Grepping preferSite across config/crd/bases/ and charts/bloodraven/crds/ returns no matchapi/v1alpha1/types.go:246-260; config/crd/bases/shipstream.io_mysqlfailovergroups.yaml:6390-6405A1/A6
67Field doc: empty sitePriorities = manual resolution, alert only// When omitted, or when SitePriorities is empty, the operator takes no automated action and alerts only (manual resolution required).api/v1alpha1/types.go:165-172A6
68Tier 1 — prior failover history fences immediately, regardless of policy, but only if the recorded target is itself live, writable, and promotableif keepSite := tm.getSite(lastFailoverTarget); keepSite != nil && keepSite.state == state.StateWritable && keepSite.isPromotable() { tm.fenceSitesExcept(ctx, lastFailoverTarget, false) }internal/controller/topology.go:1682-1688A6
69Tier 2 — no history + sitePriorities → fence the losers, re-promote the winner through the standard pathwinner, losers := state.ResolveSplitBrain(writable, tm.cfg.SitePriorities)internal/controller/topology.go:1704-1731A6
70Split-brain winner selection deliberately does not consult GTID// GTID freshness is intentionally not consulted here — split-brain winner selection is policy-driven because every writable side may carry unique writesinternal/controller/topology.go:1699-1702A1/A4
71ResolveSplitBrain refuses non-candidates and refuses to guess with an empty priority list; it never falls back to declared orderif len(sitePriorities) == 0 { return "", nil }; // It never falls back to declared orderinternal/state/matrix.go:200-226A1
72CEL validation: priority entries must name primary-candidate sites- message: splitBrainPolicy.sitePriorities entries must match the names of sites with role 'primary-candidate'charts/bloodraven/crds/shipstream.io_mysqlfailovergroups.yaml:6468-6472A6
73Emitted split-brain log msg names sitePriorities and carries winner/fencedSitetm.logger.Warn("split-brain auto-resolve: fencing non-preferred site per spec.splitBrainPolicy.sitePriorities", "winner", winner, "fencedSite", loser)internal/controller/topology.go:1710-1711A1
74Split-brain fencing is retried on non-transition polls, counting writable candidates directly rather than trusting action.SplitBrainif !action.SplitBrain && writableCandidates < 2 { return false }internal/controller/topology.go:1861-1878A1
75Priority-based resolution is a policy decision, not a safety feature:::danger … "makes split-brain resolution fast and deterministic at the cost of silently losing the loser's unreplicated writes. The loss is surfaced loudly but not prevented."site/content/docs/6.operations/6.failover.md:262-268A4

Old-primary recovery, divergence, reclone

#ClaimValue / verbatim quoteSourceAngle
76Recovery states are exactly two strings plus a bare "" backoff markerrecoveryStateInProgress = "RecoveryInProgress"; recoveryStateBlocked = "RecoveryBlocked"internal/controller/topology.go:467-468, 3477-3480A1
77Re-verification cadence is 30 srecoveryRetryDelay = 30 * time.Secondinternal/controller/topology.go:469-472A1
78Containment comparison: no divergence ⇔ the new primary's GTID set contains the old primary'sif newGtid.Contains(oldGtid) { tm.logger.Info("no GTID divergence, auto-recovering old primary as replica", "site", oldPrimary.name)internal/controller/topology.go:3622-3639A1
79Divergence path computes the set difference and countdivergent := oldGtid.Subtract(newGtid); count := divergent.TransactionCount()internal/controller/topology.go:3642-3674A1
80The comparison runs against a GTID re-read after a defensive fence, so the set cannot grow underneath it// Re-read after the fence: this is the authoritative set the divergence comparison runs against (the fence guarantees it can no longer grow).internal/controller/topology.go:3577-3588A1
81Recovery is deliberately not gated on lastFailoverTarget; safety comes from a directly confirmed unique writable primary// Deliberately NOT gated on lastFailoverTarget:internal/controller/topology.go:3145-3161A1
82The rejoin SQL sequence, in order1. SET GLOBAL super_read_only = ON 2. STOP REPLICA 3. RESET REPLICA ALL 4. CHANGE REPLICATION SOURCE TO 5. START REPLICAinternal/controller/recovery.go:10-44A1
83CHANGE REPLICATION SOURCE always uses SOURCE_AUTO_POSITION=1; adds GET_SOURCE_PUBLIC_KEY=1 when TLS is off"CHANGE REPLICATION SOURCE TO SOURCE_HOST='%s', SOURCE_USER='%s', SOURCE_PASSWORD='%s', SOURCE_AUTO_POSITION=1"internal/mysql/replication.go:214-226A1
84RecoveryInProgress is persisted before the mutation runs, as the restart handoff// … this early write is the durable handoff for operator restarts that happen inside the STOP/RESET/CHANGE/START sequence.internal/controller/topology.go:3631-3638A1
85Status conditions: RecoveryInProgress → reason RecoveryInProgress; RecoveryBlocked → reason DivergentTransactionsReason: "DivergentTransactions", Message: fmt.Sprintf("Old primary %s has %d divergent transactions — annotate with bloodraven.shipstream.io/reclone-site=%s to recover", …)internal/controller/runner.go:1014-1031A1
86Divergence gaugemetrics.DivergentTransactions.WithLabelValues(oldPrimary.name).Set(float64(count))bloodraven_divergent_transactionsinternal/controller/topology.go:3677; internal/metrics/metrics.go:111-114A1
87An "empty" site is decided from shared GTID UUIDs, not from absent user schemasempty = preGtid.IsEmpty(); if !empty && !tm.sharesHistory(ctx, preGtid, newPrimary) { … empty = !hasSchemas }internal/controller/topology.go:3531-3573A1
88Why: a schema-only test would have cloned over a diverged old primary"A cluster legitimately has no user schemas before its first app write… a diverged old primary would have been cloned over rather than reported as RecoveryBlocked."issue #130; commit 1daffd6; PR #129A4
89Reclone annotation key and two accepted value formsRecloneAnnotation = "bloodraven.shipstream.io/reclone-site"; reclone-site=<siteName> or reclone-site=<siteName>:<divergentGtidPrefix>internal/controller/reconciler.go:65; internal/controller/reclone.go:17-46A1
90Hot reclone requires a ≥8-character prefix matching status.sites[].divergentGtidconst minRecloneGtidPrefix = 8; if !strings.HasPrefix(divergentGtid, req.GtidPrefix) { … "does not match the observed divergentGtid" }internal/controller/reclone.go:15, 106-123A1
91Cold reclone requires a literal confirm token equal to the failover-group nameif req.GtidPrefix != "confirm="+fg.Name { return fmt.Errorf("reclone of %q rejected: cold reclone wipes the datadir and must be confirmed — set annotation bloodraven.shipstream.io/reclone-site=%s:confirm=%s", …) }internal/controller/reclone.go:91-103A1
92The interlock keys on divergentGtid presence only, not on RecoveryState// Only the presence of divergentGtid matters for the interlock — RecoveryState ("RecoveryBlocked") is a downstream UX field and could be transiently unset during a reconcile.internal/controller/reclone.go:80-90A1
93A rejected annotation emits RecloneRejected and is deleted so it cannot spamr.recorder.Eventf(fg, corev1.EventTypeWarning, "RecloneRejected", "%s", err.Error())internal/controller/runner.go:363-384A1
94Reclone refuses the active primary and requires a confirmed writable donorif donor.name == recipient.name { tm.logger.Error("cannot reclone the active primary", "site", site) … }internal/controller/topology.go:3753-3769A1
95CLONE INSTANCE statement and clone timeout default 3600 sfmt.Sprintf("CLONE INSTANCE FROM '%s'@'%s':3306 IDENTIFIED BY '%s'", …) + " REQUIRE SSL" when TLSinternal/mysql/clone.go:26-71; api/v1alpha1/types.go:112A1

Durability and RPO

#ClaimValue / verbatim quoteSourceAngle
96The RPO contract in one line"An emergency failover can lose every transaction that committed on the dying primary but had not yet replicated to the surviving site."site/content/docs/5.architecture/6.durability-and-rpo.md:16A2
97sync_binlog=1 is an overridable default, not a guarantee — set in the base my.cnf map, applied before spec.mysqlConf overridesbase map internal/controller/reconciler.go:698; overrides applied :738-745A2
98innodb_flush_log_at_trx_commit=2, also overridableinternal/controller/reconciler.go:703A2
99The un-weakenable invariants, written after user overrides: gtid_mode=ON, enforce_gtid_consistency=ON, log_replica_updates=ON, log_bin, skip_replica_start=ON, plugin-load-add=mysql_clone.so. skip-log-bin and disable-log-bin aliases are deleted outrightinternal/controller/reconciler.go:749-755, 765-771A2
100binlog-expire-logs-seconds = 1209600 (14 days), overridableinternal/controller/reconciler.go:699A2
101spec.replication.maxLagSeconds default 300, and it drives only the ReplicationLagging Degraded conditionmaxLagSeconds := freshFG.Spec.EffectiveMaxLagSeconds()Reason: "ReplicationLagging"internal/controller/runner.go:932, 965-974; api/v1alpha1/types.go:265-268A1/A2
102It is not a promotion gate"If the primary dies while the replica is beyond the threshold, Bloodraven still promotes the replica" — the alternative of no writable site at all is almost always worsesite/content/docs/5.architecture/6.durability-and-rpo.md:201-208; never consulted by pickFreshestCandidate (internal/controller/topology.go:1690-1730)A4
103readOnlyMaxLagSeconds has no default; nil inherits maxLagSeconds, but an explicit 0 is meaningful (requires zero reported lag)api/v1alpha1/types.go:270-275; api/v1alpha1/site_helpers.go:86-93A2
104Planned failover is RPO 0 by construction// TransactionsLost … By construction this is 0 on a successful planned switchover; mechanism = fence source → snapshot its GTID → promote only when target GTID_EXECUTED ⊇ that snapshotapi/v1alpha1/planned_failover_types.go:140-143; internal/controller/planned_failover_reconciler.go:460-463, 607A6
105The lag gate is a true GTID-set superset test, not a lag-seconds heuristiccaughtUp, cmpErr := gtidContains(targetGtid, cur.SourceGtidAtFence); gtidContainsreturn super.Contains(sub), nilinternal/controller/planned_failover_reconciler.go:607, 923-933A6
106PVC loss → PITR does not recover the tail"The previously-active binlog lived on the destroyed PVC. It is gone forever"site/content/docs/5.architecture/6.durability-and-rpo.md:163-181A2/A4
107PITR cannot reach back past the async-replication cutoff"Transactions the old primary committed but never shipped are not in the replica's binlog stream and therefore not in PITR's replay material."site/content/docs/5.architecture/6.durability-and-rpo.md:170-174A4

Application integration, DNS, taints, Dragonfly

#ClaimValue / verbatim quoteSourceAngle
108Four Service kinds per group; object count is 2×len(sites) + 2reconcileSiteService / reconcileInternalSiteService (per site) then reconcilePrimaryService / reconcileReplicasServiceinternal/controller/reconciler.go:283, 286, 295, 298A6
109-primary selector is 2 keys — instance + role=primary, no healthymap[string]string{ labelInstance: fg.Name, labelRole: "primary" }; PublishNotReadyAddresses = falseinternal/controller/reconciler.go:1435, 1451-1453, 1462A6
110-replicas selector is 3 keys — instance + role=replica + healthy=yeslabelInstance: fg.Name, labelRole: "replica", labelHealthy: "yes"internal/controller/reconciler.go:1487-1490A6
111Reader endpoint eligibility requires five conjuncts: converged source, replicating, non-nil lag, canonical direct source host, and lag ≤ EffectiveReadOnlyMaxLagSeconds()internal/controller/reconciler.go:1771-1781A6
112The fence mechanism at the Service layer: restore and planned failover stamp role = "fenced", which matches neither selectorinternal/controller/reconciler.go:1697-1699, 1714, 1725-1748A6
113Invalid authority deliberately sheds every endpoint// Invalid or incomplete authority deliberately leaves every site non-primary and every reader non-serving, shedding stale Service endpoints rather than returning earlyinternal/controller/reconciler.go:1725-1748A6
114DNSEndpoint CR: apiVersion and kind"apiVersion": "externaldns.k8s.io/v1alpha1", "kind": "DNSEndpoint"internal/platform/dns.go:68-69A6
115Fields set, always an A record; object named bloodraven-<group>"dnsName": d.hostname, "recordType": "A", "targets": []interface{}{ip}, "recordTTL": d.ttlinternal/platform/dns.go:50, 55-59, 73-78A6
116spec.dns.ttl default 60// +kubebuilder:default=60api/v1alpha1/types.go:419-422A6
117There is no create/update split — it is an idempotent server-side apply on every poll, which self-heals a rejected writed.client.Patch(ctx, obj, client.Apply, client.FieldOwner("bloodraven"), client.ForceOwnership); // The desired target is always re-derived from live topology insteadinternal/platform/dns.go:87; internal/controller/topology.go:288-301, 1231-1236A1/A6
118applyDNS is the sole writer// applyDNS is the ONLY place the DNS record is written.internal/controller/topology.go:1238-1256A1
119Taint key format, value, and effectTaintKeyPrefix = "shipstream.io/db-readonly-"; TaintValue = "true"; Effect: corev1.TaintEffectNoExecuteinternal/platform/tainter.go:19-26, 76-80A6
120NoExecute eviction is verified end to end"the old-primary node's db-readonly-playground:NoExecute taint evicts a non-tolerating canary … while a canary that tolerates the same taint stays Running."internal/playground/scenarios/s21_noexecute_eviction_semantics.go:41-58A6
121Planned-failover phases, in order"";Pending;Deferred;Validating;Draining;WaitingForLag;WaitingForDragonflySync;PromotingDragonfly;Promoting;Resuming;Succeeded;Failedapi/v1alpha1/planned_failover_types.go:47A6
122Planned-failover annotation keyPlannedFailoverAnnotation = "bloodraven.shipstream.io/planned-failover"internal/controller/planned_failover.go:32A6
123Rollback (unfence the source) exists only in WaitingForLag; Promoting/Resuming failures fail without unfencingthree call sites, all inside plannedFailoverWaitingForLag; reasons "LagTimeout", "InvalidGTID"internal/controller/planned_failover_reconciler.go:600, 612, 643, 745-769A6
124spec.plannedFailover defaults: maxLagWait 5m, drainTimeout 30s, onCooldown rejectapi/v1alpha1/planned_failover_types.go:15, 25, 37A2
125The connection drain proceeds even when the budget is exhausted"drain budget exhausted after %s with %d connection(s) remaining on %q; proceeding"internal/controller/planned_failover_reconciler.go:524-527A6
126Dragonfly active Service AND-gates two labels; shedding deletes the traffic key rather than setting a disabled valuelabelDragonflyRole = "shipstream.io/dragonfly-role", labelDragonflyTraffic = "shipstream.io/dragonfly-traffic"; // 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_resources.go:478-479, 506, 512-513; internal/controller/dragonfly_labels.go:20-27, 105-110A6
127Emergency Dragonfly promotion: REPLTAKEOVER first, REPLICAOF NO ONE fallback (sessions lost)"dragonfly emergency: target promoted via REPLICAOF NO ONE (sessions lost)"internal/controller/dragonfly_topology.go:649-651, 712, 726, 751A6
128The planned path has no REPLICAOF NO ONE fallbacknext.Dragonfly.PromotionMethod = "REPLTAKEOVER" onlyinternal/controller/planned_failover_df.go:343, 413, 485A6
129Emergency Dragonfly budget is 10 s, hard-coded, and never blocks MySQLconst budget = 10 * time.Second; // 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 durabilityinternal/controller/dragonfly_topology.go:641-643, 666-667A2/A6
130sessionsPreserved is tri-state// SessionsPreserved is a tri-state: true if … false if sessions were lost … nil when the field is unknownSessionsPreserved *boolapi/v1alpha1/planned_failover_types.go:236-244A6
131maxSyncWait default 30 s, also used as the REPLTAKEOVER timeout argument; the client adds 5 s of I/O graceapi/v1alpha1/dragonfly_types.go:158-165; internal/dragonfly/client.go:152A2/A6
132onSyncTimeout enum proceed;fail, default proceedapi/v1alpha1/dragonfly_types.go:167-178A6
133Dragonfly v1.38.0+ is a support policy, not a guardrail — nothing in api/, internal/, or the chart enforces or checks a version. The only CEL rules are "image required when enabled" and "no :latest"api/v1alpha1/dragonfly_types.go:14-15; playground pin playground/manifests/failovergroup.yaml:87A2

Fencing and the sidecar

#ClaimValue / verbatim quoteSourceAngle
134The FencingMonitor implements exactly two rules// self-fences (sets super_read_only=ON) when one of two conditions holds: — topology mismatch, lease expiryinternal/sidecar/fencing.go:24-38A6
135The startup safety net is a separate one-shot in Server, completing before the monitor is constructedinternal/sidecar/server.go:225-276; cmd/sidecar/main.go:128-131A6
136Rule #1 (topology mismatch) fires first and returns, so a mismatch fences without consulting the lease// Rule #1 — topology mismatch at fencing.go:411; // Rule #2 — lease expiry at :430internal/sidecar/fencing.go:374-455A6
137Read-only instances never self-fencereadOnly, err := f.mysql.IsReadOnly(ctx)if readOnly { return }internal/sidecar/fencing.go:376-408A6
138Peer topology is adopted only when strictly newerif f.topology.Adopt(snap.ActiveSite, snap.ObservedAt); Adopt: if !observedAt.After(c.observedAt) { return false }. Operator reads use Set unconditionally — // the operator is always authoritativeinternal/sidecar/fencing.go:317, 346; internal/sidecar/topology_cache.go:38-59A6
139The startup net is fail-closed by staying fenced"safety net: could not query active site, staying fenced" / "safety net: no active site reported by operator, staying fenced" / "safety net: confirmed standby site, staying fenced"internal/sidecar/server.go:240-275A6
140leaseTimeout default 20 s — operator and every peer must be silent for the full window. One reachable peer keeps the site writableapi/v1alpha1/types.go:436-439; internal/sidecar/config.go:15, 39-43A2
141peerCheckInterval default 5 sapi/v1alpha1/types.go:441-444; internal/sidecar/config.go:16A2
142CEL invariants: peerCheckInterval ≥ 1s, leaseTimeout ≥ 3s, leaseTimeout ≥ 3 × peerCheckInterval. The shipped 20 s / 5 s sits exactly at the 3× floorapi/v1alpha1/types.go:432-434; internal/sidecar/config.go:12-14, 265-283A2
143The peer rule is explicitly not a quorum, and a reader counts as a peer"A reachable peer without fresh authoritative topology can still suppress the lease-only all-peers-unreachable fence. This is retained compatibility behavior, not a quorum guarantee"site/content/docs/5.architecture/2.multi-site.md:170-186A4
144Fencing does not close sockets — a surviving session can serve stale reads until the site is next promoted or demotedsite/content/docs/8.observability/7.log-schema.md:192; internal/sidecar/mysql.go:197 (killableConnection)A4
145After promotion the operator retries one bounded eviction pass per topology poll until a pass finds no sessions or spec.connectionDrainTimeout (default 30s) expires. Every pass needs a reachable old primary. An autonomous sidecar self-fence still has no operator-side drain. Planned failover drains ahead of the switch.api/v1alpha1/types.go ConnectionDrainTimeout; issue #123 (CLOSED); PR #137 (merged)A4

Operator availability and partitions

#ClaimValue / verbatim quoteSourceAngle
146The data plane does not need the operator"A healthy primary and replica keep serving reads and writes with zero operator involvement. The operator is on the failure-detection and promotion path, not the request path."site/content/docs/5.architecture/5.operator-availability.md:24-27A6
147Availability and correctness fail separately"Correctness — no split brain, no silent divergence … — is preserved by the sidecar fencing layer regardless of how long the operator is gone." / "Availability is not preserved during operator downtime."site/content/docs/5.architecture/5.operator-availability.md:34-38, 116-119A6
148Single replica with leader electionreplicaCount: 1; leaderElection: enabledcharts/bloodraven/values.yaml:7, 83-85A2/A6
149Partition scenario coverage: A exercised (chaos 09, 06 + DST partitionOperatorSite); B exercised (chaos 17); C DST only (partitionPair); D asymmetric is analysis-onlypairKey is symmetric by construction, so DST cannot express one-way reachability; E only indirectly (chaos 11)internal/playground/scenarios/s09_*.go, s06_*.go, s17_*.go, s11_*.go; internal/dst/schedule.go:18, 21; internal/dst/sim.go:227A6
150Host-netns iptables does not partition Kubernetes Service traffic"Host-level iptables rules in a k3d node are not reliable for Kubernetes Service traffic"site/content/docs/6.operations/8.network-partitions.md:121-126A4
151A NetworkPolicy can be a silent no-op: chaos 33 found a CNI evaluating it post-DNAT, so the canary "kept resolving DNS through the full 45s hold"playground/chaos-scenarios.md §33 "Observed defect and fix"A4
152Issue #93: the operator reported activeSite=iad, state=writable, Ready=True for two minutes under a deny-all NetworkPolicy, because Poll() froze on a hung MySQL read — database/sql ctx cancellation does not reliably abort a read parked on a blackholed socket, and Poll waits on every siteissue #93 (CLOSED); fix commit 8bb66dd / PR #95A4
153The first fix attempted was wrong: SetConnMaxLifetime(10s) "could not help: a connection parked in a blocked read is never returned to the pool, so it is never recycled"commit 8bb66dd body; issue #93A4
154Issue #128: a clone into a non-promotable reader suppressed every cross-site action including emergency failover, so a primary dying mid-clone left the group with zero writable sites for the clone durationissues #118, #128; PR #121 commit c7e828aA4
155Issue #46: a site reported as a healthy read-only peer had empty slave_master_info and was not replicating at all — replica health was inferred from super_read_only without verifying replicationissue #46A4
156A newly created or cloned MySQL pod comes up writable for seconds: "T+22s — new pdx pod Running, but writable… T+33s — ALERT: SPLIT BRAIN"issues #46, #128A4
157A SET GLOBAL that returns an error may still have landed: "cancelling the context tears down the client connection, it does not roll back a write the server already applied." Treating it as failed made the monitor re-fence the site it had just promotedcommit ddf0087 (PR #122)A4
158DNS propagation is external-dns's job: "The operator cannot accelerate DNS propagation. A stuck external-dns is an outage for writes even after the operator has 'finished'." Chaos 38 proves the CR can promote correctly while DNS stays stale under an RBAC denialsite/content/docs/6.operations/9.failure-mode-matrix.md:37; playground/chaos-scenarios.md §38A4
159Replication breaking cross-site triggers no automatic action: "This mode is indistinguishable, from the operator's point of view, from 'replica fell behind because of I/O pressure'. Human judgement decides."site/content/docs/6.operations/9.failure-mode-matrix.md:31A4
160The anti-flap cooldown survives a restart only if at least one of the two durable paths worked; CooldownViolated(restart+stateLost) is a documented inherent DST finding classsite/content/docs/3.get-started/1.known-limitations.md:51-74; internal/dst/README.md:145-166A4

Backup, PITR, verification, restore, encryption, DR

#ClaimValue / verbatim quoteSourceAngle
161Backup source selection: replica first, primary fallback, with exact reason strings "override" / "replica-preferred" / "primary-fallback"// selectSourceSite picks the MySQL site to dump from. Replica first, primary fallback.internal/controller/backup_reconciler.go:1107-1163A6
162read-only reader sites are excluded as backup sources, and an explicit override naming one is rejectedreturn "", "", fmt.Errorf("sourceSiteOverride %q names a read-only site, which cannot be a backup source", override)internal/controller/backup_reconciler.go:1123-1125, 1142-1145A6
163maxLagSecondsForSource default 300api/v1alpha1/backup_types.go:53A2
164The PITR archiver watches the directory, not the index file, and the reason is teachablewatcher.Add(a.cfg.BinlogDir) with // We watch the directory, not the .index file itself: MySQL rewrites the index atomically (write to .index.tmp, rename), so a file-level watch would lose the watch after the first rotate.internal/sidecar/binlog_archiver.go:163-166, 191-194A6
165inotify is an optimisation, not the only path — a poll ticker runs alongside it, plus a best-effort initial scan// runPollOnly is the fallback path when inotify is unavailable … Rotation is detected with worse latency but never missed.internal/sidecar/binlog_archiver.go:152-153, 172-173, 209-222A6
166archivePollInterval default 60 sapi/v1alpha1/backup_types.go:148A2
167Only the primary archives, gated on @@read_only// Role gate: only the primary archives.ro, err := a.mysql.IsReadOnly(ctx)if ro { … return }internal/sidecar/binlog_archiver.go:231-250A6
168Only sealed binlogs upload — the active one is dropped// Drop the last entry: it is the active binlog (tail of the index file is what MySQL is currently writing to).sealed := entries[:len(entries)-1]internal/sidecar/binlog_archiver.go:257-270A6
169maxBinlogSize default "100M", applied only when PITR is enabled and written before user overrides so spec.mysqlConf can still winapi/v1alpha1/backup_types.go:142; internal/controller/pitr.go:18; internal/controller/reconciler.go:730-736A2
170/pitr-cutoff endpoint with three query paramsurl := fmt.Sprintf("http://%s/pitr-cutoff?namespace=%s&group=%s&profile=%s", …)internal/sidecar/binlog_archiver.go:429-433; cmd/bloodraven/main.go:430, 517A6
171Pruning is rate-limited (default 1 h) and fails silently by design; without operator wiring there is no pruning at allif a.retentionCfg == nil { return }; // Errors are logged but not surfaced via setError so a transient 503 from the operator doesn't leak into the archiver status.internal/sidecar/binlog_archiver.go:350-364, 384-402A6
172What a verification proves"A verification restores a MysqlBackup artifact into an ephemeral, throwaway MySQL instance to prove the backup can actually be loaded. Unverified backups are schrödinger backups"api/v1alpha1/mysqlbackupverification_types.go:19-32A6
173Mechanism: ephemeral PVC + in-pod mysqld bound to 127.0.0.1, no Service; PVC floor 10 GiBverificationMinPVCSize = int64(10 * 1024 * 1024 * 1024) // 10 GiBapi/v1alpha1/mysqlbackupverification_types.go:26-32; internal/controller/backup_verification_job.go:46-50, 64-68A6
174Sanity check runs via mysql -B -N -e wrapped in timeout, expects one row/one column, treats an empty result as scalar 0, and fails on SanityCheckFailed / SanityCheckTimeoutapi/v1alpha1/mysqlbackupverification_types.go:115-121, 146-152A6
175What verification does not prove: logical equivalence with the live primary, application-level rehearsal of writes or traffic cutoversite/content/docs/7.backup-and-restore/6.backup-verification.md:52-58A6
176Verification shipped with a real bug proving the point: scenario 31 failed with ERROR 1062 Duplicate entry because the verify mysqld ran gtid_mode=OFF, defeating GTID dedup on replayissue #101 → PR #105A4
177There is no restore CR. Restore is two fields on the group: spec.initFromBackup (one-shot, gates bootstrap) and spec.restoreInPlace (re-runnable, no teardown/rename cycle)api/v1alpha1/types.go:158-163; api/v1alpha1/backup_types.go:694-762A6
178In-place restore phases and the anti-fat-finger token+kubebuilder:validation:Enum="";Preflight;Fencing;Restoring;Resuming;Succeeded;Failed; // Confirm is a required anti-fat-finger token … Must be an RFC 3339 timestamp … strictly greater than the timestamp recorded in status.restoreInPlace.confirmTokenUsedapi/v1alpha1/backup_types.go:723-732, 775-782A6
179pointInTime is rejected when spec.backup.pitr.enabled=false, for both restore entry points, as a reconciler error rather than admissionreturn out, fmt.Errorf("pointInTime is set but spec.backup.pitr.enabled=false; PITR restore requires the failover group to have continuous binlog archival configured on the source")internal/controller/pitr.go:325-343A6
180Keyring phases — five, including Failed+kubebuilder:validation:Enum="";Pending;Unsealed;Escrowed;Sealed;Failedapi/v1alpha1/encryption_types.go:169-195A6
181Rotation re-enters Unsealed from Sealedif site.Phase == v1alpha1.KeyringPhaseSealed && rotateTarget == site.Nameinternal/controller/encryption_reconcile.go:197-204A6
182The escrow lives in per-site versioned Kubernetes Secrets, owner-ref'd, with the digest recomputed rather than trustedlabelKeyringVersion = "shipstream.io/keyring-version"; annotationKeyringDigest = "shipstream.io/keyring-digest"internal/controller/encryption_escrow.go:24-32, 74-93, 191A6
183etcd therefore joins your key custody"The live keyring is projected from a Kubernetes Secret. Kubernetes stores Secrets unencrypted in etcd by default. Without API-server encryption at rest, enabling this feature does not protect your keys — it just moves them from the MySQL data disk to the control-plane disk." … "None of these are optional. Bloodraven cannot verify them for you."site/content/docs/4.configuration/8.encryption-at-rest.md:62-85A6
184encryptionAtRest requires spec.tls as a hard CEL rejection- message: 'spec.encryptionAtRest.enabled requires spec.tls: MySQL requires a secure connection to clone encrypted data'charts/bloodraven/crds/shipstream.io_mysqlfailovergroups.yaml:6497-6500A6
185Rotation is refused on the active primary// The operator refuses to rotate the active primary. Rotation necessarily runs with a writable keyring, and the only window in which a keyring can be lost is that oneinternal/controller/encryption_escrow.go:34-58A6
186MysqlStandbyCluster is observability only today// … publishes two conditions — BucketReadable and SourceConfigKnown … **No MySQL contact, no restore jobs, no activation in Phase 1.**api/v1alpha1/mysqlstandbycluster_types.go:16-22A6
187The DR source-fencing checklist requires at least two of three independent signals, and "Bloodraven v1 does not automatically detect or resolve cross-cluster split-brain"site/content/docs/5.architecture/3.multi-cluster-dr.md:200-237A6
188kubectl bloodraven subcommands: status, promote, reclone, backup, verify-backup, version, helpswitch args[0] { case "help", …: case "version": case "status": case "promote": case "reclone": case "backup": case "verify-backup": …}cmd/kubectl-bloodraven/main.go:32-39, 72-90A6
189The plugin only writes resources the operator already reads// The plugin only writes the resources the operator already reads … It never talks to MySQL directlycmd/kubectl-bloodraven/main.go:6-9A6

Environment and versions

#ClaimValue / verbatim quoteSourceAngle
190Release under study: v1.0.0; chart version: 1.0.0, appVersion: "1.0.0", kubeVersion: ">=1.26.0"charts/bloodraven/Chart.yaml:5-6, 25; git tagA2
191MySQL image default mysql:9.7; sidecar image ghcr.io/shipstream/bloodraven-sidecar:1.0.0api/v1alpha1/types.go:46, 50A2
192spec.sites MinItems 2 / MaxItems 16api/v1alpha1/types.go:63-64A2
19351 registered chaos scenarios. Profiles: smoke = 4, release = 17; full is every registered scenario whose Quarantine string is empty (encrypted 48/50/51 are quarantined from batch profiles)internal/playground/scenarios/*.go; internal/playground/runner/profile.go:34-38, 48-67, 93-96A2
194The playground overrides shipped defaults: failoverCooldown: 30s (vs 5 m), replication.maxLagSeconds: 30 (vs 300), dns.ttl: 10 (vs 60). It matches defaults for pollInterval, failureThreshold, recoveryThreshold, leaseTimeout, peerCheckInterval, maxSyncWaitplayground/manifests/failovergroup.yaml:10-13, 18, 76-79, 92-94A2
195The playground needs 3 worker nodes; the third is dedicated to the reader so storage-loss testing is deterministicsite/content/docs/3.get-started/3.playground.md:34A2
196chaos.sh kill-site does kubectl delete pod -l shipstream.io/site=<site> --grace-period=0 --force, and deletes MySQL and Dragonfly pods at the siteplayground/chaos.sh:52-56A2
197Pod force-delete does trigger failover — scenario 09b hard-waits up to 90 s for activeSite to flip and lastFailover to stamp before reaching its real assertion, and passesinternal/playground/scenarios/s09b_anti_flap_cooldown.go:119-127, 148-172A2
198Why the <5 s pod respawn does not save it: the Deployment recreates the pod object in ~5 s, but the debounce watches whether mysqld answers CheckReadOnly. A cold container start plus InnoDB recovery exceeds the 6 s windowinternal/controller/topology.go:1532-1545; internal/playground/scenarios/s09b_anti_flap_cooldown.go:38-43, 180-190A2
199Scenario 01 uses scale-to-0 for determinism, not because delete fails: "pod-delete races the Deployment respawn" and can restore the original topology via split-brain recovery instead of completing the failoverinternal/playground/scenarios/s01_clean_primary_kill.go:18-21; playground/chaos-scenarios.md:116, 367A2
200The genuine no-failover case is a container restart in place: scenario 16 issues SQL SHUTDOWN, pod/PVC/IP survive, and the scenario explicitly accepts either outcome "depending on how fast Kubernetes restarts the mysql container vs the operator's pollInterval × failureThreshold (~6s in playground config)"internal/playground/scenarios/s16_mysql_process_kill.go:18-42A2
201Pod crash with PVC intact is RPO 0 and no failover at all — the operator sees the primary return writable and keeps itsite/content/docs/5.architecture/6.durability-and-rpo.md:131A2/A4

External behaviour and misconceptions from the wider world

#ClaimValue / verbatim quoteSourceAngle
202A pool can hand out a read-only connection after failover — open upstream issue titled "got a read-only connection from the connection pool after the db failover"https://github.com/brettwooldridge/HikariCP/issues/1802A4
203A pool validation query passes against a demoted primary: the node is alive, just read-only. Drivers added rejectReadOnly handling precisely because the connection looks healthy until a write returns ERROR 1290/1792https://github.com/awslabs/aws-mysql-jdbc/issues/214A4
204A short DNS TTL is not enough: the JVM's default DNS cache can be infinite for the process lifetime; AWS documents forcing networkaddress.cache.ttl ≤ 60 shttps://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/jvm-ttl-dns.htmlA4
205A proxy does not move existing sessions; with fast_forward=1 connections keep talking to the old master and hit read-only errorshttps://github.com/sysown/proxysql/issues/2590A4
206read_only=ON is bypassed by anyone with SUPER; super_read_only is the actual barrierhttps://www.percona.com/blog/mysql-101-using-super_read_only/A4
207GitHub's October 2018 incident: a 43-second partition left East and West each holding writes the other never saw; recovery took over 24 hourshttps://github.blog/2018-10-30-oct21-post-incident-analysis/A4
208Quorum alone is not enough: "the loss of quorum can take an unbounded amount of time to detect and react to… The ultimate cure is to use fencing and lock the other side out"https://clusterlabs.org/projects/pacemaker/doc/2.1/Pacemaker_Explained/html/fencing.htmlA4
209A graceful takeover can still split-brain: orchestrator #854, demoted master left holding 2–7 transactions the cluster never got, because the new master was made writable before the old was set read-onlyhttps://github.com/openark/orchestrator/issues/854A4
210Semi-sync does not give zero RPO: MySQL bug #99370, closed as a documentation bug — an unacknowledged transaction still executes on restart, and the failed master "should not be reused as the replication master, and should be discarded"https://bugs.mysql.com/bug.php?id=99370A4
211Seconds_Behind_Source = 0 does not mean caught up: it compares last-executed against last-downloaded relay event, so it reads 0 when the IO thread has stalled or the replica is idlehttps://www.percona.com/blog/possible-reasons-when-mysql-replication-lag-is-flapping-between-0-and-xxxxx/A4
212Errant GTIDs break rejoin: the new primary cannot supply transactions it never saw. Detected with GTID_SUBSET() / GTID_SUBTRACT()https://www.percona.com/blog/errant-transactions-major-hurdle-for-gtid-based-failover-in-mysql-5-6/A4
213The tempting fix destroys your PITR chain: gtid-errant-reset-master "destroys the binary logs". The safe fix is injecting empty transactionshttps://www.percona.com/blog/fixing-errant-gtid-with-orchestrator-the-easy-way-out/A4
214GitLab's January 2017 outage: five backup mechanisms, none usable — pg_dump silently failing on a version mismatch, empty S3 uploads, misconfigured alert emails. Recovered from an incidental 6-hour-old staging snapshothttps://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/A4
215PITR reaches only the last binlog event you actually retained and shipped; retention or a purge sets a hard floorhttps://dev.mysql.com/doc/refman/8.0/en/point-in-time-recovery.htmlA4
216Kubernetes will not delete pods merely because a node is unreachable — the pod sits Terminating/Unknown indefinitely, to protect at-most-one identityhttps://github.com/kubernetes/kubernetes/issues/74689A4
217Force-deleting it breaks at-most-one: the API object goes away while the process may still be running and writing on the partitioned nodehttps://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/A4
218Terminating status is set by the API server, not the kubelet. On an unreachable node the container keeps running and keeps writing to the PVhttps://discuss.kubernetes.io/t/stateful-set-pod-remain-stuck-in-terminating-state-in-case-node-went-to-notready-state/22768A4
219ReadWriteOnce means one node, not one pod; storage attach is not fencinghttps://github.com/kubernetes/kubernetes/issues/61832 · https://access.redhat.com/solutions/2214541A4
220Single-observer polling is the documented false-positive trap: orchestrator's holistic detection requires the master be unreachable and its replicas independently confirm they cannot see ithttps://github.com/openark/orchestrator/blob/master/docs/failure-detection.mdA4
221Detection and recovery are separate: a detected DeadMaster can sit for minutes with no promotion, gated by auto-failover lists, admin blocks, anti-flapping, and failure typehttps://github.com/openark/orchestrator/blob/master/docs/failure-detection.mdA4
222kubectl delete pod as an HA test "might produce misleading results" — a graceful delete runs a clean shutdown; --grace-period=0 removes the API object without proving the process diedhttps://cloudnative-pg.io/documentation/1.23/failure_modes/A4
223Control plane and data plane fail separately in practice: Cloudflare's November 2023 incident kept the data plane serving for roughly two days of control-plane outagehttps://blog.cloudflare.com/post-mortem-on-cloudflare-control-plane-and-analytics-outage/A4

Upstream current-state check (verified August 2026)

#ClaimValue / verbatim quoteSourceAngle
224MySQL 9.7 is the current LTS, not the latest MySQL. The Innovation line moved to calendar versioning and is at 26.7.0; latest 9.7 patch is 9.7.2 (2026-07-28)"MySQL July 2026 GA Releases … MySQL 26.7.0 Innovation, MySQL 9.7.2 LTS, MySQL 8.4.11 LTS"https://blogs.oracle.com/mysql/mysql-july-2026-ga-releases-now-available ; https://dev.mysql.com/doc/relnotes/mysql/9.7/en/A3
225read_only permits updates from CONNECTION_ADMIN / deprecated SUPER"the server permits no client updates except from users who have the CONNECTION_ADMIN privilege (or the deprecated SUPER privilege). This variable is disabled by default."https://dev.mysql.com/doc/refman/9.7/en/server-system-variables.html#sysvar_read_onlyA3
226super_read_only is the actual barrier — it prohibits updates even from CONNECTION_ADMIN or SUPER"If the super_read_only system variable is also enabled, the server prohibits client updates even from users who have CONNECTION_ADMIN or SUPER."same page, #sysvar_super_read_onlyA3
227Setting super_read_only=ON implicitly forces read_only=ON; setting read_only=OFF implicitly forces super_read_only=OFF"Setting super_read_only to ON implicitly forces read_only to ON." … "Setting read_only to OFF implicitly forces super_read_only to OFF."same pageA3
228Fencing blocks; it does not cut off writers. The SET waits out in-flight work and can fail outright"The attempt fails and an error occurs if you have any explicit locks (acquired with LOCK TABLES) or have a pending transaction." … "The attempt blocks while other clients have any ongoing statement, active LOCK TABLES WRITE, or ongoing commit, until the locks are released and the statements and transactions end."same page, #sysvar_read_onlyA3
229Replication threads keep writing under super_read_only"Updates performed by replication threads, if the server is a replica." (listed under operations still permitted)same pageA3
230The legacy statements were removed in MySQL 8.4.0, not merely deprecated"The following SQL statements have been removed (replacements in brackets): START SLAVE (START REPLICA); STOP SLAVE (STOP REPLICA); SHOW SLAVE STATUS (SHOW REPLICA STATUS); … CHANGE MASTER TO (CHANGE REPLICATION SOURCE TO) …"https://dev.mysql.com/doc/relnotes/mysql/8.4/en/news-8-4-0.htmlA3
231Asymmetry worth teaching: log_slave_updates survives as a deprecated alias for log_replica_updates, while the statements were removed outright"log_slave_updates … Deprecated: Yes … Deprecated alias for log_replica_updates."https://dev.mysql.com/doc/refman/9.7/en/replication-options-binary-log.htmlA3
232Both threads must be stopped before CHANGE REPLICATION SOURCE TO … SOURCE_AUTO_POSITION = 1"Both the receiver thread and the applier thread must be stopped before issuing a CHANGE REPLICATION SOURCE TO statement that employs SOURCE_AUTO_POSITION = 1 …"https://dev.mysql.com/doc/refman/9.7/en/change-replication-source-to.htmlA3
233RESET REPLICA ALL removes all connection parameters; a later CHANGE REPLICATION SOURCE TO is required to use the instance as a replica again"If you want to remove all of the replication connection parameters, use RESET REPLICA ALL. … When you have used RESET REPLICA ALL, if you want to use the instance as a replica again, you need to issue a CHANGE REPLICATION SOURCE TO statement"https://dev.mysql.com/doc/refman/9.7/en/reset-replica.htmlA3
234CLONE INSTANCE privileges: donor needs BACKUP_ADMIN, recipient needs CLONE_ADMIN, which implies BACKUP_ADMIN and SHUTDOWN"The CLONE_ADMIN privilege includes BACKUP_ADMIN and SHUTDOWN privileges implicitly."https://dev.mysql.com/doc/refman/9.7/en/clone-plugin-remote.htmlA3
235Error 3707 is documented and does not indicate a clone failure"ERROR 3707 (HY000): Restart server failed (mysqld is not managed by supervisor process)." … "This error does not indicate a cloning failure. It means that the recipient MySQL server instance must be started again manually after the data is cloned."same pageA3
236A secure connection is genuinely required to clone encrypted data"A secure connection is required when cloning encrypted data regardless of whether this clause is specified."same pageA3
237GTID set grammar in MySQL 9.x includes tags, and a tag is part of the identityuuid_set: uuid:tag:interval:[tag:interval]... ; "When constructing a GTID set, a user-defined tag is treated as part of the UUID." Example: 3E11FA47-…-C80AA9429562:Domain_1:1-3:15-21:Domain_2:8-52https://dev.mysql.com/doc/refman/9.7/en/replication-gtids-concepts.htmlA3
238GTID_SUBSET() semantics"Given two sets of global transaction identifiers set1 and set2, returns true if all GTIDs in set1 are also in set2."https://dev.mysql.com/doc/refman/9.7/en/gtid-functions.htmlA3
239GTID_SUBTRACT() semantics — the divergence primitive"returns only those GTIDs from set1 that are not in set2"same pageA3
240sync_binlog=1 guarantee, verbatim"This is the safest setting… In the event of a power failure or operating system crash, transactions that are missing from the binary log are only in a prepared state. This permits the automatic recovery routine to roll back the transactions, which guarantees that no transaction is lost from the binary log." Default Value: 1https://dev.mysql.com/doc/refman/9.7/en/replication-options-binary-log.html#sysvar_sync_binlogA3
241innodb_flush_log_at_trx_commit=2 — the manual attributes loss to any unexpected mysqld process exit, not only power loss, and recommends =1"With a setting of 2, logs are written after each transaction commit and flushed to disk once per second. Transactions for which logs have not been flushed can be lost in a crash." … "any unexpected mysqld process exit can erase up to N seconds of transactions." … "If binary logging is enabled, set sync_binlog=1. Always set innodb_flush_log_at_trx_commit=1."https://dev.mysql.com/doc/refman/9.7/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commitA3
242Current MySQL keyring is component_keyring_file; the keyring_file plugin was removed in 8.4.09.7 manual lists only components: "component_keyring_file: Stores keyring data in a file local to the server host." 8.4.0 notes: "keyring_file: Use component_keyring_file instead… The keyring_file_data system variable has also been removed."https://dev.mysql.com/doc/refman/9.7/en/keyring.html ; https://dev.mysql.com/doc/relnotes/mysql/8.4/en/news-8-4-0.htmlA3
243Oracle's own caveat on file-based keyrings"the component_keyring_file and component_keyring_encrypted_file components are not intended as a regulatory compliance solution. Security standards such as PCI, F[IPS]…"https://dev.mysql.com/doc/refman/9.7/en/keyring.htmlA3
244external-dns DNSEndpoint is still externaldns.k8s.io/v1alpha1; an approved proposal targets v1beta1 with no date"apiVersion: externaldns.k8s.io/v1alpha1"; proposal "status: approved" showing externaldns.k8s.io/v1beta1https://kubernetes-sigs.github.io/external-dns/latest/docs/sources/crd/ ; https://kubernetes-sigs.github.io/external-dns/latest/docs/proposal/003-dnsendpoint-graduation-to-beta/A3
245NoExecute semantics"pods that do not tolerate the taint are evicted immediately"; "pods that tolerate the taint with a specified tolerationSeconds remain bound for the specified amount of time. After that time elapses, the node lifecycle controller evicts the Pods"https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/A3
246PDB protects only against voluntary evictions"The budget can only protect against voluntary evictions, not all causes of unavailability."https://kubernetes.io/docs/tasks/run-application/configure-pdb/A3
247REPLTAKEOVER exists, is an ADMIN-port, GLOBAL_TRANS command taking a timeout in seconds, and was introduced in Dragonfly v1.5.0 (2023-07-03). It has no official docs pageCI{"REPLTAKEOVER", CO::ADMIN | CO::GLOBAL_TRANS, -2, 0, 0, acl::kReplTakeOver}; // REPLTAKEOVER <seconds> [SAVE]; v1.5.0 notes "feat: Support atomic replica takeover … pull/1314"dragonflydb/dragonfly src/server/server_family.cc:4104, 3493; GitHub releases APIA3
248The Dragonfly pin has drifted two minors: repo pins v1.38.0 (2026-04-14), current stable is v1.40.1 (2026-08-06)docker.dragonflydb.io/dragonflydb/dragonfly:v1.38.0 vs upstream v1.40.1 2026-08-06T06:54:05Zplayground/manifests/failovergroup.yaml:87; GitHub releases APIA3
249sigs.k8s.io/controller-runtime v0.24.1 is the current latest; k8s.io/* at v0.36.2 is current minor, one patch behind. No material deprecation affects this operatorgo.mod:14-18; GitHub releases APIA3
250Bloodraven v1.0.0, published 2026-08-14. The repo is public and not archived"tag_name": "v1.0.0", "published_at": "2026-08-14T03:53:57Z"; "private": false, "visibility": "public", "archived": falsehttps://api.github.com/repos/ShipStream/bloodravenA3
251v1.0.0 ships BSL 1.1 (LICENSE, LICENSE-COMMERCIAL.md). GitHub reports "licenseInfo": { "key": "other" } because BSL is not an OSI SPDX key. The course points at version-appendix row D and the two re-check commandsls LICENSE*; https://api.github.com/repos/ShipStream/bloodravenA3

The playground counter application

#ClaimValue / verbatim quoteSourceAngle
252The counter application's pool is deliberately small and short-lived, which is what makes its failover behaviour observable in a labSetMaxOpenConns(5), SetConnMaxLifetime(30 * time.Second)playground/counter-app/main.go:89-90A1
253The counter application reports which site served a request and whether that site is writable, which is how a stale read is detected at allprobes @@global.read_only and @@hostnameplayground/counter-app/main.go:179-186A1
255Playground site load-balancer IPs, used as the DNSEndpoint A-record targets: iad is 10.96.100.10, pdx is 10.96.100.20- lbIP: 10.96.100.10 (iad) and - lbIP: 10.96.100.20 (pdx) in the captured group specplayground/chaos-results/20260429T142107Z/01-clean-primary-kill/cluster.yaml:93,110A2
256Playground DNS hostname and TTL"dns":{"hostname":"playground-db.example.local","ttl":10}same file, line 4A2
254The counter application writes to both MySQL and Dragonfly on every increment; the MySQL counter is durable and the Dragonfly counter is the session-continuity signal. After a planned failover with sessionsPreserved=true the Dragonfly counter survives; after an emergency failover it usually resets to 0site/content/docs/3.get-started/3.playground.md:291A2

Ungrounded

Claims considered and not used, with what was done about each.

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.