Skip to content

fix(cruisecontrol): sequence CC rolls around scaling operations (fixes #301) - #304

Closed
amuraru wants to merge 2 commits into
adobe:masterfrom
amuraru:cc-restart-race
Closed

fix(cruisecontrol): sequence CC rolls around scaling operations (fixes #301)#304
amuraru wants to merge 2 commits into
adobe:masterfrom
amuraru:cc-restart-race

Conversation

@amuraru

@amuraru amuraru commented Aug 11, 2026

Copy link
Copy Markdown

Fixes #301.

Replaces the "flip capacity.json out of the pod-template hash" approach (#303): that flip drops the CC roll that loads a new broker's capacity into CruiseControl (CC reads capacity.json only at startup and estimates unknown brokers via a -1 default koperator doesn't generate), silently breaking upscale. This PR keeps the roll and sequences it — no dependency on capacity estimation or a -1 default.

The race

A broker add/remove regenerates capacity.json, which is hashed into the CC pod-template annotation and rolls the CC Deployment. That roll (KafkaClusterReconciler) and the add_broker/remove_broker op (CruiseControlOperationReconciler) are driven by two uncoordinated controllers, so an op can race the roll: the fresh CC pod loses its in-memory task and resets its metric-sampling window, stalling scaling. The op can even be created before the roll is triggered — KafkaClusterReconciler marks a new broker GracefulUpscaleRequired (which makes the task controller create add_broker) before, and independently of, the CC reconciler that regenerates capacity.json and patches the Deployment.

Removal is chicken-and-egg: marking a broker GracefulDownscaleRequired needs a CC-ready cc.BrokersWithState(), but in the pre-downscale window capacity.json is regenerated with a fallback entry for the departing broker, which rolls CC and keeps it un-ready — so the downscale is never marked and no remove_broker op is created. This stalled the KRaft removal e2e on master.

Fix — sequence the roll, keep exact per-broker capacity

  1. Don't roll CC during removal (load-bearing). isBrokerRemovalPending (broker in status, dropped from spec) broadens the existing "reuse the deployed capacity.json" guard so CC isn't rolled in the pre-downscale window → stays ready → downscale marked → remove_broker created. mergeCapacityConfig reuses the deployed config but additively merges newly-joined brokers (user-provided capacity if the CR defines one, else generated), keeping departing entries verbatim — so a mixed add+remove still writes the new broker's capacity, and a pure removal returns the deployed config byte-for-byte (no spurious roll). While a downscale is actively running (a remove_broker task is in flight) the deployed config is reused verbatim with no merge — appending a concurrently-added broker would roll CC and kill that in-flight removal; the added broker's capacity is written by the next full regeneration once the downscale completes (its add_broker is deferred until then by the roll gate).
  2. Gate CC ops on a Deployment that's safe to submit to (requeueIfCCDeploymentNotRolledOut). Every op except stop_execution is deferred while the CC Deployment is mid-rollout — a restart wipes the in-flight task, and a roll can be triggered by any hashed pod-template change (capacity.json, cruisecontrol.properties, clusterConfigs.json, log4j.properties), not just broker scaling. Because a capacity roll can also land just after an op is submitted, every gated op additionally defers while the pod-template capacity hash lags the current ConfigMap capacity.json hash; add_broker also requires that capacity.json to already contain the target broker id(s) — closing both the "op created before the roll is triggered" and "op submitted mid-roll" windows, so an add always runs against a CC that has loaded the new broker's exact capacity. Every check fails open when the Deployment/ConfigMap is absent or capacity isn't koperator-managed (never deadlocks); a wedged rollout (ProgressDeadlineExceeded) is logged at error level and backs off its requeue.

Asymmetric, so no deadlock: removal reuses capacity.json (no roll); an add still rolls CC and the op waits for that roll to settle. Correct for homogeneous and heterogeneous clusters (no estimation / -1 dependency).

Tests

  • Unit: TestIsBrokerRemovalPending; TestGenerateCapacityConfigReuseAndMerge (verbatim reuse on pure removal, merge on add incl. a user-provided new broker, and an unparseable deployed config reused verbatim); TestCapacityConfigContainsBrokers; TestCapacityConfigHash; TestIsDeploymentRolling; TestDeploymentRolloutTimedOut; and TestRequeueIfCCDeploymentNotRolledOut, which drives the gate directly with fake Deployment/ConfigMap states (stop_execution never gated, rebalance/remove_disks deferred only while rolling, hash mismatch and add_broker-with-absent-capacity requeue, fail-open, execute). The full controllers/tests envtest suite passes.
  • e2e testKRaftBrokerScaling: upscale 3→4 (add broker 103) then downscale 4→3, asserting exactly one add_broker then one remove_broker op, exact broker/controller ids (not just counts), capacity.json gaining then dropping broker 103, the CC pod-template capacity hash carrying that capacity.json, and controllers untouched. Samples omit a -1 universal-default capacityConfig so per-broker generation is actually exercised; the quiescent-CC gate counts queued and retry-pending ops as in-flight.

Verified locally: go build ./..., go vet -tags e2e ./..., golangci-lint (no new issues), unit tests, and the controllers/tests envtest suite pass.

🤖 Generated with Claude Code

@amuraru amuraru changed the title fix(cruisecontrol): don't roll CruiseControl on capacity.json changes (fixes #301) fix(cruisecontrol): sequence CC rolls around scaling operations (fixes #301) Aug 12, 2026
@amuraru
amuraru force-pushed the cc-restart-race branch 14 times, most recently from c1e2dac to 2723128 Compare August 18, 2026 13:20
…adobe#301)

A broker add/remove regenerates capacity.json, which is hashed into the CC
pod-template annotation and rolls the CC Deployment. That roll
(KafkaClusterReconciler) and the add_broker/remove_broker CruiseControlOperation
(CruiseControlOperationReconciler) are driven by two uncoordinated controllers,
so a roll can land on an in-flight op - the fresh CC pod loses its in-memory task
and resets its metric-sampling window, stalling scaling. Removal is additionally
chicken-and-egg: marking a broker GracefulDownscaleRequired needs a CC-ready
cc.BrokersWithState(), but the pre-downscale capacity.json regeneration rolls CC
un-ready, so the remove_broker op is never created.

- isBrokerRemovalPending: keep reusing the deployed capacity.json while a broker
  is dropped from the spec but not yet deleted from CC, instead of regenerating a
  fallback entry that rolls CC mid-removal.
- mergeCapacityConfig: additively merge newly joined brokers into the deployed
  capacity.json (user-provided capacity when the CR defines one, else generated),
  preserving departing brokers' entries verbatim; return the deployed config
  byte-for-byte when nothing new joined so no spurious roll happens. Reuse the
  deployed config verbatim only while a downscale is actively RUNNING (a
  remove_broker task is in flight on CC) - appending a concurrently added broker
  would roll CC and kill the in-flight removal (adobe#301); that broker's capacity is
  written by the next full regeneration once the downscale completes, and its
  add_broker is deferred until then by the roll gate. isBrokerDeletionInProgress
  now keys off IsDownscaleRunning(), not the broader IsDownscale(): treating
  Required/Scheduled (no in-flight CC task) as "in progress" deadlocks a
  concurrent add - the generator would never write the new broker's capacity, its
  add_broker would defer forever, and the add-before-remove task priority would
  keep the downscale from ever advancing to unblock it.
- Gate Cruise Control ops on a Deployment that is safe to submit to. Every op
  except stop_execution is deferred while the CC Deployment is mid-rollout, and -
  because a roll can land just after an op is submitted - also while any of the
  running pod-template hash annotations (capacity.json, cruisecontrol.properties,
  clusterConfigs.json, log4j.properties) lags its current ConfigMap entry; a roll
  is triggered by any hashed config change, not just capacity.json.
  add_broker additionally requires that capacity.json to contain the target broker
  id(s) - closing the race where the op is created before the roll is triggered
  (KafkaClusterReconciler marks the broker GracefulUpscaleRequired independently
  of the CC reconciler that rolls the Deployment). The gate reads the Deployment
  and ConfigMap through the non-cached API reader (DirectClient, falling back to
  the cached client when unset) because freshness is part of its correctness
  contract - a lagging informer cache could show old state as matching and let an
  op through just before the real roll wipes it. Every check fails open when the
  Deployment/ConfigMap is absent or the entry is not koperator-managed; a wedged
  rollout (ProgressDeadlineExceeded) is logged at error level and backs off its
  requeue. Names/keys shared via exported cruisecontrol.DeploymentName /
  ConfigMapName / CapacityConfigMapKey / ConfigHash /
  {Capacity,Config,ClusterConfig,LogConfig}HashAnnotationKey.

Tests: TestIsBrokerRemovalPending, TestIsBrokerDeletionInProgress (Required/
Scheduled/Succeeded are not "in progress"; only Running is),
TestGenerateCapacityConfigReuseAndMerge (incl. mixed add+remove under a
Required/Scheduled downscale still adding the new broker - no deadlock - and an
unparseable deployed config reused verbatim), TestCapacityConfigContainsBrokers,
TestCapacityConfigHash, TestIsDeploymentRolling, TestDeploymentRolloutTimedOut,
and TestRequeueIfCCDeploymentNotRolledOut - which drives the gate's branches with
fake Deployment/ConfigMap states (stop never gated, rebalance/remove_disks
deferred only while rolling, each of the four hashes mismatching and
add_broker-with-absent-capacity requeue, fail-open, execute) and proves the gate
consults DirectClient's fresh view over a stale cache (both directions);
testKRaftBrokerScaling e2e regression (upscale 3->4 then downscale back, asserting
exact broker/controller ids, capacity.json and the pod-template hash carrying
broker 103, and controllers untouched). The quiescent-CC gate used by the
mutation e2e tests now counts queued and retry-pending operations as in-flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
amuraru added a commit to amuraru/koperator that referenced this pull request Aug 18, 2026
…lout

CI on adobe#304 hit exactly the hazard the roll gate exists to prevent avoiding,
but in reverse: a CC Deployment rollout wedged (new pod crash-looping /
unreachable broker) while a remove_broker retry was pending, and the gate's
"defer until the roll completes" policy had no escape hatch - it deferred
every subsequent reconcile indefinitely, since a wedged rollout never
completes on its own. Brokers 3/4 never got removed and
testBatchedBrokerRemoval timed out after 20 minutes.

- requeueIfCCDeploymentNotRolledOut: once a rollout is confirmed wedged
  (ProgressDeadlineExceeded), check the Deployment's Available condition.
  RollingUpdate's default maxUnavailable keeps the OLD replica up until a
  replacement is Ready, and a ClusterIP Service only routes to Ready pods -
  so if the old replica is still Available, the not-yet-Ready surged pod is
  receiving no traffic and cannot race a cutover, the exact hazard this gate
  guards against. It is therefore safe to let the operation through instead
  of deferring behind a wedge that may never clear. Only when neither
  replica is Available (no CC to safely submit to) does it keep deferring,
  with the existing stalled backoff.
- deploymentAvailable: new helper reading the Deployment's Available
  condition, mirroring deploymentRolloutTimedOut's Progressing check.
- cruisecontrol Deployment: raise progressDeadlineSeconds from Kubernetes'
  600s default to 1800s. A capacity.json/config roll can land while CC is
  mid-rebalance on a large cluster, and the new pod's readiness depends on
  reaching every broker to bootstrap its monitor - both can legitimately
  exceed 10 minutes on a large or degraded cluster, flipping Progressing to
  ProgressDeadlineExceeded (and triggering the gate's wedged-rollout path)
  well before the rollout would otherwise finish on its own. This does not
  by itself fix the deadlock above; it only reduces how often the wedged
  branch is reached at all.

Tests: TestDeploymentAvailable; TestRequeueIfCCDeploymentNotRolledOut adds
"wedged rollout with the old replica still Available executes despite the
stalled rollout", proving the operation now executes (not defers) in that
case, while the existing "wedged rollout defers with the stalled backoff
interval" case (no Available condition present) is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… is pending

Root-caused the actual CI hangs (two prior gate-escape-hatch commits on this
branch were backed out - they treated the symptom, not this) with a local
kind repro plus a live cluster watch: testBatchedBrokerRemoval applies
simplekafkacluster.yaml (headlessServiceEnabled: false) as its "drop to 3
brokers" patch over a cluster started from simplekafkacluster_5broker.yaml
(headlessServiceEnabled: true). That flip is unrelated to which brokers are
being removed, but koperator's reconcile loop only migrates/re-addresses
brokers still in spec.Brokers (reorderBrokers) - a broker just dropped from
spec whose pod is still alive (deletion gated on Cruise Control finishing
its data migration) is invisible to that loop, so it never gets a
replacement Service on the other addressing scheme, and the reconcile still
deletes its old one (the shared headless Service) once the in-spec brokers
finish migrating. On a fast/idle cluster Cruise Control usually evicts the
departing broker before that teardown lands (confirmed empirically - a
local repro passed clean, in-spec brokers migrating brokers 0/1/2 well
after 3/4's pods were already gone); on a loaded CI runner the ordering can
flip, and a broker still mid-removal loses its only address entirely -
matching every symptom in the CI failures (UnknownHostException for the
removed broker across the whole cluster, the resulting new Cruise Control
pod crash-looping, removal never completing).

- firstRunningBrokerOutsideSpec: reports whether any broker with a running
  pod is missing from spec.Brokers.
- Reconcile: skip the headless/non-headless Service transition entirely
  while such a broker exists, logging it, instead of proceeding once and
  hoping the timing works out - koperator would rather stay wedged here
  than risk orphaning a broker it still needs.

Also fixed the two e2e tests that surfaced this: both were using a second,
independently maintained sample manifest to trigger a broker add/remove,
which is exactly how the unrelated headlessServiceEnabled drift snuck in.
testBatchedBrokerRemoval and testKRaftBrokerScaling now fetch the running
KafkaCluster and patch spec.brokers directly (new
tests/e2e/kafkacluster_brokers.go: patch/remove/addKafkaClusterBroker),
which cannot introduce that kind of unrelated drift. Removed
simplekafkacluster_kraft_4broker.yaml, now unused (it existed solely for
the old apply-a-manifest upscale step).

Tests: TestFirstRunningBrokerOutsideSpec covers the new guard (no running
brokers, all in spec, one dropped from spec, scale-up in progress).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@amuraru

amuraru commented Aug 19, 2026

Copy link
Copy Markdown
Author

Discarding the CC roll-sequencing changes (#301 / 82088cc). After further investigation, the stall we were chasing was caused by a broker's only Service
being torn down while its removal was pending (fixed minimally in b22ab71), plus an e2e test that applied a full manifest instead of patching spec.Brokers.

The elaborate machinery in 82088cc — the CC-op roll gate, mergeCapacityConfig, and the IsDownscaleRunning retuning — targets a different mechanism (a
capacity.json change rolling the CC Deployment onto an in-flight op). That race is real but self-healing: a lost task surfaces as CompletedWithError, and
with the default ErrorPolicy: retry koperator automatically re-fires the operation after a short backoff. So the patch buys us avoidance of a bounded delay
and cleaner logs — not correctness — at the cost of ~2000 lines, a scaling-only capacity-merge path, and a gate that can defer operations forever if it
regresses. That's a poor trade for an optimization, so we're dropping it.

@amuraru amuraru closed this Aug 19, 2026
@amuraru

amuraru commented Aug 19, 2026

Copy link
Copy Markdown
Author

Replaced by a much shorter fix: #318

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.

CruiseControl restart races in-flight add_broker/remove_broker operations (scaling stalls during CC roll)

1 participant