This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new fd3dbbd3 [SPARK-59926] Do not retry failed K8s event writes
fd3dbbd3 is described below

commit fd3dbbd304e3ba40405b25edfa892a584a47e7a7
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Thu Oct 1 11:00:18 2026 -0700

    [SPARK-59926] Do not retry failed K8s event writes
    
    ### What changes were proposed in this pull request?
    
    This PR aims to write K8s events with a Kubernetes client which does not 
retry a failed request.
    
    - `ConfigurableEventRecorder.withDefaultSink` uses 
`KubernetesClientFactory.withoutRetries`, which is moved from 
`KueueWorkloadUtils` for reuse. The derived client keeps the connections, the 
interceptors and the request timeout of the operator client.
    - `ReconcileError` is published on every retry, subject to 
`spark.kubernetes.operator.events.minIntervalSeconds`, instead of only on the 
first attempt, so that a dropped first write does not hide the whole failure.
    - `docs/configuration.md` is updated accordingly.
    
    ### Why are the changes needed?
    
    `DefaultEventSink` writes events synchronously on the reconciliation 
thread. By default, the fabric8 client retries a write which fails with `429`, 
`5xx` or an I/O error up to 10 times with backoff. So, a single event write can 
hold a reconciliation for tens of seconds exactly when the API server is in 
trouble and error events are published. Since a failed event write is only 
logged anyway, there is no need to retry it.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No for the released versions, because K8s Events support is not released 
yet in 
[1.0.0](https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0) 
(2026-07-23).
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added and updated test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #916 from dongjoon-hyun/SPARK-59926.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 docs/configuration.md                              | 15 +++-
 .../operator/client/KubernetesClientFactory.java   | 21 +++++
 .../k8s/operator/kueue/KueueWorkloadUtils.java     | 14 +---
 .../operator/reconciler/SparkAppReconciler.java    | 18 ++---
 .../reconciler/SparkClusterReconciler.java         | 18 ++---
 .../operator/utils/ConfigurableEventRecorder.java  | 16 +++-
 .../spark/k8s/operator/utils/ReconcilerUtils.java  | 14 ----
 .../client/KubernetesClientFactoryTest.java        | 82 +++++++++++++++++++
 .../reconciler/SparkAppReconcilerTest.java         |  9 +--
 .../reconciler/SparkClusterReconcilerTest.java     |  5 +-
 .../ConfigurableEventRecorderDefaultSinkTest.java  | 94 ++++++++++++++++++++++
 11 files changed, 247 insertions(+), 59 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index 526d491a..38b4ba69 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -100,7 +100,7 @@ In addition, the operator publishes the following `Warning` 
events.
 
 | Reason | When |
 |---|---|
-| `ReconcileError` | A reconciliation throws. Only the first attempt of a 
failure episode publishes. |
+| `ReconcileError` | A reconciliation throws. It is republished on every retry 
while the failure lasts, subject to [`minIntervalSeconds`](#event-frequency). |
 | `CleanupError` | A cleanup throws, so the resource cannot finish deleting. |
 | `StatusUpdateFailed` | A status patch is rejected. Transport-level errors 
are skipped. |
 | `KueueAdmissionRequestFailed` | Creating, reading or deleting a stale Kueue 
`Workload` fails, or the driver or master cannot be read before the admission 
is requested. Transport-level errors are skipped and retried every 5 seconds, 
while a persistent failure is retried with the default interval. |
@@ -181,7 +181,13 @@ visible.
 
 The interval is held from the moment an event is handed to the event sink, not 
from the moment it
 reaches the API server. The operator logs and swallows event write failures 
rather than failing
-the reconciliation, so a write that failed still holds the interval; the next 
repeat recovers it.
+the reconciliation, and its Kubernetes client does not retry an event write, 
e.g. one that the API
+server throttles (`429`) or fails (`5xx`), since the reconciliation waits for 
the write. So a
+write that failed is dropped, right away when the API server answers with an 
error or refuses the
+connection, or after the request timeout (10 seconds by default) when it does 
not answer, yet
+still holds the interval. An event which is republished, such as `SuspendHeld` 
or a warning about
+a failure which lasts, recovers with its next repeat after the interval, while 
one which is not,
+such as a state transition, is lost; the resource status remains the source of 
truth.
 
 ## Metrics
 
@@ -223,6 +229,11 @@ via [Codahale JVM 
Metrics](https://javadoc.io/doc/com.codahale.metrics/metrics-j
 | kubernetes.client.`ResourceName`.`Method`                 | Meter      | 
Tracking the rates of HTTP request for a combination of one Kubernetes resource 
and one http method                      |
 | kubernetes.client.`NamespaceName`.`ResourceName`.`Method` | Meter      | 
Tracking the rates of HTTP request for a combination of one namespace-scoped 
Kubernetes resource and one http method     |
 
+A request which gets no response is counted in `kubernetes.client.failed` 
while the client still
+has a retry left for it, even if it does not retry it, e.g. on a request 
timeout. So a request of
+a client which never retries, i.e. a Kubernetes event write or the recording 
of the Kueue
+`PodsReady` condition, is not counted there when it gets no response.
+
 ### Latency for State Transition
 
 Spark Operator also measures the latency between each state transition for 
apps, in the format of
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/client/KubernetesClientFactory.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/client/KubernetesClientFactory.java
index 9b975dff..8673ce72 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/client/KubernetesClientFactory.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/client/KubernetesClientFactory.java
@@ -23,6 +23,8 @@ import java.util.List;
 import io.fabric8.kubernetes.client.Config;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import io.fabric8.kubernetes.client.RequestConfig;
+import io.fabric8.kubernetes.client.RequestConfigBuilder;
 import io.fabric8.kubernetes.client.http.HttpClient;
 import io.fabric8.kubernetes.client.http.Interceptor;
 import io.fabric8.kubernetes.client.utils.HttpClientUtils;
@@ -69,4 +71,23 @@ public final class KubernetesClientFactory {
             })
         .build();
   }
+
+  /**
+   * Returns a client over the same connections, with the same interceptors 
and request timeout,
+   * which does not retry a failed request. The given client retries a 
throttled request, a server
+   * error and a connection failure with backoff, which holds the caller 
meanwhile. Since fabric8
+   * calls {@link Interceptor#afterConnectionFailure} only while a request has 
a retry left, the
+   * interceptors do not see a request of the returned client which gets no 
response, so the client
+   * metrics do not count it as failed.
+   *
+   * @param client The client to derive from.
+   * @return A client which does not retry a failed request.
+   */
+  public static KubernetesClient withoutRetries(final KubernetesClient client) 
{
+    RequestConfig requestConfig =
+        new RequestConfigBuilder(client.getConfiguration().getRequestConfig())
+            .withRequestRetryBackoffLimit(0)
+            .build();
+    return client.newClient(requestConfig).adapt(KubernetesClient.class);
+  }
 }
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
index 50099eb5..0e335a93 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
@@ -52,11 +52,10 @@ import io.fabric8.kubernetes.api.model.OwnerReference;
 import io.fabric8.kubernetes.api.model.Toleration;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.fabric8.kubernetes.client.KubernetesClientException;
-import io.fabric8.kubernetes.client.RequestConfig;
-import io.fabric8.kubernetes.client.RequestConfigBuilder;
 import lombok.extern.slf4j.Slf4j;
 
 import org.apache.spark.k8s.operator.Constants;
+import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
 import org.apache.spark.k8s.operator.context.BaseContext;
 import org.apache.spark.k8s.operator.kueue.v1beta2.PodSet;
 import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetAssignment;
@@ -875,7 +874,7 @@ public final class KueueWorkloadUtils {
     if (!shouldRecordPodsReady(context, cached)) {
       return Optional.empty();
     }
-    KubernetesClient client = withoutRetries(context.getClient());
+    KubernetesClient client = 
KubernetesClientFactory.withoutRetries(context.getClient());
     String name = cached.getMetadata().getName();
     try {
       Workload workload = client.resource(cached).get();
@@ -909,15 +908,6 @@ public final class KueueWorkloadUtils {
     }
   }
 
-  /** Returns a client over the same connections which does not retry a failed 
request. */
-  private static KubernetesClient withoutRetries(final KubernetesClient 
client) {
-    RequestConfig requestConfig =
-        new RequestConfigBuilder(client.getConfiguration().getRequestConfig())
-            .withRequestRetryBackoffLimit(0)
-            .build();
-    return client.newClient(requestConfig).adapt(KubernetesClient.class);
-  }
-
   /**
    * Checks whether the given Workload is admitted without the `PodsReady` 
condition, while as many
    * pods of each of its pod sets as its count are ready.
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
index 8cf56fcd..24ac7629 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
@@ -22,7 +22,6 @@ package org.apache.spark.k8s.operator.reconciler;
 import static 
org.apache.spark.k8s.operator.Constants.LABEL_SPARK_APPLICATION_NAME;
 import static 
org.apache.spark.k8s.operator.config.SparkOperatorConf.KUEUE_ENABLED;
 import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.completeAndDefaultRequeue;
-import static 
org.apache.spark.k8s.operator.utils.ReconcilerUtils.isFirstAttempt;
 import static 
org.apache.spark.k8s.operator.utils.Utils.basicLabelSecondaryToPrimaryMapper;
 import static 
org.apache.spark.k8s.operator.utils.Utils.commonResourceLabelsStr;
 
@@ -141,15 +140,14 @@ public class SparkAppReconciler implements 
Reconciler<SparkApplication>, Cleaner
                       retryInfo.isLastAttempt());
                 }
               });
-      // JOSDK calls this on every retry attempt. Emitting each time would 
mean one blocking write
-      // per attempt against an API server that is often the cause of the 
failure, so only the
-      // first attempt publishes: the reason is stable, so a later attempt 
would only bump a count.
-      if (isFirstAttempt(context)) {
-        EventUtils.warn(
-            context.eventRecorder(),
-            EventUtils.REASON_RECONCILE_ERROR,
-            "Spark App Reconciliation failed. " + EventUtils.describe(e));
-      }
+      // JOSDK calls this on every retry attempt, and each one publishes. The 
event write is not
+      // retried, so a write which a struggling API server dropped is 
recovered by a later attempt,
+      // while ConfigurableEventRecorder drops a repeat with the same message 
within the minimum
+      // interval, so a failure which persists only bumps the count of the one 
event.
+      EventUtils.warn(
+          context.eventRecorder(),
+          EventUtils.REASON_RECONCILE_ERROR,
+          "Spark App Reconciliation failed. " + EventUtils.describe(e));
       return ErrorStatusUpdateControl.noStatusUpdate();
     } finally {
       trackedMDC.reset();
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconciler.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconciler.java
index 04b346f1..a35a9265 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconciler.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconciler.java
@@ -22,7 +22,6 @@ package org.apache.spark.k8s.operator.reconciler;
 import static org.apache.spark.k8s.operator.Constants.LABEL_SPARK_CLUSTER_NAME;
 import static 
org.apache.spark.k8s.operator.config.SparkOperatorConf.KUEUE_ENABLED;
 import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.completeAndDefaultRequeue;
-import static 
org.apache.spark.k8s.operator.utils.ReconcilerUtils.isFirstAttempt;
 import static 
org.apache.spark.k8s.operator.utils.Utils.basicLabelSecondaryToPrimaryMapper;
 
 import java.util.ArrayList;
@@ -126,15 +125,14 @@ public class SparkClusterReconciler implements 
Reconciler<SparkCluster>, Cleaner
                       retryInfo.isLastAttempt());
                 }
               });
-      // JOSDK calls this on every retry attempt. Emitting each time would 
mean one blocking write
-      // per attempt against an API server that is often the cause of the 
failure, so only the
-      // first attempt publishes: the reason is stable, so a later attempt 
would only bump a count.
-      if (isFirstAttempt(context)) {
-        EventUtils.warn(
-            context.eventRecorder(),
-            EventUtils.REASON_RECONCILE_ERROR,
-            "Spark Cluster Reconciliation failed. " + EventUtils.describe(e));
-      }
+      // JOSDK calls this on every retry attempt, and each one publishes. The 
event write is not
+      // retried, so a write which a struggling API server dropped is 
recovered by a later attempt,
+      // while ConfigurableEventRecorder drops a repeat with the same message 
within the minimum
+      // interval, so a failure which persists only bumps the count of the one 
event.
+      EventUtils.warn(
+          context.eventRecorder(),
+          EventUtils.REASON_RECONCILE_ERROR,
+          "Spark Cluster Reconciliation failed. " + EventUtils.describe(e));
       return ErrorStatusUpdateControl.noStatusUpdate();
     } finally {
       trackedMDC.reset();
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorder.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorder.java
index c952e25f..7baff8a7 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorder.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorder.java
@@ -44,6 +44,8 @@ import 
io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
 import io.javaoperatorsdk.operator.api.reconciler.Context;
 import lombok.extern.slf4j.Slf4j;
 
+import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
+
 /**
  * An {@link EventRecorder} that drops every event unless {@link
  * 
org.apache.spark.k8s.operator.config.SparkOperatorConf#KUBERNETES_EVENTS_ENABLED}
 is set, drops
@@ -102,13 +104,21 @@ public class ConfigurableEventRecorder implements 
EventRecorder {
   }
 
   /**
-   * Constructs a recorder over the JOSDK defaults, writing events with the 
given client.
+   * Constructs a recorder over the JOSDK defaults, writing events with a 
client derived from the
+   * given one which does not retry a failed request.
+   *
+   * <p>{@link DefaultEventSink} writes on the reconciliation thread, and an 
error event often
+   * reports a failure of the API server itself, so a failed write is dropped 
like any other write
+   * failure rather than retried with backoff while the reconciliation waits. 
A write which the API
+   * server does not answer still waits for the request timeout of the given 
client.
    *
-   * @param client The Kubernetes client used to write events.
+   * @param client The Kubernetes client to derive the client which writes 
events from.
    * @return A recorder gated on the events config option.
    */
   public static ConfigurableEventRecorder withDefaultSink(KubernetesClient 
client) {
-    return new ConfigurableEventRecorder(new DefaultEventRecorder(new 
DefaultEventSink(client)));
+    return new ConfigurableEventRecorder(
+        new DefaultEventRecorder(
+            new 
DefaultEventSink(KubernetesClientFactory.withoutRetries(client))));
   }
 
   /**
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ReconcilerUtils.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ReconcilerUtils.java
index 5388a646..60b32591 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ReconcilerUtils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ReconcilerUtils.java
@@ -53,9 +53,7 @@ import io.fabric8.kubernetes.client.KubernetesClient;
 import io.fabric8.kubernetes.client.KubernetesClientException;
 import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable;
 import io.fabric8.kubernetes.client.dsl.PodResource;
-import io.javaoperatorsdk.operator.api.reconciler.Context;
 import io.javaoperatorsdk.operator.api.reconciler.DeleteControl;
-import io.javaoperatorsdk.operator.api.reconciler.RetryInfo;
 import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
 import lombok.extern.slf4j.Slf4j;
 
@@ -357,18 +355,6 @@ public final class ReconcilerUtils {
     };
   }
 
-  /**
-   * Whether the given reconciliation is the first attempt at the resource 
rather than a retry.
-   * {@link RetryInfo#getAttemptCount()} is 0 for an execution that is not a 
retry, and the info is
-   * absent altogether when retries are not configured.
-   *
-   * @param context The reconciliation context.
-   * @return True if this execution is not a retry, false otherwise.
-   */
-  public static boolean isFirstAttempt(Context<?> context) {
-    return context.getRetryInfo().map(RetryInfo::getAttemptCount).orElse(0) == 
0;
-  }
-
   /**
    * Whether the given failure is expected to clear without anyone acting on 
it, so that a caller
    * may wait it out rather than report it. A broken connection and an 
overloaded or proxied server
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/client/KubernetesClientFactoryTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/client/KubernetesClientFactoryTest.java
new file mode 100644
index 00000000..ba8dc4de
--- /dev/null
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/client/KubernetesClientFactoryTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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 org.apache.spark.k8s.operator.client;
+
+import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
+import static 
org.apache.spark.k8s.operator.metrics.source.KubernetesMetricsInterceptor.HTTP_REQUEST_FAILED_GROUP;
+import static 
org.apache.spark.k8s.operator.metrics.source.KubernetesMetricsInterceptor.HTTP_REQUEST_GROUP;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import org.junit.jupiter.api.Test;
+
+import 
org.apache.spark.k8s.operator.metrics.source.KubernetesMetricsInterceptor;
+
+@EnableKubernetesMockClient
+class KubernetesClientFactoryTest {
+
+  private static final String CONFIG_MAP_PATH = 
"/api/v1/namespaces/ns-1/configmaps/cm-1";
+
+  private KubernetesMockServer server;
+  private KubernetesClient client;
+
+  @Test
+  void withoutRetriesSendsAFailedRequestOnceThroughTheSameInterceptors() {
+    KubernetesMetricsInterceptor interceptor = new 
KubernetesMetricsInterceptor();
+    try (KubernetesClient given =
+        KubernetesClientFactory.buildKubernetesClient(
+            List.of(interceptor), client.getConfiguration())) {
+      // The given client retries a request which fails once, and the retry 
finds no ConfigMap.
+      
server.expect().get().withPath(CONFIG_MAP_PATH).andReturn(HTTP_UNAVAILABLE, 
null).once();
+      
assertThat(given.configMaps().inNamespace("ns-1").withName("cm-1").get()).isNull();
+      assertThat(meterCount(interceptor, HTTP_REQUEST_GROUP)).isEqualTo(2L);
+
+      
server.expect().get().withPath(CONFIG_MAP_PATH).andReturn(HTTP_UNAVAILABLE, 
null).once();
+      KubernetesClient withoutRetries = 
KubernetesClientFactory.withoutRetries(given);
+      assertThatThrownBy(
+              () -> 
withoutRetries.configMaps().inNamespace("ns-1").withName("cm-1").get())
+          .isInstanceOfSatisfying(
+              KubernetesClientException.class,
+              e -> assertThat(e.getCode()).isEqualTo(HTTP_UNAVAILABLE));
+      // The single request is still measured by the interceptor of the given 
client.
+      assertThat(meterCount(interceptor, HTTP_REQUEST_GROUP)).isEqualTo(3L);
+
+      // A connection failure is counted as a request but not as failed, since 
fabric8 reports it
+      // to the interceptors only while the request has a retry left.
+      long failed = meterCount(interceptor, HTTP_REQUEST_FAILED_GROUP);
+      server.shutdown();
+      assertThatThrownBy(
+              () -> 
withoutRetries.configMaps().inNamespace("ns-1").withName("cm-1").get())
+          .isInstanceOf(KubernetesClientException.class);
+      assertThat(meterCount(interceptor, HTTP_REQUEST_GROUP)).isEqualTo(4L);
+      assertThat(meterCount(interceptor, 
HTTP_REQUEST_FAILED_GROUP)).isEqualTo(failed);
+    }
+  }
+
+  private static long meterCount(KubernetesMetricsInterceptor interceptor, 
String name) {
+    return interceptor.metricRegistry().meter(name).getCount();
+  }
+}
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
index d86f7b1e..572645dc 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
@@ -38,7 +38,6 @@ import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -422,7 +421,7 @@ class SparkAppReconcilerTest {
   }
 
   @Test
-  void updateErrorStatusPublishesOnlyOnTheFirstAttempt() {
+  void updateErrorStatusPublishesOnARetryToo() {
     when(mockContext.eventRecorder()).thenReturn(mockEventRecorder);
     var retryInfo = mock(RetryInfo.class);
     when(retryInfo.getAttemptCount()).thenReturn(1);
@@ -430,9 +429,9 @@ class SparkAppReconcilerTest {
 
     reconciler.updateErrorStatus(app, mockContext, new 
RuntimeException("boom"));
 
-    // JOSDK calls this on every retry attempt, each emit is a blocking write 
against an API
-    // server that is often the cause of the failure.
-    verify(mockEventRecorder, never()).record(any(EventRecord.class));
+    // The event write is not retried, so a retry attempt recovers a write 
which a struggling API
+    // server dropped, while the recorder drops an unchanged repeat within the 
minimum interval.
+    
assertThat(captureRecordedEvent().reason()).isEqualTo(EventUtils.REASON_RECONCILE_ERROR);
   }
 
   @Test
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconcilerTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconcilerTest.java
index 57e4a594..41ca91b2 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconcilerTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkClusterReconcilerTest.java
@@ -33,7 +33,6 @@ import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -298,7 +297,7 @@ class SparkClusterReconcilerTest {
   }
 
   @Test
-  void updateErrorStatusPublishesOnlyOnTheFirstAttempt() {
+  void updateErrorStatusPublishesOnARetryToo() {
     when(mockContext.eventRecorder()).thenReturn(mockEventRecorder);
     var retryInfo = mock(RetryInfo.class);
     when(retryInfo.getAttemptCount()).thenReturn(1);
@@ -306,7 +305,7 @@ class SparkClusterReconcilerTest {
 
     reconciler.updateErrorStatus(cluster, mockContext, new 
RuntimeException("boom"));
 
-    verify(mockEventRecorder, never()).record(any(EventRecord.class));
+    
assertThat(captureRecordedEvent().reason()).isEqualTo(EventUtils.REASON_RECONCILE_ERROR);
   }
 
   @Test
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorderDefaultSinkTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorderDefaultSinkTest.java
new file mode 100644
index 00000000..2fc6d1c0
--- /dev/null
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorderDefaultSinkTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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 org.apache.spark.k8s.operator.utils;
+
+import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
+import static org.apache.spark.k8s.operator.Constants.HTTP_TOO_MANY_REQUESTS;
+import static 
org.apache.spark.k8s.operator.config.SparkOperatorConf.KUBERNETES_EVENTS_ENABLED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import java.util.Map;
+
+import io.fabric8.kubernetes.api.model.Event;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import io.javaoperatorsdk.operator.api.config.ConfigurationService;
+import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.event.EventRecord;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.spark.k8s.operator.SparkApplication;
+import org.apache.spark.k8s.operator.config.SparkOperatorConfManager;
+
+/**
+ * Tests {@link ConfigurableEventRecorder#withDefaultSink} against a mock API 
server. It is
+ * separate from {@link ConfigurableEventRecorderTest}, since the mock server 
extension starts a
+ * server for every test of the class which it annotates.
+ */
+@EnableKubernetesMockClient(crud = true)
+class ConfigurableEventRecorderDefaultSinkTest {
+
+  private KubernetesMockServer server;
+  private KubernetesClient client;
+
+  @AfterEach
+  void resetConf() {
+    SparkOperatorConfManager.INSTANCE.refresh(Map.of());
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {HTTP_TOO_MANY_REQUESTS, HTTP_UNAVAILABLE})
+  void dropsAFailedWriteRatherThanRetryingIt(int code) {
+    // Only the first create fails, so a retry would have created the Event.
+    
SparkOperatorConfManager.INSTANCE.refresh(Map.of(KUBERNETES_EVENTS_ENABLED.getKey(),
 "true"));
+    ConfigurableEventRecorder recorder = 
ConfigurableEventRecorder.withDefaultSink(client);
+    Context<?> context = appContext();
+    
server.expect().post().withPath("/api/v1/namespaces/ns-1/events").andReturn(code,
 null).once();
+
+    recorder.record(EventRecord.warning("ReconcileError", "dropped"), context);
+    
assertThat(client.v1().events().inNamespace("ns-1").list().getItems()).isEmpty();
+
+    // The next write goes through, so the first one was dropped only for the 
injected failure.
+    recorder.record(EventRecord.warning("ReconcileError", "published"), 
context);
+    assertThat(client.v1().events().inNamespace("ns-1").list().getItems())
+        .extracting(Event::getMessage)
+        .containsExactly("published");
+  }
+
+  /** Returns a context from which the JOSDK recorder can build an Event about 
app-1 in ns-1. */
+  private static Context<?> appContext() {
+    SparkApplication app = new SparkApplication();
+    app.setMetadata(
+        new 
ObjectMetaBuilder().withName("app-1").withNamespace("ns-1").withUid("uid-1").build());
+    ControllerConfiguration<?> configuration = 
mock(ControllerConfiguration.class);
+    
doReturn(mock(ConfigurationService.class)).when(configuration).getConfigurationService();
+    Context<?> context = mock(Context.class);
+    doReturn(app).when(context).getPrimaryResource();
+    doReturn(configuration).when(context).getControllerConfiguration();
+    return context;
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to