Skip to content

Commit e28c619

Browse files
authored
chore: skip switchover for safe metadata-only in-place updates (#10312)
1 parent 98e3339 commit e28c619

4 files changed

Lines changed: 329 additions & 3 deletions

File tree

pkg/controller/instanceset/in_place_update_util.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,79 @@ func equalBasicInPlaceFields(old, new *corev1.Pod) bool {
258258
return true
259259
}
260260

261+
// safeMetadataOnlyInPlaceUpdate returns true when the only difference between
262+
// old and new is a Pod metadata patch (annotations and/or labels) that has no
263+
// effect on the running database process or availability. In that case the
264+
// caller can skip the lifecycle switchover that is normally invoked before an
265+
// in-place pod update.
266+
//
267+
// Specifically, it returns true if and only if:
268+
// - old and new are otherwise equal when annotations and labels are ignored
269+
// (basic spec fields + container resources match)
270+
// - metadata has actually changed (do not vacuously skip switchover when
271+
// there is no real diff)
272+
// - the annotation diff does not include constant.RestartAnnotationKey, the
273+
// explicit restart trigger which always implies a process restart
274+
// - the annotation diff does not touch any key prefixed with
275+
// constant.UpgradeRestartAnnotationKey ("config.kubeblocks.io/restart"),
276+
// which the parameters controller writes via GenerateUniqKeyWithConfig to
277+
// signal a restart-required config change. filterInPlaceFields keeps these
278+
// keys alongside RestartAnnotationKey, so a diff on any of them must fall
279+
// through to switchover to honor the restart contract.
280+
//
281+
// Label changes are allowed because pure label patches do not restart the
282+
// container, do not change the spec, and do not touch the database process.
283+
// Service-selector routing impact is a network-layer concern and is not
284+
// resolved by invoking lifecycle switchover. Role-label patches are likewise
285+
// state synchronization, not a trigger for additional switchover.
286+
func safeMetadataOnlyInPlaceUpdate(old, new *corev1.Pod) bool {
287+
oldCopy := old.DeepCopy()
288+
newCopy := new.DeepCopy()
289+
oldCopy.Annotations = nil
290+
newCopy.Annotations = nil
291+
oldCopy.Labels = nil
292+
newCopy.Labels = nil
293+
if !equalBasicInPlaceFields(oldCopy, newCopy) || !equalResourcesInPlaceFields(oldCopy, newCopy) {
294+
return false
295+
}
296+
if equalField(old.Annotations, new.Annotations) && equalField(old.Labels, new.Labels) {
297+
return false
298+
}
299+
if !equalField(old.Annotations[constant.RestartAnnotationKey], new.Annotations[constant.RestartAnnotationKey]) {
300+
return false
301+
}
302+
if !equalUpgradeRestartAnnotations(old.Annotations, new.Annotations) {
303+
return false
304+
}
305+
return true
306+
}
307+
308+
// equalUpgradeRestartAnnotations returns true when old and new agree on every
309+
// annotation whose key is prefixed with constant.UpgradeRestartAnnotationKey
310+
// ("config.kubeblocks.io/restart"). The parameters controller writes these via
311+
// GenerateUniqKeyWithConfig to signal that a config change requires a pod
312+
// restart; filterInPlaceFields keeps them in the in-place template diff, so
313+
// any add/change/remove on these keys must invoke switchover.
314+
func equalUpgradeRestartAnnotations(old, new map[string]string) bool {
315+
keys := make(map[string]struct{})
316+
for k := range old {
317+
if strings.HasPrefix(k, constant.UpgradeRestartAnnotationKey) {
318+
keys[k] = struct{}{}
319+
}
320+
}
321+
for k := range new {
322+
if strings.HasPrefix(k, constant.UpgradeRestartAnnotationKey) {
323+
keys[k] = struct{}{}
324+
}
325+
}
326+
for k := range keys {
327+
if old[k] != new[k] {
328+
return false
329+
}
330+
}
331+
return true
332+
}
333+
261334
// equalResourcesInPlaceFields checks if the desired values of pod resources are equal to their current actual values.
262335
// If they are equal, it returns true. Containers in 'old' that are not recognized (they may have been injected by external mutating admission webhooks)
263336
// will not participate in the comparison.

pkg/controller/instanceset/instance_util_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,134 @@ var _ = Describe("instance util test", func() {
9999
return item
100100
})).ShouldNot(Succeed())
101101
})
102+
103+
It("identifies safe metadata-only in-place updates per process-impact semantic", func() {
104+
basePod := builder.NewPodBuilder(namespace, name+"-0").
105+
AddAnnotations("kept", "value", constant.CMInsConfigurationHashLabelKey, "old-hash").
106+
AddLabels("app", "valkey").
107+
SetContainers(template.Spec.Containers).
108+
GetObject()
109+
110+
positiveCases := []struct {
111+
name string
112+
mutate func(*corev1.Pod)
113+
}{{
114+
name: "config-hash annotation patch",
115+
mutate: func(pod *corev1.Pod) {
116+
pod.Annotations[constant.CMInsConfigurationHashLabelKey] = "new-hash"
117+
},
118+
}, {
119+
name: "non-restart annotation added",
120+
mutate: func(pod *corev1.Pod) {
121+
pod.Annotations["custom"] = "value"
122+
},
123+
}, {
124+
name: "non-restart annotation value changed",
125+
mutate: func(pod *corev1.Pod) {
126+
pod.Annotations["kept"] = "changed"
127+
},
128+
}, {
129+
name: "label added",
130+
mutate: func(pod *corev1.Pod) {
131+
pod.Labels["extra"] = "value"
132+
},
133+
}, {
134+
name: "label value changed",
135+
mutate: func(pod *corev1.Pod) {
136+
pod.Labels["app"] = "valkey-renamed"
137+
},
138+
}, {
139+
name: "role label state synchronization",
140+
mutate: func(pod *corev1.Pod) {
141+
pod.Labels[constant.RoleLabelKey] = "primary"
142+
},
143+
}}
144+
for _, tc := range positiveCases {
145+
By("skipping switchover when " + tc.name)
146+
newPod := basePod.DeepCopy()
147+
tc.mutate(newPod)
148+
Expect(safeMetadataOnlyInPlaceUpdate(basePod, newPod)).Should(BeTrue())
149+
}
150+
151+
negativeCases := []struct {
152+
name string
153+
mutate func(*corev1.Pod)
154+
}{{
155+
name: "no diff",
156+
mutate: func(pod *corev1.Pod) {},
157+
}, {
158+
name: "restart annotation added",
159+
mutate: func(pod *corev1.Pod) {
160+
pod.Annotations[constant.RestartAnnotationKey] = "2026-05-19T14:00:00Z"
161+
},
162+
}, {
163+
name: "restart annotation value changed",
164+
mutate: func(pod *corev1.Pod) {
165+
if pod.Annotations == nil {
166+
pod.Annotations = map[string]string{}
167+
}
168+
pod.Annotations[constant.RestartAnnotationKey] = "next"
169+
},
170+
}, {
171+
name: "upgrade-restart prefixed annotation added (config.kubeblocks.io/restart-mysql-config)",
172+
mutate: func(pod *corev1.Pod) {
173+
if pod.Annotations == nil {
174+
pod.Annotations = map[string]string{}
175+
}
176+
pod.Annotations[constant.UpgradeRestartAnnotationKey+"-mysql-config"] = "hash-1"
177+
},
178+
}, {
179+
name: "exact UpgradeRestartAnnotationKey annotation added (no suffix)",
180+
mutate: func(pod *corev1.Pod) {
181+
if pod.Annotations == nil {
182+
pod.Annotations = map[string]string{}
183+
}
184+
pod.Annotations[constant.UpgradeRestartAnnotationKey] = "trigger"
185+
},
186+
}, {
187+
name: "container image changed",
188+
mutate: func(pod *corev1.Pod) {
189+
pod.Spec.Containers[0].Image = "valkey:10"
190+
},
191+
}, {
192+
name: "container resources changed",
193+
mutate: func(pod *corev1.Pod) {
194+
pod.Spec.Containers[0].Resources.Requests = corev1.ResourceList{
195+
corev1.ResourceCPU: resource.MustParse("1"),
196+
}
197+
},
198+
}, {
199+
name: "container env added",
200+
mutate: func(pod *corev1.Pod) {
201+
pod.Spec.Containers[0].Env = append(pod.Spec.Containers[0].Env, corev1.EnvVar{Name: "EXTRA", Value: "v"})
202+
},
203+
}}
204+
for _, tc := range negativeCases {
205+
By("invoking switchover when " + tc.name)
206+
newPod := basePod.DeepCopy()
207+
tc.mutate(newPod)
208+
Expect(safeMetadataOnlyInPlaceUpdate(basePod, newPod)).Should(BeFalse())
209+
}
210+
211+
By("invoking switchover when an existing upgrade-restart prefixed annotation value changes")
212+
podWithUpgradeRestart := basePod.DeepCopy()
213+
podWithUpgradeRestart.Annotations[constant.UpgradeRestartAnnotationKey+"-mysql-config"] = "hash-1"
214+
mutatedUpgradeRestart := podWithUpgradeRestart.DeepCopy()
215+
mutatedUpgradeRestart.Annotations[constant.UpgradeRestartAnnotationKey+"-mysql-config"] = "hash-2"
216+
Expect(safeMetadataOnlyInPlaceUpdate(podWithUpgradeRestart, mutatedUpgradeRestart)).Should(BeFalse())
217+
218+
By("invoking switchover when an existing upgrade-restart prefixed annotation is removed")
219+
removedUpgradeRestart := podWithUpgradeRestart.DeepCopy()
220+
delete(removedUpgradeRestart.Annotations, constant.UpgradeRestartAnnotationKey+"-mysql-config")
221+
Expect(safeMetadataOnlyInPlaceUpdate(podWithUpgradeRestart, removedUpgradeRestart)).Should(BeFalse())
222+
223+
By("skipping switchover when a non-restart config.kubeblocks.io/* annotation changes (prefix does not match)")
224+
nonRestartConfig := basePod.DeepCopy()
225+
nonRestartConfig.Annotations["config.kubeblocks.io/non-restart-key"] = "value-1"
226+
mutatedNonRestart := nonRestartConfig.DeepCopy()
227+
mutatedNonRestart.Annotations["config.kubeblocks.io/non-restart-key"] = "value-2"
228+
Expect(safeMetadataOnlyInPlaceUpdate(nonRestartConfig, mutatedNonRestart)).Should(BeTrue())
229+
})
102230
})
103231

104232
Context("buildInstanceName2TemplateMap", func() {

pkg/controller/instanceset/reconciler_update.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,10 @@ func (r *updateReconciler) Reconcile(tree *kubebuilderx.ObjectTree) (kubebuilder
181181
if !equalResourcesInPlaceFields(pod, newInstance.pod) && supportResizeSubResource {
182182
err = tree.Update(newPod, kubebuilderx.WithSubResource("resize"))
183183
} else {
184-
if err = r.switchover(tree, its, newPod.(*corev1.Pod)); err != nil {
185-
return kubebuilderx.Continue, err
184+
if !safeMetadataOnlyInPlaceUpdate(pod, newInstance.pod) {
185+
if err = r.switchover(tree, its, newPod.(*corev1.Pod)); err != nil {
186+
return kubebuilderx.Continue, err
187+
}
186188
}
187189
err = tree.Update(newPod)
188190
}
@@ -274,6 +276,8 @@ func (r *updateReconciler) isPodCanBeUpdated(tree *kubebuilderx.ObjectTree, its
274276
return true, false
275277
}
276278

279+
var newLifecycleAction = lifecycle.New
280+
277281
func (r *updateReconciler) switchover(tree *kubebuilderx.ObjectTree, its *workloads.InstanceSet, pod *corev1.Pod) error {
278282
if its.Spec.MembershipReconfiguration == nil || its.Spec.MembershipReconfiguration.Switchover == nil {
279283
return nil
@@ -296,7 +300,7 @@ func (r *updateReconciler) switchover(tree *kubebuilderx.ObjectTree, its *worklo
296300
}
297301
return m
298302
}()
299-
lfa, err := lifecycle.New(its.Namespace, clusterName, its.Labels[constant.KBAppComponentLabelKey], lifecycleActions, templateVars, pod)
303+
lfa, err := newLifecycleAction(its.Namespace, clusterName, its.Labels[constant.KBAppComponentLabelKey], lifecycleActions, templateVars, pod)
300304
if err != nil {
301305
return err
302306
}

pkg/controller/instanceset/reconciler_update_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
package instanceset
2121

2222
import (
23+
"context"
2324
"slices"
2425
"time"
2526

@@ -39,6 +40,7 @@ import (
3940
"github.com/apecloud/kubeblocks/pkg/constant"
4041
"github.com/apecloud/kubeblocks/pkg/controller/builder"
4142
"github.com/apecloud/kubeblocks/pkg/controller/kubebuilderx"
43+
"github.com/apecloud/kubeblocks/pkg/controller/lifecycle"
4244
intctrlutil "github.com/apecloud/kubeblocks/pkg/controllerutil"
4345
viper "github.com/apecloud/kubeblocks/pkg/viperx"
4446
)
@@ -410,5 +412,124 @@ var _ = Describe("update reconciler test", func() {
410412
It("inplace updates pod resource using resize subresource", func() {
411413
testInplacePodVerticalScaling(true)
412414
})
415+
416+
It("patches pod without calling switchover for metadata-only in-place updates", func() {
417+
// This test exercises the Reconcile call site (not just the
418+
// safeMetadataOnlyInPlaceUpdate helper) to assert the contract
419+
// from PR #10252: when the only difference between the existing
420+
// pod and the rebuilt pod is non-restart metadata, the pod is
421+
// patched but the switchover lifecycle action must not be
422+
// invoked.
423+
origSupportResize := intctrlutil.SupportResizeSubResource
424+
intctrlutil.SupportResizeSubResource = func() (bool, error) { return false, nil }
425+
defer func() { intctrlutil.SupportResizeSubResource = origSupportResize }()
426+
427+
spy := &lifecycleCallSpy{}
428+
origNewLifecycleAction := newLifecycleAction
429+
newLifecycleAction = func(_ string, _ string, _ string, _ *kbappsv1.ComponentLifecycleActions, _ map[string]any, _ *corev1.Pod, _ ...*corev1.Pod) (lifecycle.Lifecycle, error) {
430+
return spy, nil
431+
}
432+
defer func() { newLifecycleAction = origNewLifecycleAction }()
433+
434+
tree := kubebuilderx.NewObjectTree()
435+
its.Spec.PodManagementPolicy = appsv1.ParallelPodManagement
436+
its.Spec.Replicas = ptr.To[int32](1)
437+
its.Spec.PodUpdatePolicy = kbappsv1.PreferInPlacePodUpdatePolicyType
438+
// Configure a switchover action so r.switchover would invoke
439+
// newLifecycleAction (and thus the spy) if the gate did not
440+
// suppress it.
441+
its.Spec.MembershipReconfiguration = &workloads.MembershipReconfiguration{
442+
Switchover: &kbappsv1.Action{
443+
Exec: &kbappsv1.ExecAction{Command: []string{"true"}},
444+
},
445+
}
446+
tree.SetRoot(its)
447+
448+
// Run the revision pipeline against the original template so the
449+
// generated pod's controller-revision label matches
450+
// its.Status.UpdateRevisions. This avoids the revision-mismatch
451+
// branch in getPodUpdatePolicy that would force recreatePolicy.
452+
prepareForUpdate(tree)
453+
454+
pods := tree.List(&corev1.Pod{})
455+
Expect(pods).Should(HaveLen(1))
456+
pod := pods[0].(*corev1.Pod)
457+
pod.Status.Phase = corev1.PodRunning
458+
pod.Status.Conditions = append(pod.Status.Conditions, corev1.PodCondition{
459+
Type: corev1.PodReady,
460+
Status: corev1.ConditionTrue,
461+
LastTransitionTime: metav1.NewTime(time.Now().Add(-1 * minReadySeconds * time.Second)),
462+
})
463+
464+
// Mutate the template AFTER prepareForUpdate so the rebuilt pod
465+
// differs from the existing pod only in the config-hash
466+
// annotation. its.Status.UpdateRevisions is no longer
467+
// recomputed, so the pod's revision still matches and the
468+
// reconciler enters the in-place branch via basicUpdate=true.
469+
if its.Spec.Template.ObjectMeta.Annotations == nil {
470+
its.Spec.Template.ObjectMeta.Annotations = map[string]string{}
471+
}
472+
its.Spec.Template.ObjectMeta.Annotations[constant.CMInsConfigurationHashLabelKey] = "new-hash"
473+
474+
reconciler = NewUpdateReconciler()
475+
res, err := reconciler.Reconcile(tree)
476+
Expect(err).Should(BeNil())
477+
Expect(res).Should(Equal(kubebuilderx.Continue))
478+
479+
postPods := tree.List(&corev1.Pod{})
480+
Expect(postPods).Should(HaveLen(1),
481+
"pod should be in-place updated, not deleted/recreated")
482+
updatedPod := postPods[0].(*corev1.Pod)
483+
Expect(updatedPod.Annotations).Should(HaveKeyWithValue(constant.CMInsConfigurationHashLabelKey, "new-hash"),
484+
"pod should be patched with the new config-hash annotation even though switchover is skipped")
485+
Expect(spy.switchoverCalls).Should(Equal(0),
486+
"switchover must not be invoked when only the config-hash annotation differs")
487+
})
413488
})
414489
})
490+
491+
// lifecycleCallSpy is a test double for lifecycle.Lifecycle used to assert
492+
// that switchover is or is not invoked during reconciliation. Methods that
493+
// the call-site tests do not exercise return nil to satisfy the interface.
494+
type lifecycleCallSpy struct {
495+
switchoverCalls int
496+
reconfigureCalls int
497+
}
498+
499+
func (s *lifecycleCallSpy) PostProvision(_ context.Context, _ client.Reader, _ *lifecycle.Options) error {
500+
return nil
501+
}
502+
503+
func (s *lifecycleCallSpy) PreTerminate(_ context.Context, _ client.Reader, _ *lifecycle.Options) error {
504+
return nil
505+
}
506+
507+
func (s *lifecycleCallSpy) RoleProbe(_ context.Context, _ client.Reader, _ *lifecycle.Options) ([]byte, error) {
508+
return nil, nil
509+
}
510+
511+
func (s *lifecycleCallSpy) Switchover(_ context.Context, _ client.Reader, _ *lifecycle.Options, _ string) error {
512+
s.switchoverCalls++
513+
return nil
514+
}
515+
516+
func (s *lifecycleCallSpy) MemberJoin(_ context.Context, _ client.Reader, _ *lifecycle.Options) error {
517+
return nil
518+
}
519+
520+
func (s *lifecycleCallSpy) MemberLeave(_ context.Context, _ client.Reader, _ *lifecycle.Options) error {
521+
return nil
522+
}
523+
524+
func (s *lifecycleCallSpy) Reconfigure(_ context.Context, _ client.Reader, _ *lifecycle.Options, _ map[string]string) error {
525+
s.reconfigureCalls++
526+
return nil
527+
}
528+
529+
func (s *lifecycleCallSpy) AccountProvision(_ context.Context, _ client.Reader, _ *lifecycle.Options, _, _, _ string) error {
530+
return nil
531+
}
532+
533+
func (s *lifecycleCallSpy) UserDefined(_ context.Context, _ client.Reader, _ *lifecycle.Options, _ string, _ *kbappsv1.Action, _ map[string]string) error {
534+
return nil
535+
}

0 commit comments

Comments
 (0)