Read-only reader sites: first-party non-promotable replicas
Motivation
Two consumers need read-only replicas that reliably follow the active primary across failovers:
- App read pool — read-only replicas exposed to the WMS via
externalTrafficPolicy: Local so client source IPs are preserved (see platform TODO, "External MySQL access for customer ETL/BI" — the ETP: Local requirement applies to any externally exposed MySQL Service because ipBlock NetworkPolicy rules match SNAT'd node IPs otherwise).
- Customer reader — a third read-only server per group for customer ETL / CDC / OLAP workloads. No backup for this instance: if it is lost, we reschedule it onto another node and it does a fresh clone from the primary. SLA target ≈ 1 hour.
Both are the same operator feature — a non-promotable follower site — differing only in Service exposure and MySQL accounts, which stay platform-owned (GitOps NetworkPolicy allowlist, host-scoped accounts, DNS). The topology mechanics (follow the master, re-clone on loss, health-labeled endpoints) must live in Bloodraven: nothing outside the operator can repoint replication or trigger clones without racing it.

What already exists (~80%)
role: dr-only (api/v1alpha1/types.go:254-283) — passive follower: replicates from the active primary, never auto-promoted, rejected from splitBrainPolicy.sitePriorities, cloned after primary-candidates during bootstrap.
- Auto-clone of empty sites —
startBootstrap / detectEmptySite (internal/controller/topology.go:1991-2042): a site that comes up with an empty data dir is cloned from the primary on a subsequent poll cycle. This is the "reschedule + fresh clone" recovery path; no code needed for the no-backup story. kubectl bloodraven reclone covers manual day-2 forcing.
- Services — per-site
mysql-<group>-<site> (reconcileSiteService), group-wide mysql-<group>-primary (selector role=primary) and mysql-<group>-replicas (selector role=replica, healthy=yes) (internal/controller/reconciler.go:1234-1352). The healthy=yes label gating is exactly what ETP: Local needs.
- Unplanned-failover repoint —
checkRecovery (topology.go:2240) scans all non-active read-only sites with broken replication and repoints them at the current primary, not just the old primary. A follower whose source died gets repointed automatically.
credentials.readOnlySecret — managed read-only application account.
Gaps (the actual feature work)
1. Planned-switchover repoint — correctness gap, exists today for dr-only
replicaStatusHealthy (topology.go:2497) checks only IORunning && SQLRunning && SourceHost != "" — it never compares SourceHost to the active primary. After a planned switchover the demoted site keeps serving binlogs, so a third follower stays chained (new-primary → demoted-site → reader) indefinitely: doubled lag, and the reader dies with the demoted site.

Fix — follower source convergence: once a promotion is confirmed writable (and as a periodic invariant in the poll loop), for every non-active site with running replication whose Source_Host ≠ active primary host: STOP REPLICA; CHANGE REPLICATION SOURCE TO ...; START REPLICA. Guards: active site confirmed writable, no split-brain, GTID subset check before repointing (reuse the divergence detection from old-primary recovery). This also fixes the same latent bug for existing dr-only DR sites.
2. Role semantics — new read-only role
dr-only is close but is a DR site: SiteSpec requires lbIP (DNS steering) and taintNodeSelector (app-pod eviction), both meaningless for a reader. Rather than overloading dr-only, add a third SiteRole:
spec:
sites:
- name: dc1
role: primary-candidate
# ...
- name: dc2
role: primary-candidate
# ...
- name: rdr
role: read-only # NEW
zone: iad
storage: { storageClassName: local-path, size: 200Gi }
# lbIP / taintNodeSelector optional for this role
serviceTemplate: # NEW: per-site override (gap 3)
type: NodePort
externalTrafficPolicy: Local
mysqlConf: # NEW, optional: per-site overrides (gap 4)
innodb-buffer-pool-size: "8G"
Semantics (mostly shared with dr-only via IsPromotable() == false):
- Never auto-promoted; never a planned-failover target; rejected from
splitBrainPolicy.sitePriorities; fenced if anomalously writable (same as dr-only).
- Does not count toward the ≥2 primary-candidate CEL minimum (unchanged rule).
- Does not participate in DNS steering or the
db-readonly taint choreography.
- Never selected as a backup source or clone donor.
- Replication lag / unavailability of a
read-only site must not degrade the group Ready condition — surface per-site status (and optionally a separate ReadersHealthy condition + metric) instead. A wedged OLAP reader must not page like a broken standby.
- CRD validation: relax
lbIP/taintNodeSelector to optional when role == read-only (XValidation), keep them required for primary-candidate/dr-only for compatibility.
3. Per-site Service exposure
spec.serviceTemplate currently applies one Type to every Service in the group and has no externalTrafficPolicy at all. Needed:
sites[].serviceTemplate override: type, externalTrafficPolicy, nodePort, annotations — applied to that site's mysql-<group>-<site> Service only.
externalTrafficPolicy added to the group-level ServiceTemplate too (for the -replicas Service when the app read pool is exposed).
- Primary/site Services keep current defaults (
ClusterIP) unless overridden.
4. Nice-to-haves
sites[].mysqlConf overrides merged over spec.mysqlConf (bigger buffer pool on an OLAP box; longer binlog_expire_logs_seconds if CDC ever tails the reader).
- Per-role
replication.maxLagSeconds override so reader lag alerting can be looser than standby lag.
- Document clone-donor impact: a reader re-clone reads the primary at full speed; note expectation (or add a throttle knob later).
Reader loss / recovery flow (target behavior)

Out of scope (platform-owned)
- NetworkPolicy IP allowlists from
tenants.git (GitOps), host-scoped ETL accounts ('acme_etl'@'<ip>', REQUIRE SSL), customer DNS names, node provisioning.
- Connection-kill on demote (
offline_mode=ON) — separate platform TODO item; may later hook Bloodraven's demotion path.
- Backups of reader sites — explicitly none; recovery is always re-clone.
- Proxy layer (ProxySQL/HAProxy) — rejected in platform decision 2026-07-17.
- Per-tenant filtered readers (replica-side replication filters / partial single-schema replicas) — evaluated and rejected (2026-07-17). The reader is always a full replica; MySQL grant-scoped accounts are the tenant isolation boundary (consistent with the platform ETL/BI decision). A tenant requiring hard data isolation gets their own dedicated failover group instead — filtering never enters the operator.
Acceptance criteria
Diagram sources (.tldr, editable on tldraw.com) live next to the PNGs on the issue-assets/read-only-readers branch — keep that branch alive while this issue is open.
Read-only reader sites: first-party non-promotable replicas
Motivation
Two consumers need read-only replicas that reliably follow the active primary across failovers:
externalTrafficPolicy: Localso client source IPs are preserved (see platform TODO, "External MySQL access for customer ETL/BI" — the ETP: Local requirement applies to any externally exposed MySQL Service becauseipBlockNetworkPolicy rules match SNAT'd node IPs otherwise).Both are the same operator feature — a non-promotable follower site — differing only in Service exposure and MySQL accounts, which stay platform-owned (GitOps NetworkPolicy allowlist, host-scoped accounts, DNS). The topology mechanics (follow the master, re-clone on loss, health-labeled endpoints) must live in Bloodraven: nothing outside the operator can repoint replication or trigger clones without racing it.
What already exists (~80%)
role: dr-only(api/v1alpha1/types.go:254-283) — passive follower: replicates from the active primary, never auto-promoted, rejected fromsplitBrainPolicy.sitePriorities, cloned after primary-candidates during bootstrap.startBootstrap/detectEmptySite(internal/controller/topology.go:1991-2042): a site that comes up with an empty data dir is cloned from the primary on a subsequent poll cycle. This is the "reschedule + fresh clone" recovery path; no code needed for the no-backup story.kubectl bloodraven reclonecovers manual day-2 forcing.mysql-<group>-<site>(reconcileSiteService), group-widemysql-<group>-primary(selectorrole=primary) andmysql-<group>-replicas(selectorrole=replica, healthy=yes) (internal/controller/reconciler.go:1234-1352). Thehealthy=yeslabel gating is exactly what ETP: Local needs.checkRecovery(topology.go:2240) scans all non-active read-only sites with broken replication and repoints them at the current primary, not just the old primary. A follower whose source died gets repointed automatically.credentials.readOnlySecret— managed read-only application account.Gaps (the actual feature work)
1. Planned-switchover repoint — correctness gap, exists today for
dr-onlyreplicaStatusHealthy(topology.go:2497) checks onlyIORunning && SQLRunning && SourceHost != ""— it never comparesSourceHostto the active primary. After a planned switchover the demoted site keeps serving binlogs, so a third follower stays chained (new-primary → demoted-site → reader) indefinitely: doubled lag, and the reader dies with the demoted site.Fix — follower source convergence: once a promotion is confirmed writable (and as a periodic invariant in the poll loop), for every non-active site with running replication whose
Source_Host≠ active primary host:STOP REPLICA; CHANGE REPLICATION SOURCE TO ...; START REPLICA. Guards: active site confirmed writable, no split-brain, GTID subset check before repointing (reuse the divergence detection from old-primary recovery). This also fixes the same latent bug for existingdr-onlyDR sites.2. Role semantics — new
read-onlyroledr-onlyis close but is a DR site:SiteSpecrequireslbIP(DNS steering) andtaintNodeSelector(app-pod eviction), both meaningless for a reader. Rather than overloadingdr-only, add a thirdSiteRole:Semantics (mostly shared with
dr-onlyviaIsPromotable() == false):splitBrainPolicy.sitePriorities; fenced if anomalously writable (same asdr-only).db-readonlytaint choreography.read-onlysite must not degrade the groupReadycondition — surface per-site status (and optionally a separateReadersHealthycondition + metric) instead. A wedged OLAP reader must not page like a broken standby.lbIP/taintNodeSelectorto optional whenrole == read-only(XValidation), keep them required forprimary-candidate/dr-onlyfor compatibility.3. Per-site Service exposure
spec.serviceTemplatecurrently applies oneTypeto every Service in the group and has noexternalTrafficPolicyat all. Needed:sites[].serviceTemplateoverride:type,externalTrafficPolicy,nodePort,annotations— applied to that site'smysql-<group>-<site>Service only.externalTrafficPolicyadded to the group-levelServiceTemplatetoo (for the-replicasService when the app read pool is exposed).ClusterIP) unless overridden.4. Nice-to-haves
sites[].mysqlConfoverrides merged overspec.mysqlConf(bigger buffer pool on an OLAP box; longerbinlog_expire_logs_secondsif CDC ever tails the reader).replication.maxLagSecondsoverride so reader lag alerting can be looser than standby lag.Reader loss / recovery flow (target behavior)
Out of scope (platform-owned)
tenants.git(GitOps), host-scoped ETL accounts ('acme_etl'@'<ip>',REQUIRE SSL), customer DNS names, node provisioning.offline_mode=ON) — separate platform TODO item; may later hook Bloodraven's demotion path.Acceptance criteria
role: read-only;lbIP/taintNodeSelectoroptional for that role; generated CRDs synced tocharts/bloodraven/crds/; Helmclusterrole.yamluntouched or mirrored per pre-PR gate.read-only(anddr-only) site is repointed to the new primary within one poll cycle of writable-confirm (component test + envtest where applicable).checkRecoverypath (regression test).Ready;kubectl bloodraven reclone <site>works against a reader.serviceTemplaterenders NodePort +externalTrafficPolicy: Localonmysql-<group>-<site>only.Ready; per-site status and metrics still report it.read-onlysite in the k3d playground; chaos scenario "kill reader node → auto re-clone → healthy behind Services" (release profile); planned-switchover scenario asserts reader follows the new primary (not chained).docs/topology + failover pages updated; any new structured-logmsgstrings added todocs/docs/log-schema.mdx.Diagram sources (
.tldr, editable on tldraw.com) live next to the PNGs on theissue-assets/read-only-readersbranch — keep that branch alive while this issue is open.