Skip to content

Read-only reader sites: first-party non-promotable replicas (app RO pool + customer ETL/CDC/OLAP) #115

Description

@colinmollenhour

Read-only reader sites: first-party non-promotable replicas

Motivation

Two consumers need read-only replicas that reliably follow the active primary across failovers:

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

Target topology

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 sitesstartBootstrap / 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 repointcheckRecovery (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.

Repoint behavior: unplanned works, planned chains

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)

Reader recovery flow

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

  • CRD accepts role: read-only; lbIP/taintNodeSelector optional for that role; generated CRDs synced to charts/bloodraven/crds/; Helm clusterrole.yaml untouched or mirrored per pre-PR gate.
  • Follower source convergence: after a planned switchover, a read-only (and dr-only) site is repointed to the new primary within one poll cycle of writable-confirm (component test + envtest where applicable).
  • Unplanned failover: reader repoints via existing checkRecovery path (regression test).
  • Empty reader site auto-clones from primary without affecting group Ready; kubectl bloodraven reclone <site> works against a reader.
  • Per-site serviceTemplate renders NodePort + externalTrafficPolicy: Local on mysql-<group>-<site> only.
  • Reader lag/outage does not flip group Ready; per-site status and metrics still report it.
  • Playground: 3rd read-only site 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: docs/ topology + failover pages updated; any new structured-log msg strings added to docs/docs/log-schema.mdx.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions