This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-operator.git
The following commit(s) were added to refs/heads/main by this push:
new c09922f Always retry a failed cluster operation (#876)
c09922f is described below
commit c09922fd90b9541f88f8ea0d6f91d4d8d62f3be3
Author: Jan Høydahl <[email protected]>
AuthorDate: Thu Nov 5 23:25:10 2026 +0100
Always retry a failed cluster operation (#876)
---
controllers/solrcloud_clusterop_requeue_test.go | 272 ++++++++++++++++++++++++
controllers/solrcloud_controller.go | 17 +-
helm/solr-operator/Chart.yaml | 7 +
3 files changed, 294 insertions(+), 2 deletions(-)
diff --git a/controllers/solrcloud_clusterop_requeue_test.go
b/controllers/solrcloud_clusterop_requeue_test.go
new file mode 100644
index 0000000..b2edc01
--- /dev/null
+++ b/controllers/solrcloud_clusterop_requeue_test.go
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package controllers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
+ "github.com/apache/solr-operator/controllers/util"
+ "github.com/apache/solr-operator/controllers/util/solr_api"
+ "github.com/go-logr/logr"
+ zkApi "github.com/pravega/zookeeper-operator/api/v1beta1"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/tools/record"
+ "k8s.io/utils/pointer"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// errSolrUnreachable simulates the transient loss of connectivity to Solr
that stalled cluster
+// operations before they were guaranteed to be requeued.
+var errSolrUnreachable = errors.New("simulated i/o timeout talking to Solr")
+
+type unreachableSolrTransport struct{}
+
+func (unreachableSolrTransport) RoundTrip(*http.Request) (*http.Response,
error) {
+ return nil, errSolrUnreachable
+}
+
+// makeSolrUnreachable forces every Solr HTTP call to fail. The failing client
goes in the mTLS slot,
+// which the Solr API calls prefer over the default client. Only main.go ever
fills that slot, so it
+// is nil in tests and resetting it to nil restores exactly what was there
before.
+func makeSolrUnreachable(t *testing.T) {
+ t.Helper()
+ solr_api.SetMTLSHttpClient(&http.Client{Transport:
unreachableSolrTransport{}})
+ t.Cleanup(func() {
+ solr_api.SetMTLSHttpClient(nil)
+ })
+}
+
+// clusterOpFixture builds the state of a SolrCloud with 4 running pods, a
requested size of 3 pods
+// and the given cluster operation already locked in on its StatefulSet.
+func clusterOpFixture(t *testing.T, clusterOp SolrClusterOp)
(*SolrCloudReconciler, *solrv1beta1.SolrCloud, *appsv1.StatefulSet) {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := solrv1beta1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := zkApi.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+
+ instance := &solrv1beta1.SolrCloud{
+ ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace:
"default"},
+ Spec: solrv1beta1.SolrCloudSpec{
+ Replicas: pointer.Int32(3),
+ ZookeeperRef: &solrv1beta1.ZookeeperRef{
+ ConnectionInfo:
&solrv1beta1.ZookeeperConnectionInfo{
+ InternalConnectionString:
"zoo-headless:2181",
+ ChRoot: "/example",
+ },
+ },
+ },
+ }
+ // Apply the defaults up-front, otherwise the first reconcile would
just persist them and requeue.
+ instance.WithDefaults(logr.Discard())
+
+ statefulSet := &appsv1.StatefulSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instance.StatefulSetName(),
+ Namespace: instance.Namespace,
+ UID: "statefulset-uid",
+ Annotations: map[string]string{
+ util.ClusterOpsLockAnnotation:
clusterOpAnnotation(t, clusterOp),
+ },
+ },
+ Spec: appsv1.StatefulSetSpec{
+ Replicas: pointer.Int32(4),
+ Selector: &metav1.LabelSelector{MatchLabels:
solrPodLabels(instance)},
+ },
+ Status: appsv1.StatefulSetStatus{Replicas: 4, ReadyReplicas: 4,
UpdatedReplicas: 4},
+ }
+
+ objects := []client.Object{instance, statefulSet}
+ // One pod per StatefulSet replica, i.e. including the pod that is
about to be scaled down.
+ for i := 0; i < int(*statefulSet.Spec.Replicas); i++ {
+ objects = append(objects, readySolrPod(instance, statefulSet,
instance.GetSolrPodName(i)))
+ }
+
+ r := &SolrCloudReconciler{
+ Client: fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(objects...).
+ WithStatusSubresource(&solrv1beta1.SolrCloud{},
&appsv1.StatefulSet{}, &corev1.Pod{}).
+ Build(),
+ Scheme: scheme,
+ Recorder: record.NewFakeRecorder(64),
+ }
+ return r, instance, statefulSet
+}
+
+func clusterOpAnnotation(t *testing.T, op SolrClusterOp) string {
+ t.Helper()
+ bytes, err := json.Marshal(op)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(bytes)
+}
+
+// solrPodLabels returns the labels that the operator uses to find the pods of
a SolrCloud.
+func solrPodLabels(instance *solrv1beta1.SolrCloud) map[string]string {
+ labels := instance.SharedLabels()
+ labels["technology"] = solrv1beta1.SolrTechnologyLabel
+ return labels
+}
+
+// readySolrPod builds a ready Solr pod owned by the given StatefulSet.
+func readySolrPod(instance *solrv1beta1.SolrCloud, statefulSet
*appsv1.StatefulSet, podName string) *corev1.Pod {
+ return &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: podName,
+ Namespace: instance.Namespace,
+ Labels: solrPodLabels(instance),
+ OwnerReferences: []metav1.OwnerReference{{UID:
statefulSet.UID, Name: statefulSet.Name}},
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{{Name:
util.SolrNodeContainer, Image: instance.Spec.SolrImage.ToImageName()}},
+ },
+ Status: corev1.PodStatus{
+ Conditions: []corev1.PodCondition{{Type:
corev1.PodReady, Status: corev1.ConditionTrue}},
+ ContainerStatuses: []corev1.ContainerStatus{{Name:
util.SolrNodeContainer, Started: pointer.Bool(true)}},
+ },
+ }
+}
+
+// TestClusterOpRequeuedWhenSolrIsUnreachable verifies that a cluster
operation that fails because
+// Solr cannot be reached is requeued, so that the operation continues once
Solr is reachable again.
+// Regression test for https://github.com/apache/solr-operator/issues/707,
where a transiently failed
+// scale down was never retried because nothing triggered another reconcile.
+func TestClusterOpRequeuedWhenSolrIsUnreachable(t *testing.T) {
+ makeSolrUnreachable(t)
+ r, instance, statefulSet := clusterOpFixture(t, SolrClusterOp{
+ Operation: ScaleDownLock,
+ Metadata: "3",
+ LastStartTime: metav1.Now(),
+ })
+
+ ctx := log.IntoContext(context.Background(), logr.Discard())
+ result, err := r.Reconcile(ctx, ctrl.Request{NamespacedName:
types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}})
+ if err != nil {
+ t.Fatalf("did not expect the reconcile to fail, got %v", err)
+ }
+ if result.RequeueAfter <= 0 {
+ t.Errorf("expected the failed scale-down to be requeued, got
%+v", result)
+ }
+
+ // The operation should still hold the lock, so that it continues where
it left off.
+ if err = r.Get(ctx, client.ObjectKeyFromObject(statefulSet),
statefulSet); err != nil {
+ t.Fatal(err)
+ }
+ clusterOp, err := GetCurrentClusterOp(statefulSet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if clusterOp == nil || clusterOp.Operation != ScaleDownLock {
+ t.Errorf("expected the scale-down lock to still be held, got
%+v", clusterOp)
+ }
+ if *statefulSet.Spec.Replicas != 4 {
+ t.Errorf("expected the StatefulSet to not be scaled down while
Solr is unreachable, got %d replicas", *statefulSet.Spec.Replicas)
+ }
+}
+
+// TestClusterOpQueuedForRetryAfterTimeoutWhenSolrIsUnreachable verifies that
a cluster operation
+// that keeps failing is eventually given up on, queued for a later retry and
its lock released, so
+// that other cluster operations are not blocked forever.
+func TestClusterOpQueuedForRetryAfterTimeoutWhenSolrIsUnreachable(t
*testing.T) {
+ makeSolrUnreachable(t)
+ r, instance, statefulSet := clusterOpFixture(t, SolrClusterOp{
+ Operation: ScaleDownLock,
+ Metadata: "3",
+ LastStartTime: metav1.NewTime(time.Now().Add(-2 * time.Minute)),
+ })
+
+ ctx := log.IntoContext(context.Background(), logr.Discard())
+ if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName:
types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}}); err
!= nil {
+ t.Fatalf("did not expect the reconcile to fail, got %v", err)
+ }
+
+ if err := r.Get(ctx, client.ObjectKeyFromObject(statefulSet),
statefulSet); err != nil {
+ t.Fatal(err)
+ }
+ if clusterOp, err := GetCurrentClusterOp(statefulSet); err != nil {
+ t.Fatal(err)
+ } else if clusterOp != nil {
+ t.Errorf("expected the scale-down lock to be released after the
operation timed out, got %+v", clusterOp)
+ }
+ queue, err := GetClusterOpRetryQueue(statefulSet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(queue) != 1 || queue[0].Operation != ScaleDownLock {
+ t.Errorf("expected the scale-down to be queued for a later
retry, got %+v", queue)
+ }
+}
+
+// TestFailingLongRunningClusterOpQueuedForRetryAfterAMinute verifies that a
cluster operation that
+// is allowed to run for a long time (here a PVC expansion) is queued for a
later retry as soon as it
+// has been failing for a minute, instead of holding its lock for the full
long timeout.
+func TestFailingLongRunningClusterOpQueuedForRetryAfterAMinute(t *testing.T) {
+ r, instance, statefulSet := clusterOpFixture(t, SolrClusterOp{
+ Operation: PvcExpansionLock,
+ Metadata: "not-a-size",
+ LastStartTime: metav1.NewTime(time.Now().Add(-2 * time.Minute)),
+ })
+
+ ctx := log.IntoContext(context.Background(), logr.Discard())
+ result, err := r.Reconcile(ctx, ctrl.Request{NamespacedName:
types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}})
+ if err != nil {
+ t.Fatalf("did not expect the reconcile to fail, got %v", err)
+ }
+ if result.RequeueAfter <= 0 {
+ t.Errorf("expected the failed PVC expansion to be requeued, got
%+v", result)
+ }
+
+ if err = r.Get(ctx, client.ObjectKeyFromObject(statefulSet),
statefulSet); err != nil {
+ t.Fatal(err)
+ }
+ if clusterOp, opErr := GetCurrentClusterOp(statefulSet); opErr != nil {
+ t.Fatal(opErr)
+ } else if clusterOp != nil {
+ t.Errorf("expected the PVC expansion lock to be released after
a minute of errors, got %+v", clusterOp)
+ }
+ queue, err := GetClusterOpRetryQueue(statefulSet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(queue) != 1 || queue[0].Operation != PvcExpansionLock {
+ t.Errorf("expected the PVC expansion to be queued for a later
retry, got %+v", queue)
+ }
+}
diff --git a/controllers/solrcloud_controller.go
b/controllers/solrcloud_controller.go
index cc0b249..203d6e1 100644
--- a/controllers/solrcloud_controller.go
+++ b/controllers/solrcloud_controller.go
@@ -510,7 +510,9 @@ func (r *SolrCloudReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
case PvcExpansionLock:
operationComplete, retryLaterDuration, err =
handlePvcExpansion(ctx, r, instance, statefulSet, clusterOp, logger)
// PVC expansion (the controller-side volume resize)
can take a long time on some provisioners,
- // so it should use the long requeue timeout rather
than being preempted after a minute.
+ // so a slow-but-progressing expansion should use the
long requeue timeout rather than being
+ // preempted after a minute. An expansion that is
erroring is still queued for a later retry
+ // after a minute, like any other failing operation.
shortTimeoutForRequeue = false
default:
operationFound = false
@@ -521,6 +523,10 @@ func (r *SolrCloudReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
err = clearClusterOpLockWithPatch(ctx, r, statefulSet,
"clusterOp not supported", logger)
}
if operationFound {
+ // The error returned by the cluster operation is not
returned by the reconcile, because the
+ // clusterOp has its own retry mechanism (it will be
queued for a later retry if it keeps failing).
+ // The error is still needed below, to decide whether
the operation should be queued for a later retry.
+ clusterOpError := err
err = nil
if operationComplete {
if nextClusterOperation == nil {
@@ -545,7 +551,7 @@ func (r *SolrCloudReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
clusterOpRuntime :=
time.Since(clusterOp.LastStartTime.Time)
queueForLaterReason := ""
- if err != nil && clusterOpRuntime > time.Minute
{
+ if clusterOpError != nil && clusterOpRuntime >
time.Minute {
queueForLaterReason = "hit an error
during operation"
} else if shortTimeoutForRequeue &&
clusterOpRuntime > time.Minute {
queueForLaterReason = "timed out during
operation (1 minutes)"
@@ -568,6 +574,13 @@ func (r *SolrCloudReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
"Pausing cluster operation %q
because it %s; it has been queued to be retried later",
string(clusterOp.Operation), queueForLaterReason)
}
}
+
+ // A failed cluster operation must always be retried,
otherwise a transient error (e.g. Solr
+ // being temporarily unreachable) would stall the
operation forever, since nothing else is
+ // guaranteed to trigger another reconcile for this
SolrCloud.
+ if clusterOpError != nil && retryLaterDuration == 0 {
+ retryLaterDuration = time.Second * 5
+ }
}
} else if opErr == nil {
if clusterOpQueue, opErr :=
GetClusterOpRetryQueue(statefulSet); opErr == nil {
diff --git a/helm/solr-operator/Chart.yaml b/helm/solr-operator/Chart.yaml
index 65737b2..1fc22f4 100644
--- a/helm/solr-operator/Chart.yaml
+++ b/helm/solr-operator/Chart.yaml
@@ -155,6 +155,13 @@ annotations:
url: https://solr.apache.org/docs/9_11_0/changes/Changes.html
- name: Solr Security News
url: https://solr.apache.org/security-news.html
+ - kind: fixed
+ description: A cluster operation (scale up/down, rolling update, replica
balancing, PVC expansion) that fails, e.g. because Solr is temporarily
unreachable, is now always retried instead of stalling the SolrCloud until
something else triggers a reconcile
+ links:
+ - name: Github Issue
+ url: https://github.com/apache/solr-operator/issues/707
+ - name: Github PR
+ url: https://github.com/apache/solr-operator/pull/876
artifacthub.io/images: |
- name: solr-operator
image: apache/solr-operator:v0.10.0-prerelease