Skip to content

feat(operator): union all ProxySQLConfigs for a cluster into one desired runtime - #84

Merged
jesmarcannao merged 3 commits into
mainfrom
feat/union-proxysqlconfigs
Aug 4, 2026
Merged

feat(operator): union all ProxySQLConfigs for a cluster into one desired runtime#84
jesmarcannao merged 3 commits into
mainfrom
feat/union-proxysqlconfigs

Conversation

@jesmarcannao

@jesmarcannao jesmarcannao commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

The operator treats each ProxySQLConfig as authoritative for the cluster's entire runtime: buildDesired builds the desired state from one CR, and proxysqlclient.Sync DELETEs + re-INSERTs exactly those rows on every replica. So two configs for one cluster oscillate — each reconcile DELETEs the other's servers/users/rules and re-LOADs its own — and client connections landing in the DELETE+LOAD ... TO RUNTIME window hit Access denied / Lost connection. Adding a second ProxySQLConfig to a running cluster destabilises it, forcing a one-config-per-cluster workaround.

(Found downstream in the ProxySQL SaaS byoc live runs — a companion <cluster>-vars config + an app config for backends/users/rules oscillated and dropped sysbench traffic.)

Change

Union all ProxySQLConfigs whose clusterRef points at the cluster into a single desired runtime, so multiple configs compose instead of fighting:

  • proxysqlclient.Union (new) merges Desired states per key with last-writer-wins: servers by hostgroup:host:port, users by username, query rules by ruleId, replication hostgroups by writer hg, hostgroup-attrs by hg, proxysql-servers by host:port, the three variable maps by name; sqlStatements concatenated in order. Output sorted by key so the sync fingerprint is stable.
  • buildDesiredconfigToDesired (per-config) + buildUnionedDesired (lists the cluster's configs, orders by name for deterministic last-writer-wins, merges). proxysql_servers auto-population (Empty spec.proxysqlServers wipes the cnf-seeded peer table, disabling ProxySQL cluster sync #39) now happens once at cluster level, only when no config set an explicit list — a second config can't wipe it.
  • Reconcile builds the union: every sibling config reconciles to the same desired state → they converge, oscillation stops.
  • Deletion re-pushes the union of the remaining configs (excludeName) instead of clearing the runtime; falls back to cleanupDesired only for the last config, and never wedges deletion on a sibling's missing Secret.

Tests

  • Union unit tests: distinct-key compose, same-key last-writer-wins, determinism, sqlStatements order, empty-stays-nil.
  • New envtest: two configs on one cluster both survive (server from A + rule from B), plus the exclude/deletion path.
  • Existing auto-population + drift tests retargeted to buildUnionedDesired.
  • Full make test envtest suite green; go vet + gofmt clean; make manifests shows no CRD drift (no API changes).

Docs

Adds a "Multiple ProxySQLConfigs per cluster" section to docs/user-guide/configuration.md describing the union + last-writer-wins semantics.

Compatibility

No CRD/API changes — purely reconcile behaviour. Single-config clusters are unaffected (a one-element union == the old buildDesired, modulo the moved auto-populate which is preserved). Suitable for a minor release (e.g. v0.5.0).

Summary by CodeRabbit

  • New Features

    • Multiple ProxySQL configurations can now target the same cluster.
    • Configuration entries are combined deterministically, with later values taking precedence when keys overlap.
    • SQL statements remain ordered during configuration merging.
  • Bug Fixes

    • Removing one configuration now preserves settings from remaining configurations instead of clearing the cluster.
    • Peer settings remain consistent across configuration and cleanup operations.
  • Documentation

    • Added guidance for composing multiple configurations and explaining merge and deletion behavior.

…red runtime

The operator treated each ProxySQLConfig CR as authoritative for the cluster's
entire runtime: buildDesired built the desired state from ONE config, and Sync
DELETEs+re-INSERTs exactly those rows on every replica. So two ProxySQLConfigs
targeting one cluster oscillate — each reconcile DELETEs the other's servers/
users/rules and re-LOADs its own — and client connections landing in the
DELETE+LOAD window hit 'Access denied' / 'Lost connection'. Adding any second
config to a running cluster destabilised it, forcing an out-of-band
one-config-per-cluster workaround.

Union all ProxySQLConfigs whose clusterRef points at the cluster into a single
desired runtime, so multiple configs compose instead of fighting:

- new proxysqlclient.Union merges Desired states per key with last-writer-wins
  (servers by hostgroup:host:port, users by username, rules by ruleId, repl-
  hostgroups by writer hg, hostgroup-attrs by hg, proxysql-servers by host:port,
  variable maps by name; sqlStatements concatenated). Output is sorted by key so
  the sync fingerprint is stable.
- buildDesired is split into a per-config translator (configToDesired) + a new
  buildUnionedDesired that lists all configs for the cluster, orders them by name
  (deterministic last-writer-wins) and merges. proxysql_servers auto-population
  (#39) now happens once at cluster level, only when no config set an explicit
  list — so a second config can't wipe it.
- Reconcile builds the union: every sibling config reconciles to the SAME desired
  state, so they converge and stop oscillating.
- Deletion re-pushes the union of the REMAINING configs (excludeName), instead of
  clearing the whole runtime; falls back to cleanupDesired only for the last
  config, and never wedges deletion on a sibling's missing Secret.

Tests: Union unit tests (distinct-key compose, same-key last-writer-wins,
determinism, sqlStatements order, empty-stays-nil); an envtest proving two
configs on one cluster both survive + the exclude/deletion path; existing
auto-population + drift tests retargeted to buildUnionedDesired. Full envtest
suite green; go vet + gofmt clean; no CRD drift. Docs: 'Multiple ProxySQLConfigs
per cluster' section in the configuration user guide.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: ec9c68a5-09cf-4550-9c8e-b9e8962e2bf2

📥 Commits

Reviewing files that changed from the base of the PR and between 59284e5 and fbf1f66.

⛔ Files ignored due to path filters (1)
  • operator/go.sum is excluded by !**/*.sum, !operator/go.sum
📒 Files selected for processing (1)
  • operator/go.mod

📝 Walkthrough

Walkthrough

The controller now combines active ProxySQLConfig resources that target the same cluster. The union uses deterministic ordering and last-writer-wins semantics. Deletion preserves contributions from remaining resources. Tests and documentation cover the new behavior.

Changes

ProxySQLConfig unioning

Layer / File(s) Summary
Deterministic desired-state union
operator/internal/proxysqlclient/union.go, operator/internal/proxysqlclient/union_test.go, operator/go.mod
Added proxysqlclient.Union with keyed last-writer-wins merging, ordered SQL concatenation, deterministic output, and updated indirect module versions.
Cluster-wide reconciliation and cleanup
operator/internal/controller/proxysqlconfig_controller.go
The controller now unions active configurations for each cluster, auto-populates peers once, and preserves remaining state during deletion.
Union behavior and reconciliation coverage
operator/internal/controller/proxysqlconfig_controller_test.go, operator/internal/controller/cleanup_desired_test.go, docs/user-guide/configuration.md
Added coverage for multi-resource composition and deletion exclusion. Updated peer consistency tests and documented merge behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • ProxySQL/proxysql-saas#956 — Directly addresses unioning all ProxySQLConfig resources per cluster with deterministic last-writer-wins behavior.

Possibly related PRs

Suggested reviewers: renecannao

Sequence Diagram(s)

sequenceDiagram
  participant ProxySQLConfigResources
  participant buildUnionedDesired
  participant configToDesired
  participant proxysqlclientUnion
  participant ProxySQLCluster
  ProxySQLConfigResources->>buildUnionedDesired: list and sort active cluster configs
  buildUnionedDesired->>configToDesired: translate each config
  configToDesired-->>buildUnionedDesired: individual Desired states
  buildUnionedDesired->>proxysqlclientUnion: merge Desired states
  proxysqlclientUnion-->>buildUnionedDesired: deterministic union
  buildUnionedDesired->>ProxySQLCluster: apply unioned desired state
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: unioning all ProxySQLConfig resources for a cluster into one desired runtime.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/union-proxysqlconfigs

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
docs/user-guide/configuration.md (1)

50-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document raw SQL statement ordering.

The merge rules describe keyed sections only. Document that unkeyed SQL statements concatenate in ProxySQLConfig name order. Users need this rule when SQL statements in separate configs have ordering dependencies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/user-guide/configuration.md` around lines 50 - 54, Update the merge
rules documentation in the configuration guide to state that unkeyed SQL
statements are concatenated in ProxySQLConfig name order, preserving that
ordering for statements with dependencies.
operator/internal/controller/proxysqlconfig_controller_test.go (1)

873-899: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a same-key conflict test for name ordering.

This test only combines disjoint sections. It cannot detect a regression in buildUnionedDesired name sorting or last-writer-wins behavior.

Create two configs with the same section key and different values. Create them in reverse lexical order. Assert that the config with the lexically last name wins.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/proxysqlconfig_controller_test.go` around lines
873 - 899, Extend the buildUnionedDesired test with two configs created in
reverse lexical order that both define the same section key but different
values. Assert the lexically last config’s value is retained, verifying
deterministic name ordering and last-writer-wins behavior while preserving the
existing disjoint-section assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@operator/internal/controller/proxysqlconfig_controller.go`:
- Around line 194-198: Update the error-condition handling after
buildUnionedDesired in the reconciliation flow to use DesiredBuildFailed by
default for all union-build errors, including list failures. Only use
UserSecretError when the returned error is explicitly classified as a Secret
resolution failure, preserving the existing status error message and condition
update behavior.
- Around line 677-693: Update the deletion flow around buildUnionedDesired so a
union-build error does not fall back to cleanupDesired; propagate the error
through the finalization path and requeue. Only apply full cleanupDesired after
a successful union build reports any == false, while using the remaining union
when any == true. Add a deletion test covering a remaining configuration whose
Secret lookup fails and assert the finalization error is requeued.

---

Nitpick comments:
In `@docs/user-guide/configuration.md`:
- Around line 50-54: Update the merge rules documentation in the configuration
guide to state that unkeyed SQL statements are concatenated in ProxySQLConfig
name order, preserving that ordering for statements with dependencies.

In `@operator/internal/controller/proxysqlconfig_controller_test.go`:
- Around line 873-899: Extend the buildUnionedDesired test with two configs
created in reverse lexical order that both define the same section key but
different values. Assert the lexically last config’s value is retained,
verifying deterministic name ordering and last-writer-wins behavior while
preserving the existing disjoint-section assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 47199963-0513-42ab-a8a3-64270d2613cd

📥 Commits

Reviewing files that changed from the base of the PR and between 0a27e2b and dd91a02.

📒 Files selected for processing (6)
  • docs/user-guide/configuration.md
  • operator/internal/controller/cleanup_desired_test.go
  • operator/internal/controller/proxysqlconfig_controller.go
  • operator/internal/controller/proxysqlconfig_controller_test.go
  • operator/internal/proxysqlclient/union.go
  • operator/internal/proxysqlclient/union_test.go

Comment on lines +194 to +198
// 3) Assemble the desired runtime from the UNION of all ProxySQLConfigs targeting this
// cluster (#956) — not just this one — so multiple configs compose one runtime instead of
// each being authoritative and oscillating. Every sibling config reconciles to the same
// union, so they converge. User passwords are resolved from Secrets here.
desired, _, err := r.buildUnionedDesired(ctx, &cluster, b, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a condition reason that matches every union-build failure.

At Line 198, buildUnionedDesired can return a list failure from Line 413. The existing branch reports every error as UserSecretError. A list failure then has the wrong condition reason. Use DesiredBuildFailed unless the error is explicitly classified as a Secret resolution failure.

Proposed fix
- r.setCfgCondition(&cfg, cfgCondReady, metav1.ConditionFalse, "UserSecretError", err.Error())
+ r.setCfgCondition(&cfg, cfgCondReady, metav1.ConditionFalse, "DesiredBuildFailed", err.Error())

As per path instructions, focus on "error/condition surfacing on .status".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/proxysqlconfig_controller.go` around lines 194 -
198, Update the error-condition handling after buildUnionedDesired in the
reconciliation flow to use DesiredBuildFailed by default for all union-build
errors, including list failures. Only use UserSecretError when the returned
error is explicitly classified as a Secret resolution failure, preserving the
existing status error message and condition update behavior.

Source: Path instructions

Comment on lines +677 to +693
// Push the runtime the cluster should have WITHOUT this config: the union of the
// remaining ProxySQLConfigs (#956). If sibling configs still target the cluster, deleting
// one must NOT wipe their servers/users/rules — it re-pushes their combined state. Only
// when this was the last config do we fall back to cleanupDesired, which DELETEs every
// managed table and LOAD/SAVEs each section; there, when the config's peer list was
// operator-populated (empty spec.proxysqlServers) the auto-derived peers are re-pushed
// instead of cleared (#42), and an explicit list is cleared like every other table.
// Variables are left as-is: ProxySQL has no "unset". If the remaining union can't be built
// (e.g. a sibling's Secret is missing) we must not wedge deletion — fall back to cleanup.
cleanupState := cleanupDesired(b, len(cfg.Spec.ProxySQLServers) == 0)
if remaining, any, err := r.buildUnionedDesired(ctx, &cluster, b, cfg.Name); err != nil {
log.Info("could not build remaining-configs union for cleanup; falling back to full cleanup",
"cluster", cluster.Name, "error", err.Error())
} else if any {
cleanupState = remaining
}
cleaned, errs := r.applyToReplicas(ctx, addrs, radminPassword, cleanupState, dialTLS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not clean up when the remaining union is unavailable.

If configuration listing fails or a sibling Secret cannot resolve, this branch applies cleanupDesired instead of the remaining union. That clears surviving structured configuration before finalizer release. Requeue the finalization error. Run full cleanup only after a successful union build reports any == false. Add a deletion test with a remaining configuration whose Secret lookup fails.

Proposed fix
 if remaining, any, err := r.buildUnionedDesired(ctx, &cluster, b, cfg.Name); err != nil {
-  log.Info("could not build remaining-configs union for cleanup; falling back to full cleanup",
-    "cluster", cluster.Name, "error", err.Error())
+  log.Error(err, "cleanup pending: could not build remaining-configs union",
+    "cluster", cluster.Name)
+  return ctrl.Result{RequeueAfter: requeueAfterTransient}, nil
 } else if any {
   cleanupState = remaining
 }

This conflicts with the PR objective to preserve remaining configuration during deletion. As per path instructions, use "errors via Error()".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Push the runtime the cluster should have WITHOUT this config: the union of the
// remaining ProxySQLConfigs (#956). If sibling configs still target the cluster, deleting
// one must NOT wipe their servers/users/rules — it re-pushes their combined state. Only
// when this was the last config do we fall back to cleanupDesired, which DELETEs every
// managed table and LOAD/SAVEs each section; there, when the config's peer list was
// operator-populated (empty spec.proxysqlServers) the auto-derived peers are re-pushed
// instead of cleared (#42), and an explicit list is cleared like every other table.
// Variables are left as-is: ProxySQL has no "unset". If the remaining union can't be built
// (e.g. a sibling's Secret is missing) we must not wedge deletion — fall back to cleanup.
cleanupState := cleanupDesired(b, len(cfg.Spec.ProxySQLServers) == 0)
if remaining, any, err := r.buildUnionedDesired(ctx, &cluster, b, cfg.Name); err != nil {
log.Info("could not build remaining-configs union for cleanup; falling back to full cleanup",
"cluster", cluster.Name, "error", err.Error())
} else if any {
cleanupState = remaining
}
cleaned, errs := r.applyToReplicas(ctx, addrs, radminPassword, cleanupState, dialTLS)
// Push the runtime the cluster should have WITHOUT this config: the union of the
// remaining ProxySQLConfigs (`#956`). If sibling configs still target the cluster, deleting
// one must NOT wipe their servers/users/rules — it re-pushes their combined state. Only
// when this was the last config do we fall back to cleanupDesired, which DELETEs every
// managed table and LOAD/SAVEs each section; there, when the config's peer list was
// operator-populated (empty spec.proxysqlServers) the auto-derived peers are re-pushed
// instead of cleared (`#42`), and an explicit list is cleared like every other table.
// Variables are left as-is: ProxySQL has no "unset". If the remaining union can't be built
// (e.g. a sibling's Secret is missing) we must not wedge deletion — fall back to cleanup.
cleanupState := cleanupDesired(b, len(cfg.Spec.ProxySQLServers) == 0)
if remaining, any, err := r.buildUnionedDesired(ctx, &cluster, b, cfg.Name); err != nil {
log.Error(err, "cleanup pending: could not build remaining-configs union",
"cluster", cluster.Name)
return ctrl.Result{RequeueAfter: requeueAfterTransient}, nil
} else if any {
cleanupState = remaining
}
cleaned, errs := r.applyToReplicas(ctx, addrs, radminPassword, cleanupState, dialTLS)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/proxysqlconfig_controller.go` around lines 677 -
693, Update the deletion flow around buildUnionedDesired so a union-build error
does not fall back to cleanupDesired; propagate the error through the
finalization path and requeue. Only apply full cleanupDesired after a successful
union build reports any == false, while using the remaining union when any ==
true. Add a deletion test covering a remaining configuration whose Secret lookup
fails and assert the finalization error is requeued.

Source: Path instructions

golangci-lint 'modernize' flagged the manual map-copy loop and sort.Slice in
union.go; use maps.Copy and slices.Sort. No behavior change.
Trivy flags HIGH CVE-2026-56852 in golang.org/x/text v0.37.0 (norm.Iter infinite
loop). go get golang.org/x/text@v0.39.0 + go mod tidy; the golang.org/x/* family
moves as a compatible set (net 0.55->0.56, sys, term, tools). All indirect, no
direct-dep changes. Build + full envtest suite green.
@jesmarcannao
jesmarcannao merged commit 4a49c86 into main Aug 4, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants