Skip to content

Commit 5658922

Browse files
bdchathamclaude
andcommitted
fix: guard observeCurrentImage against stale StatefulSet status
Add ObservedGeneration < Generation check to prevent false-positive convergence signals when reading StatefulSet status in the same reconcile cycle that patched the spec. ObservedGeneration and UpdatedReplicas are written atomically by the StatefulSet controller, so when both checks pass the pod is guaranteed to be running the new template. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb7546d commit 5658922

2 files changed

Lines changed: 45 additions & 0 deletions

File tree

internal/controller/node/controller.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ func (r *SeiNodeReconciler) observeCurrentImage(ctx context.Context, node *seiv1
197197
return err
198198
}
199199

200+
// Wait for the StatefulSet controller to process the latest spec change.
201+
if sts.Status.ObservedGeneration < sts.Generation {
202+
return nil
203+
}
200204
if sts.Spec.Replicas == nil || sts.Status.UpdatedReplicas < *sts.Spec.Replicas {
201205
return nil
202206
}

internal/controller/node/reconciler_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ func TestObserveCurrentImage_UpdatesWhenConverged(t *testing.T) {
250250
Build()
251251

252252
sts.Status.UpdatedReplicas = 1
253+
sts.Status.ObservedGeneration = sts.Generation
253254
g.Expect(c.Status().Update(ctx, sts)).To(Succeed())
254255

255256
r := &SeiNodeReconciler{Client: c, Scheme: s}
@@ -259,6 +260,46 @@ func TestObserveCurrentImage_UpdatesWhenConverged(t *testing.T) {
259260
g.Expect(fetched.Status.CurrentImage).To(Equal(testImageV2))
260261
}
261262

263+
func TestObserveCurrentImage_SkipsWhenStaleGeneration(t *testing.T) {
264+
g := NewWithT(t)
265+
ctx := context.Background()
266+
267+
node := newGenesisNode("mynet-0", "default")
268+
node.Finalizers = []string{nodeFinalizerName}
269+
node.Status.Phase = seiv1alpha1.PhaseRunning
270+
node.Spec.Image = testImageV2
271+
272+
sts := &appsv1.StatefulSet{
273+
ObjectMeta: metav1.ObjectMeta{Name: "mynet-0", Namespace: "default", Generation: 2},
274+
Spec: appsv1.StatefulSetSpec{
275+
Replicas: ptrInt32(1),
276+
ServiceName: "mynet-0",
277+
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"sei.io/node": "mynet-0"}},
278+
Template: corev1.PodTemplateSpec{
279+
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"sei.io/node": "mynet-0"}},
280+
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "seid", Image: testImageV2}}},
281+
},
282+
},
283+
}
284+
285+
s := newNodeTestScheme(t)
286+
c := fake.NewClientBuilder().
287+
WithScheme(s).
288+
WithObjects(node, sts).
289+
WithStatusSubresource(&seiv1alpha1.SeiNode{}, &appsv1.StatefulSet{}).
290+
Build()
291+
292+
sts.Status.UpdatedReplicas = 1
293+
sts.Status.ObservedGeneration = 1
294+
g.Expect(c.Status().Update(ctx, sts)).To(Succeed())
295+
296+
r := &SeiNodeReconciler{Client: c, Scheme: s}
297+
g.Expect(r.observeCurrentImage(ctx, node)).To(Succeed())
298+
299+
fetched := getSeiNode(t, ctx, c, "mynet-0", "default")
300+
g.Expect(fetched.Status.CurrentImage).To(BeEmpty())
301+
}
302+
262303
func TestObserveCurrentImage_SkipsWhenRolling(t *testing.T) {
263304
g := NewWithT(t)
264305
ctx := context.Background()

0 commit comments

Comments
 (0)