Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions pkg/operations/reconfigure.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,62 @@ func (r *reconfigureAction) ReconcileAction(reqCtx intctrlutil.RequestCtx, cli c
if phase == opsv1alpha1.OpsSucceedPhase {
return r.syncReconfigureForOps(reqCtx, cli, resource, opsDeepCopy, opsv1alpha1.OpsSucceedPhase)
}
// The merge failed, so the assignments this ops wrote will never be applied,
// yet they would stay in the ComponentParameter desired spec and keep failing
// the projection for every later reconfigure. Withdraw this ops's own writes
// (and only them) so the failed intent does not outlive the failed ops.
if err := r.withdrawReconfigureFromParameters(reqCtx, cli, resource); err != nil {
return "", noRequeueAfter, err
}
return opsv1alpha1.OpsFailedPhase, 0, intctrlutil.NewFatalError(fmt.Sprintf("reconfigure failed: %s", msg))
}

// withdrawReconfigureFromParameters removes the desired assignments written by
// this ops from the ComponentParameter, guarded by value equality so that a
// newer ops that re-set the same key with a different value is not clobbered.
// It is the failure-path counterpart of applyReconfigureToParameters: the ops
// only withdraws its own write, it does not do any schema validation.
func (r *reconfigureAction) withdrawReconfigureFromParameters(reqCtx intctrlutil.RequestCtx, cli client.Client, resource *OpsResource) error {
sameValue := func(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
for _, reconfigure := range resource.OpsRequest.Spec.Reconfigures {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When aggregatePhase observes one failed ComponentParameter, this loop withdraws the intent from every component in the Ops, including components that have already reached Finished and applied their configuration. That silently introduces an all-or-nothing compensation transaction across the entire Reconfigure list. The Reconfigure API currently only defines a list of per-component updates; it does not define atomicity or rollback semantics. Is the whole Ops intended to be atomic? If not, this reverts successful component changes because another component failed. If it is intended to be atomic, that behavior needs to be defined by the API/status contract rather than introduced only by this failure-path implementation.

compNames, err := r.resolveReconfigureComponents(reqCtx.Ctx, cli, resource.Cluster, reconfigure.ComponentName)
if err != nil {
return err
}
for _, compName := range compNames {
compParam, err := r.getRunningComponentParameter(reqCtx.Ctx, cli, resource.Cluster.Namespace, resource.Cluster.Name, compName)
if err != nil {
return client.IgnoreNotFound(err)
}
if compParam.Spec.Desired == nil || len(compParam.Spec.Desired.Assignments) == 0 {
continue
}
patch := client.MergeFrom(compParam.DeepCopy())
changed := false
for _, param := range reconfigure.Parameters {
current, ok := compParam.Spec.Desired.Assignments[param.Key]
if !ok || !sameValue(current, param.Value) {
continue
}
delete(compParam.Spec.Desired.Assignments, param.Key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This value-equality guard does not prove ownership. If the ComponentParameter already had foo=bar from a previous successful reconfigure (or another writer) and a later Ops includes the same foo=bar plus another parameter that makes the merge fail, this path deletes the existing desired assignment. That turns a failed Ops into a rollback/removal of previously accepted user intent. The cleanup needs an ownership marker/snapshot of values written by this Ops, or it should avoid mutating existing desired state on failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in fdfb93e — you're right that value equality cannot prove ownership, and the fix now anchors ownership on a prior-state snapshot instead:

  • SaveLastConfiguration (the framework invokes it before Action, while the ComponentParameter still holds the pre-apply state) records, per component, the prior desired-assignment state of every key this ops will write — {existed, value} — persisted as an OpsRequest annotation (operations.kubeblocks.io/reconfigure-prior-parameters, following the queue-end-time annotation precedent; no API change).
  • The failure-path withdrawal restores each key to its snapshotted prior state: delete only if it did not exist before this ops; restore the prior value if this ops overwrote it; keep it untouched when the same key=value was already accepted intent (your exact scenario); and in all cases only while the current value still equals this ops's write, so a newer writer wins.
  • With no snapshot (ops created before this mechanism), the withdrawal does not mutate desired state at all — the conservative alternative you named.

Tests added for all four behaviors: previously-accepted same-value kept; overwritten key restored to prior value; fresh key deleted; no-snapshot no-op. Existing fixtures (unrelated-key survival, newer-writer guard, mixed two-ops assignments) updated to carry the snapshot and still pass. pkg/operations suite green locally.

changed = true
}
if !changed {
continue
}
if err := cli.Patch(reqCtx.Ctx, compParam, patch); err != nil {
return err
}
}
}
return nil
}

func (r *reconfigureAction) Action(reqCtx intctrlutil.RequestCtx, cli client.Client, resource *OpsResource) (err error) {
if len(resource.OpsRequest.Spec.Reconfigures) == 0 {
return intctrlutil.NewErrorf(intctrlutil.ErrorTypeFatal, `invalid reconfigure request: %s`, resource.OpsRequest.GetName())
Expand Down
53 changes: 53 additions & 0 deletions pkg/operations/reconfigure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ parameter: {
g.Expect(cp.Spec.Desired.Assignments).Should(HaveKeyWithValue("maxmemory-samples", pointer.String("0")))
})).Should(Succeed())

By("seed an unrelated pre-existing desired key that must survive the withdrawal")
Expect(testapps.GetAndChangeObj(&testCtx, client.ObjectKeyFromObject(componentParameter), func(cp *parametersv1alpha1.ComponentParameter) {
cp.Spec.Desired.Assignments["unrelated-key"] = pointer.String("keep")
})()).Should(Succeed())

By("surface the ComponentParameter failure back to the opsRequest")
Expect(testapps.GetAndChangeObjStatus(&testCtx, client.ObjectKeyFromObject(componentParameter), func(cp *parametersv1alpha1.ComponentParameter) {
cp.Status.ObservedGeneration = cp.Generation
Expand All @@ -263,6 +268,54 @@ parameter: {
g.Expect(condition).ShouldNot(BeNil())
g.Expect(condition.Message).Should(ContainSubstring("maxmemory-samples"))
})).Should(Succeed())

By("the failed ops's own assignment is withdrawn; unrelated intent survives")
Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(componentParameter), func(g Gomega, cp *parametersv1alpha1.ComponentParameter) {
g.Expect(cp.Spec.Desired).ShouldNot(BeNil())
g.Expect(cp.Spec.Desired.Assignments).ShouldNot(HaveKey("maxmemory-samples"))
g.Expect(cp.Spec.Desired.Assignments).Should(HaveKeyWithValue("unrelated-key", pointer.String("keep")))
})).Should(Succeed())
})

It("does not withdraw a key that a newer ops has re-set to a different value", func() {
reqCtx := intctrlutil.RequestCtx{Ctx: ctx}
opsRes, _, _ := initOperationsResources(compDefName, clusterName)

componentParameter := builder.NewComponentParameterBuilder(testCtx.DefaultNamespace, parameterscore.GenerateComponentConfigurationName(clusterName, defaultCompName)).
AddLabelsInMap(constant.GetCompLabelsWithDef(clusterName, defaultCompName, compDefName)).
SetClusterName(clusterName).
SetCompName(defaultCompName).
GetObject()
componentParameter.Spec.Desired = &parametersv1alpha1.ParameterInputs{
// a newer ops has re-set the same key to a different value
Assignments: map[string]*string{"maxmemory-samples": pointer.String("7")},
}
Expect(testCtx.CreateObj(ctx, componentParameter)).Should(Succeed())

ops := testops.NewOpsRequestObj("failed-reconfigure-guard-"+randomStr, testCtx.DefaultNamespace,
clusterName, opsv1alpha1.ReconfiguringType)
ops.Spec.Reconfigures = []opsv1alpha1.Reconfigure{{
ComponentOps: opsv1alpha1.ComponentOps{ComponentName: defaultCompName},
Parameters: []opsv1alpha1.ParameterPair{{Key: "maxmemory-samples", Value: pointer.String("0")}},
}}
opsRes.OpsRequest = testops.CreateOpsRequest(ctx, testCtx, ops)

Expect(testapps.GetAndChangeObjStatus(&testCtx, client.ObjectKeyFromObject(componentParameter), func(cp *parametersv1alpha1.ComponentParameter) {
cp.Status.ObservedGeneration = cp.Generation
cp.Status.Phase = parametersv1alpha1.CMergeFailedPhase
cp.Status.Message = "merge failed"
cp.Status.ConfigurationItemStatus = []parametersv1alpha1.ConfigTemplateItemDetailStatus{{
Name: "mysql-config",
Phase: parametersv1alpha1.CMergeFailedPhase,
}}
})()).Should(Succeed())

opsRes.OpsRequest.Status.Phase = opsv1alpha1.OpsRunningPhase
_, _ = GetOpsManager().Reconcile(reqCtx, k8sClient, opsRes)

Consistently(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(componentParameter), func(g Gomega, cp *parametersv1alpha1.ComponentParameter) {
g.Expect(cp.Spec.Desired.Assignments).Should(HaveKeyWithValue("maxmemory-samples", pointer.String("7")))
})).Should(Succeed())
})
})
})
Loading