fix(cruisecontrol): sequence CC rolls around scaling operations (fixes #301) - #304
fix(cruisecontrol): sequence CC rolls around scaling operations (fixes #301)#304amuraru wants to merge 2 commits into
Conversation
354f708 to
bd1060a
Compare
c1e2dac to
2723128
Compare
…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>
2723128 to
82088cc
Compare
…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>
2a58f44 to
b22ab71
Compare
|
Discarding the CC roll-sequencing changes (#301 / 82088cc). After further investigation, the stall we were chasing was caused by a broker's only Service The elaborate machinery in 82088cc — the CC-op roll gate, mergeCapacityConfig, and the IsDownscaleRunning retuning — targets a different mechanism (a |
|
Replaced by a much shorter fix: #318 |
Fixes #301.
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 theadd_broker/remove_brokerop (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 —KafkaClusterReconcilermarks a new brokerGracefulUpscaleRequired(which makes the task controller createadd_broker) before, and independently of, the CC reconciler that regeneratescapacity.jsonand patches the Deployment.Removal is chicken-and-egg: marking a broker
GracefulDownscaleRequiredneeds a CC-readycc.BrokersWithState(), but in the pre-downscale windowcapacity.jsonis regenerated with a fallback entry for the departing broker, which rolls CC and keeps it un-ready — so the downscale is never marked and noremove_brokerop is created. This stalled the KRaft removal e2e onmaster.Fix — sequence the roll, keep exact per-broker capacity
isBrokerRemovalPending(broker in status, dropped from spec) broadens the existing "reuse the deployedcapacity.json" guard so CC isn't rolled in the pre-downscale window → stays ready → downscale marked →remove_brokercreated.mergeCapacityConfigreuses 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 (aremove_brokertask 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 (itsadd_brokeris deferred until then by the roll gate).requeueIfCCDeploymentNotRolledOut). Every op exceptstop_executionis 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 ConfigMapcapacity.jsonhash;add_brokeralso requires thatcapacity.jsonto 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 /-1dependency).Tests
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; andTestRequeueIfCCDeploymentNotRolledOut, which drives the gate directly with fake Deployment/ConfigMap states (stop_executionnever gated,rebalance/remove_disksdeferred only while rolling, hash mismatch andadd_broker-with-absent-capacity requeue, fail-open, execute). The fullcontrollers/testsenvtest suite passes.testKRaftBrokerScaling: upscale 3→4 (add broker 103) then downscale 4→3, asserting exactly oneadd_brokerthen oneremove_brokerop, exact broker/controller ids (not just counts),capacity.jsongaining then dropping broker 103, the CC pod-template capacity hash carrying thatcapacity.json, and controllers untouched. Samples omit a-1universal-defaultcapacityConfigso 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 thecontrollers/testsenvtest suite pass.🤖 Generated with Claude Code