Skip to content

Commit 17e9822

Browse files
authored
Merge pull request #515 from zalando-incubator/fix-empty-eds
Fix scale-to-zero bug when spec.replicas is nil
2 parents 74d94cf + e03eda8 commit 17e9822

File tree

2 files changed

+121
-0
lines changed

2 files changed

+121
-0
lines changed

operator/elasticsearch.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,22 @@ func (o *ElasticsearchOperator) scaleEDS(ctx context.Context, eds *zv1.Elasticse
932932
namespace := eds.Namespace
933933

934934
currentReplicas := edsReplicas(eds)
935+
936+
// Prevent writing 0 to spec.replicas when it would violate minReplicas
937+
// This handles the case where:
938+
// - spec.replicas is nil (e.g., after kubectl patch)
939+
// - edsReplicas returns 0 for autoscaling initialization
940+
// - autoscaler returns no-op (e.g., excludeSystemIndices filters all indices)
941+
if currentReplicas == 0 && scaling != nil && scaling.MinReplicas > 0 {
942+
// Prefer status.replicas (reflects actual StatefulSet state)
943+
if eds.Status.Replicas > 0 {
944+
currentReplicas = eds.Status.Replicas
945+
} else {
946+
// Fallback to minReplicas for new/uninitialized EDS
947+
currentReplicas = scaling.MinReplicas
948+
}
949+
}
950+
935951
eds.Spec.Replicas = &currentReplicas
936952
as := NewAutoScaler(es, o.metricsInterval, client)
937953

operator/elasticsearch_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func TestHasOwnership(t *testing.T) {
2727
operator := &ElasticsearchOperator{
2828
operatorID: "my-operator",
2929
}
30+
3031
assert.True(t, operator.hasOwnership(eds))
3132

3233
eds.Annotations[esOperatorAnnotationKey] = "not-my-operator"
@@ -310,3 +311,107 @@ func TestEDSReplicas(t *testing.T) {
310311
})
311312
}
312313
}
314+
315+
// TestScaleToZeroPrevention validates that the defensive logic in scaleEDS prevents
316+
// writing 0 to spec.replicas when it would violate minReplicas.
317+
// This test documents the fix for the regression introduced in PR #511 where:
318+
// - kubectl patch operations could leave spec.replicas as nil
319+
// - edsReplicas would return 0 for autoscaling-enabled EDS
320+
// - When autoscaler returned no-op (e.g., excludeSystemIndices filters all indices),
321+
// spec.replicas would be written as 0, violating minReplicas
322+
func TestScaleToZeroPrevention(t *testing.T) {
323+
minReplicas := int32(3)
324+
maxReplicas := int32(10)
325+
statusReplicas := int32(5)
326+
327+
for _, tc := range []struct {
328+
name string
329+
eds *zv1.ElasticsearchDataSet
330+
expectedReplicas int32
331+
description string
332+
}{
333+
{
334+
name: "nil replicas + status replicas set -> use status",
335+
eds: &zv1.ElasticsearchDataSet{
336+
ObjectMeta: metav1.ObjectMeta{
337+
Name: "test-eds",
338+
Namespace: "default",
339+
},
340+
Spec: zv1.ElasticsearchDataSetSpec{
341+
Replicas: nil, // Cleared by kubectl patch
342+
Scaling: &zv1.ElasticsearchDataSetScaling{
343+
Enabled: true,
344+
MinReplicas: minReplicas,
345+
MaxReplicas: maxReplicas,
346+
},
347+
},
348+
Status: zv1.ElasticsearchDataSetStatus{
349+
Replicas: statusReplicas, // Actually running 5 replicas
350+
},
351+
},
352+
expectedReplicas: statusReplicas,
353+
description: "Should use status.replicas (5) to preserve current state",
354+
},
355+
{
356+
name: "nil replicas + status replicas zero -> use minReplicas",
357+
eds: &zv1.ElasticsearchDataSet{
358+
ObjectMeta: metav1.ObjectMeta{
359+
Name: "test-eds-new",
360+
Namespace: "default",
361+
},
362+
Spec: zv1.ElasticsearchDataSetSpec{
363+
Replicas: nil, // Not yet initialized
364+
Scaling: &zv1.ElasticsearchDataSetScaling{
365+
Enabled: true,
366+
MinReplicas: minReplicas,
367+
MaxReplicas: maxReplicas,
368+
},
369+
},
370+
Status: zv1.ElasticsearchDataSetStatus{
371+
Replicas: 0, // New EDS not yet running
372+
},
373+
},
374+
expectedReplicas: minReplicas,
375+
description: "Should use minReplicas (3) for new/uninitialized EDS",
376+
},
377+
{
378+
name: "nil replicas + no minReplicas -> use status",
379+
eds: &zv1.ElasticsearchDataSet{
380+
ObjectMeta: metav1.ObjectMeta{
381+
Name: "test-eds-no-min",
382+
Namespace: "default",
383+
},
384+
Spec: zv1.ElasticsearchDataSetSpec{
385+
Replicas: nil,
386+
Scaling: &zv1.ElasticsearchDataSetScaling{
387+
Enabled: true,
388+
MinReplicas: 0, // No minimum set
389+
MaxReplicas: maxReplicas,
390+
},
391+
},
392+
Status: zv1.ElasticsearchDataSetStatus{
393+
Replicas: statusReplicas,
394+
},
395+
},
396+
expectedReplicas: 0, // No defensive logic applied when minReplicas is 0
397+
description: "Should return 0 when minReplicas is not set",
398+
},
399+
} {
400+
t.Run(tc.name, func(t *testing.T) {
401+
// Simulate what happens in scaleEDS
402+
currentReplicas := edsReplicas(tc.eds)
403+
scaling := tc.eds.Spec.Scaling
404+
405+
// Apply the defensive logic from scaleEDS
406+
if currentReplicas == 0 && scaling != nil && scaling.MinReplicas > 0 {
407+
if tc.eds.Status.Replicas > 0 {
408+
currentReplicas = tc.eds.Status.Replicas
409+
} else {
410+
currentReplicas = scaling.MinReplicas
411+
}
412+
}
413+
414+
assert.Equal(t, tc.expectedReplicas, currentReplicas, tc.description)
415+
})
416+
}
417+
}

0 commit comments

Comments
 (0)