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 17f41463 [SPARK-59931] Simplify the K8s event messages of errors and
`SchedulingFailure`
17f41463 is described below
commit 17f41463095a4de3820104c7ddb6915bf93d75d0
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Fri Oct 2 01:35:49 2026 -0700
[SPARK-59931] Simplify the K8s event messages of errors and
`SchedulingFailure`
### What changes were proposed in this pull request?
This PR aims to simplify the K8s event messages of errors and
`SchedulingFailure`.
- `EventUtils.describe` uses the message of the API server for a rejected
request, like the K8s built-in controllers, instead of the fabric8 message with
the request URL and the `Status` dump. It also no longer repeats a cause which
reads the same.
- The `SchedulingFailure` event describes the failure instead of carrying
the stack trace. The resource status keeps the stack trace.
- `docs/configuration.md` documents the event messages.
### Why are the changes needed?
K8s events reach more readers than the resource status, e.g. via the
built-in `view` ClusterRole and event exporters. Stack traces and request URLs
are not actionable for them, and they push the actual cause out of the
1024-character event message.
### Does this PR introduce _any_ user-facing change?
No, because K8s events have not been released yet. The resource status and
the operator log are unchanged.
### 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 #920 from dongjoon-hyun/SPARK-59931.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
docs/configuration.md | 27 +++++++++-
.../reconciler/reconcilesteps/AppInitStep.java | 12 ++++-
.../reconciler/reconcilesteps/ClusterInitStep.java | 5 +-
.../spark/k8s/operator/utils/EventUtils.java | 42 ++++++++++++----
.../spark/k8s/operator/utils/StatusRecorder.java | 41 +++++++++++++---
.../reconciler/reconcilesteps/AppInitStepTest.java | 19 ++++++--
.../reconcilesteps/ClusterInitStepTest.java | 14 ++++--
.../spark/k8s/operator/utils/EventUtilsTest.java | 57 ++++++++++++++++++++++
.../k8s/operator/utils/StatusRecorderTest.java | 28 +++++++++++
9 files changed, 217 insertions(+), 28 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 38b4ba69..56bba661 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -80,7 +80,9 @@ kubectl get events -A --field-selector
reportingComponent=spark-kubernetes-opera
```
Whenever a resource transitions into a new state, the operator publishes an
event whose `reason`
-is the name of the new state and whose `message` is the state message.
Repeated transitions into
+is the name of the new state and whose `message` is the state message, except
that the event of
+`SchedulingFailure` leaves out the stack trace of the failure, see
+[Event messages](#event-messages). Repeated transitions into
the same state are aggregated into a single `Event` object by incrementing its
`count` and
replacing its `message`. A repeat carrying the same `message` may be paced by
[`minIntervalSeconds`](#event-frequency), while one carrying a new `message`
is always published.
@@ -189,6 +191,29 @@ still holds the interval. An event which is republished,
such as `SuspendHeld` o
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.
+### Event messages
+
+The `message` of a `Warning` event about an error, such as `ReconcileError` or
+`ClusterRequestFailed`, describes the exception and its innermost cause,
without the stack trace,
+which the operator logs. For a request which the API server rejected, it is
the message of the API
+server, as in the events of the built-in controllers, e.g. an RBAC denial
which names the
+ServiceAccount of the operator, a rejection by an admission webhook or a
resource quota, or a field
+value which the API server found invalid. When `SchedulingFailure` is caused
by an exception, its
+event describes the exception alike, while its state message keeps the stack
trace.
+
+Events reach more readers than the status of the resources. The built-in
`view`, `edit` and
+`admin` ClusterRoles grant reading the `events` of a namespace, but not the
`SparkApplication` and
+`SparkCluster` resources, which the Helm chart does not aggregate into them,
and event exporters
+often forward warnings out of the cluster, e.g. to chat or logging services.
So keep credentials
+out of `spec.sparkConf` and pod templates, since an error may quote them, e.g.
as an invalid value,
+and refer to Kubernetes Secrets instead, e.g. with
`spark.kubernetes.driver.secretKeyRef.[EnvName]`
+and `spark.kubernetes.executor.secretKeyRef.[EnvName]`. To keep the details of
failures in the
+resource status and the operator log only, exclude their reasons, e.g. as
follows.
+
+```properties
+spark.kubernetes.operator.events.excludedReasons=SchedulingFailure,ReconcileError,CleanupError,StatusUpdateFailed,ClusterRequestFailed,SuspendReleaseFailed,Kueue.*Failed
+```
+
## Metrics
Spark operator,
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
index 01efce15..b1bf58c8 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
@@ -52,6 +52,7 @@ import
org.apache.spark.k8s.operator.status.ApplicationAttemptSummary;
import org.apache.spark.k8s.operator.status.ApplicationState;
import org.apache.spark.k8s.operator.status.ApplicationStateSummary;
import org.apache.spark.k8s.operator.status.ApplicationStatus;
+import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
@@ -162,8 +163,15 @@ public final class AppInitStep extends AppReconcileStep {
.getStatus()
.appendNewState(
new
ApplicationState(ApplicationStateSummary.SchedulingFailure, errorMessage));
- return attemptStatusUpdate(
- context, statusRecorder, updatedStatus,
completeAndImmediateRequeue());
+ // The status keeps the stack trace, while the event, which reaches more
readers, only
+ // describes the failure, like the other warning events.
+ if (!statusRecorder.persistStatus(
+ context,
+ updatedStatus,
+ Constants.SCHEDULE_FAILURE_MESSAGE + " " + EventUtils.describe(e))) {
+ log.warn("Failed to persist status, will retry status update in next
reconcile attempt");
+ }
+ return completeAndImmediateRequeue();
}
ApplicationStatus updatedStatus =
context
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
index 2378c34c..2f5f399b 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
@@ -199,12 +199,15 @@ public final class ClusterInitStep extends
ClusterReconcileStep {
log.error("Failed to request master resource.", e);
}
String msg = CLUSTER_SCHEDULE_FAILURE_MESSAGE + " StackTrace: " +
buildGeneralErrorMessage(e);
+ // The status keeps the stack trace, while the event, which reaches more
readers, only
+ // describes the failure, like the other warning events.
statusRecorder.persistStatus(
context,
context
.getResource()
.getStatus()
- .appendNewState(new ClusterState(SchedulingFailure, msg)));
+ .appendNewState(new ClusterState(SchedulingFailure, msg)),
+ CLUSTER_SCHEDULE_FAILURE_MESSAGE + " " + EventUtils.describe(e));
return completeAndImmediateRequeue();
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
index 6dc72a8f..a33384f3 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
@@ -21,6 +21,7 @@ package org.apache.spark.k8s.operator.utils;
import java.util.Set;
+import io.fabric8.kubernetes.client.KubernetesClientException;
import io.javaoperatorsdk.operator.api.event.EventRecord;
import io.javaoperatorsdk.operator.api.event.EventType;
import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
@@ -146,8 +147,8 @@ public final class EventUtils {
* <p>The reason doubles as the event key, so repeated warnings for the same
reason on the same
* resource collapse into a count increment on a single Event object instead
of creating one
* Event per occurrence. Messages therefore must not be part of the
identity: they typically vary
- * between attempts, for instance because a {@code
KubernetesClientException} message embeds the
- * request URL.
+ * between attempts, for instance because a rejection by a resource quota
reports its current
+ * usage.
*
* @param recorder The event recorder bound to the resource.
* @param reason A short CamelCase reason, as expected by Kubernetes.
@@ -193,8 +194,14 @@ public final class EventUtils {
/**
* Builds a concise single-line description of an exception, suitable for an
event message. The
* innermost cause is appended when the exception has one, since that is
usually where the
- * actionable detail is. The full stack trace is intentionally omitted, it
belongs in the
- * operator log.
+ * actionable detail is, unless its message is the same. The full stack
trace is intentionally
+ * omitted, it belongs in the operator log.
+ *
+ * <p>A request which the API server answered with an error is described by
the message of the
+ * returned {@code Status}, like the events of the built-in controllers,
rather than by the
+ * message of fabric8, which wraps it with the request URL and a dump of the
whole {@code
+ * Status}. fabric8 rethrows such a failure as a copy of itself which
carries the original as its
+ * cause, so that cause is not repeated.
*
* @param throwable The exception to describe, may be null.
* @return A description of the exception.
@@ -203,16 +210,31 @@ public final class EventUtils {
if (throwable == null) {
return "";
}
- StringBuilder builder = new StringBuilder(describeSingle(throwable));
+ String message = messageOf(throwable);
+ String description = describeSingle(throwable, message);
Throwable rootCause = rootCauseOf(throwable);
- if (rootCause != null) {
- builder.append(", caused by: ").append(describeSingle(rootCause));
+ if (rootCause == null) {
+ return description;
+ }
+ String rootCauseMessage = messageOf(rootCause);
+ // A cause with the same message adds nothing but its type, e.g. the
original of the copy which
+ // fabric8 rethrows, or the request timeout which it wraps.
+ if (StringUtils.isNotBlank(message) && message.equals(rootCauseMessage)) {
+ return description;
+ }
+ return description + ", caused by: " + describeSingle(rootCause,
rootCauseMessage);
+ }
+
+ private static String messageOf(Throwable throwable) {
+ if (throwable instanceof KubernetesClientException e
+ && e.getStatus() != null
+ && StringUtils.isNotBlank(e.getStatus().getMessage())) {
+ return e.getStatus().getMessage();
}
- return builder.toString();
+ return throwable.getMessage();
}
- private static String describeSingle(Throwable throwable) {
- String message = throwable.getMessage();
+ private static String describeSingle(Throwable throwable, String message) {
String type = throwable.getClass().getSimpleName();
return StringUtils.isBlank(message) ? type : type + ": " + message;
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
index 724fa1ab..dbc43ad5 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
@@ -84,8 +84,11 @@ public class StatusRecorder<
* behavior.
*
* @param context Context of the resource for which status update should be
performed.
+ * @param currentStateEventMessage The message of the event about the
current state, or null for
+ * the message of that state.
*/
- private void patchAndStatusWithVersionLocked(BaseContext<CR> context) {
+ private void patchAndStatusWithVersionLocked(
+ BaseContext<CR> context, String currentStateEventMessage) {
CR resource = context.getResource();
KubernetesClient client = context.getClient();
ObjectNode newStatusNode = objectMapper.convertValue(resource.getStatus(),
ObjectNode.class);
@@ -139,7 +142,7 @@ public class StatusRecorder<
listener -> {
listener.listenStatus(resource, prevStatus, resource.getStatus());
});
- recordTransitionEvents(context, prevStatus, resource.getStatus());
+ recordTransitionEvents(context, prevStatus, resource.getStatus(),
currentStateEventMessage);
}
/**
@@ -159,13 +162,17 @@ public class StatusRecorder<
*
* <p>The event type follows {@link
EventUtils#eventTypeOf(BaseStateSummary)}. The state name is
* used as both reason and dedup key, so an app that keeps re-entering the
same state bumps the
- * count on one Event instead of accumulating one per attempt.
+ * count on one Event instead of accumulating one per attempt. The message
is the state message,
+ * unless a message is given for the event about the current state.
*
* @param context Context of the resource whose status has just been patched.
* @param prevStatus The status before the patch, may be null.
* @param status The status that was persisted.
+ * @param currentStateEventMessage The message of the event about the
current state, or null for
+ * the message of that state.
*/
- private void recordTransitionEvents(BaseContext<CR> context, STATUS
prevStatus, STATUS status) {
+ private void recordTransitionEvents(
+ BaseContext<CR> context, STATUS prevStatus, STATUS status, String
currentStateEventMessage) {
BaseState<?> currentState = status.getCurrentState();
if (currentState == null
|| (prevStatus != null &&
currentState.equals(prevStatus.getCurrentState()))) {
@@ -175,7 +182,10 @@ public class StatusRecorder<
for (BaseState<?> state : newStatesSince(prevStatus, status)) {
Object summary = state.getCurrentStateSummary();
if (summary instanceof BaseStateSummary stateSummary &&
!summary.equals(lastSummary)) {
- String message = state.getMessage();
+ String message =
+ currentStateEventMessage != null && state.equals(currentState)
+ ? currentStateEventMessage
+ : state.getMessage();
EventUtils.record(
context.getEventRecorder(),
EventUtils.eventTypeOf(stateSummary),
@@ -219,9 +229,28 @@ public class StatusRecorder<
* @return true if the status is successfully patched.
*/
public boolean persistStatus(BaseContext<CR> context, STATUS newStatus) {
+ return persistStatus(context, newStatus, null);
+ }
+
+ /**
+ * Persists the new status of the resource to the Kubernetes cluster, like
{@link
+ * #persistStatus(BaseContext, BaseStatus)}, but publishes the given message
in place of the
+ * message of the current state in the event about the transition into it.
It is meant for a
+ * state message which is too detailed for an event, such as one which
carries a stack trace,
+ * since events typically reach more readers than the resource status, e.g.
through the built-in
+ * {@code view} ClusterRole or an event exporter.
+ *
+ * @param context The BaseContext containing the resource and client.
+ * @param newStatus The new status to persist.
+ * @param currentStateEventMessage The message of the event about the
current state, or null for
+ * the message of that state.
+ * @return true if the status is successfully patched.
+ */
+ public boolean persistStatus(
+ BaseContext<CR> context, STATUS newStatus, String
currentStateEventMessage) {
try {
context.getResource().setStatus(newStatus);
- patchAndStatusWithVersionLocked(context);
+ patchAndStatusWithVersionLocked(context, currentStateEventMessage);
return true;
} catch (KubernetesClientException e) {
log.error("Error while persisting status to {}", newStatus, e);
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
index 2c95ff4a..5bcc3db6 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
@@ -1135,7 +1135,7 @@ class AppInitStepTest {
Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(),
progress);
Assertions.assertNull(getWorkload());
ArgumentCaptor<ApplicationStatus> captor =
ArgumentCaptor.forClass(ApplicationStatus.class);
- verify(recorder).persistStatus(any(), captor.capture());
+ verify(recorder).persistStatus(any(), captor.capture(), any());
Assertions.assertEquals(
ApplicationStateSummary.SchedulingFailure,
captor.getValue().getCurrentState().getCurrentStateSummary());
@@ -1638,16 +1638,25 @@ class AppInitStepTest {
throw new SparkException(
"Please specify spark.kubernetes.file.upload.path
property.");
});
- when(recorder.persistStatus(any(), any())).thenReturn(true);
+ when(recorder.persistStatus(any(), any(), any())).thenReturn(true);
ReconcileProgress progress = appInitStep.reconcile(mockContext, recorder);
Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(),
progress);
ArgumentCaptor<ApplicationStatus> captor =
ArgumentCaptor.forClass(ApplicationStatus.class);
- verify(recorder).persistStatus(any(), captor.capture());
+ ArgumentCaptor<String> eventMessage =
ArgumentCaptor.forClass(String.class);
+ verify(recorder).persistStatus(any(), captor.capture(),
eventMessage.capture());
Assertions.assertEquals(
ApplicationStateSummary.SchedulingFailure,
captor.getValue().getCurrentState().getCurrentStateSummary());
+ // The status keeps the stack trace, while the event only describes the
failure
+ Assertions.assertTrue(
+ captor.getValue().getCurrentState().getMessage().contains("\tat "),
+ captor.getValue().getCurrentState().getMessage());
+ Assertions.assertEquals(
+ Constants.SCHEDULE_FAILURE_MESSAGE
+ + " SparkException: Please specify
spark.kubernetes.file.upload.path property.",
+ eventMessage.getValue());
Assertions.assertNotNull(getWorkload());
}
@@ -1804,7 +1813,7 @@ class AppInitStepTest {
when(mockContext.getClient()).thenReturn(kubernetesClient);
when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
createFlavor("spot-flavor", Map.of("pool", "spot"), List.of());
- when(recorder.persistStatus(any(), any())).thenReturn(true);
+ when(recorder.persistStatus(any(), any(), any())).thenReturn(true);
appInitStep.reconcile(mockContext, recorder);
admitWorkload(Map.of("driver", Map.of("cpu", "spot-flavor")));
@@ -1815,7 +1824,7 @@ class AppInitStepTest {
Assertions.assertNull(getWorkload());
verify(mockContext, never()).setKueuePodSetFlavors(any());
ArgumentCaptor<ApplicationStatus> captor =
ArgumentCaptor.forClass(ApplicationStatus.class);
- verify(recorder).persistStatus(any(), captor.capture());
+ verify(recorder).persistStatus(any(), captor.capture(), any());
Assertions.assertEquals(
ApplicationStateSummary.SchedulingFailure,
captor.getValue().getCurrentState().getCurrentStateSummary());
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
index 02afbd51..3fab2f4f 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
@@ -293,11 +293,19 @@ class ClusterInitStepTest {
ReconcileProgress.completeAndImmediateRequeue(),
clusterInitStep.reconcile(mockContext, recorder));
ArgumentCaptor<ClusterStatus> statusCaptor =
ArgumentCaptor.forClass(ClusterStatus.class);
- verify(recorder).persistStatus(any(), statusCaptor.capture());
+ ArgumentCaptor<String> eventMessage =
ArgumentCaptor.forClass(String.class);
+ verify(recorder).persistStatus(any(), statusCaptor.capture(),
eventMessage.capture());
Assertions.assertEquals(
ClusterStateSummary.SchedulingFailure,
statusCaptor.getValue().getCurrentState().getCurrentStateSummary());
- // The status already says why, so no event is needed
+ // The status keeps the stack trace, which the SchedulingFailure event
leaves out
+ Assertions.assertTrue(
+ statusCaptor.getValue().getCurrentState().getMessage().contains("\tat
"),
+ statusCaptor.getValue().getCurrentState().getMessage());
+ Assertions.assertEquals(
+ Constants.CLUSTER_SCHEDULE_FAILURE_MESSAGE + "
KubernetesClientException: Invalid",
+ eventMessage.getValue());
+ // SchedulingFailure already says why, so no other event is needed
verifyNoInteractions(eventRecorder);
}
@@ -1082,7 +1090,7 @@ class ClusterInitStepTest {
verify(mockContext, never()).setKueuePodSetFlavors(any());
verify(mockContext, never()).getMasterServiceSpec();
ArgumentCaptor<ClusterStatus> captor =
ArgumentCaptor.forClass(ClusterStatus.class);
- verify(recorder).persistStatus(any(), captor.capture());
+ verify(recorder).persistStatus(any(), captor.capture(), any());
Assertions.assertEquals(
ClusterStateSummary.SchedulingFailure,
captor.getValue().getCurrentState().getCurrentStateSummary());
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
index d53d55bd..422cae7a 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
@@ -27,9 +27,13 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.util.Set;
+import java.util.concurrent.TimeoutException;
import io.fabric8.kubernetes.api.model.Event;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.client.KubernetesClientException;
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder;
@@ -194,6 +198,59 @@ class EventUtilsTest {
+ "IllegalArgumentException: quota exceeded");
}
+ @Test
+ void describeUsesTheMessageOfTheApiServerForARejectedRequest() {
+ Status status =
+ new StatusBuilder()
+ .withCode(403)
+ .withReason("Forbidden")
+ .withMessage("pods \"app-1-driver\" is forbidden: exceeded quota:
compute")
+ .build();
+ // Like fabric8 builds the failure of a request which the API server
rejected, and rethrows it
+ // as a copy which carries the original as its cause.
+ KubernetesClientException rejected =
+ new KubernetesClientException(
+ "Failure executing: POST at:
https://10.43.0.1:443/api/v1/namespaces/default/pods. "
+ + "Message: "
+ + status.getMessage()
+ + ". Received status: "
+ + status
+ + ".",
+ 403,
+ status);
+
+ // Neither the request URL nor the dump of the Status, and the cause is
not repeated.
+ assertThat(EventUtils.describe(rejected.copyAsCause()))
+ .isEqualTo("KubernetesClientException: " + status.getMessage());
+ assertThat(EventUtils.describe(new IllegalStateException("not requested",
rejected)))
+ .isEqualTo(
+ "IllegalStateException: not requested, caused by:
KubernetesClientException: "
+ + status.getMessage());
+ }
+
+ @Test
+ void describeKeepsTheMessageOfAFailureWithoutStatus() {
+ KubernetesClientException notFound =
+ new KubernetesClientException("Kueue ResourceFlavor spot is not
found.", 404, null);
+
+ assertThat(EventUtils.describe(notFound))
+ .isEqualTo("KubernetesClientException: Kueue ResourceFlavor spot is
not found.");
+ }
+
+ @Test
+ void describeDoesNotRepeatACauseWithTheSameMessage() {
+ // Like fabric8 wraps a request timeout, which is neither an IOException
nor a
+ // KubernetesClientException, in a KubernetesClientException with the same
message.
+ String message =
+ "The timeout period of 1000ms has been exceeded while executing POST "
+ + "/api/v1/namespaces/default/pods for server 10.43.0.1:443";
+
+ assertThat(
+ EventUtils.describe(
+ new KubernetesClientException(message, new
TimeoutException(message))))
+ .isEqualTo("KubernetesClientException: " + message);
+ }
+
@Test
void describeHandlesNullAndCauseless() {
assertThat(EventUtils.describe(null)).isEmpty();
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
index 57ba2e3c..52a13a0b 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
@@ -235,6 +235,34 @@ class StatusRecorderTest {
ApplicationStateSummary.DriverReady.name());
}
+ @Test
+ void publishesTheGivenMessageInTheEventAboutTheCurrentStateOnly() {
+ var testResource = getSparkApplication("1");
+ var context = contextFor(testResource);
+ expectStatusPatch(testResource, getSparkApplication("2"));
+
+ var requested =
+ new ApplicationStatus()
+ .appendNewState(
+ new ApplicationState(ApplicationStateSummary.DriverRequested,
"requested"));
+ statusRecorder.persistStatus(context, requested);
+ String stackTrace = "SparkException: boom\n\tat Foo.bar(Foo.java:1)";
+ statusRecorder.persistStatus(
+ context,
+ requested
+ .appendNewState(new
ApplicationState(ApplicationStateSummary.DriverStarted, "started"))
+ .appendNewState(new
ApplicationState(ApplicationStateSummary.Failed, stackTrace)),
+ "SparkException: boom");
+
+ ArgumentCaptor<EventRecord> captor =
ArgumentCaptor.forClass(EventRecord.class);
+ verify(mockEventRecorder, times(3)).record(captor.capture());
+ assertThat(captor.getAllValues())
+ .extracting(EventRecord::message)
+ .containsExactly("requested", "started", "SparkException: boom");
+ // The status keeps the message of the state
+
assertThat(testResource.getStatus().getCurrentState().getMessage()).isEqualTo(stackTrace);
+ }
+
@Test
void publishesOnceForRepeatedStatesInASinglePatch() {
var testResource = getSparkApplication("1");
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]