dongjoon-hyun commented on code in PR #909:
URL: 
https://github.com/apache/spark-kubernetes-operator/pull/909#discussion_r4137431972


##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java:
##########
@@ -78,8 +78,7 @@ public ReconcileProgress reconcile(
         // Whether the master is live is unknown, not answered. Holding would 
claim in an event
         // that none was requested, and would release the Kueue quota of a 
running master, so
         // look again with the steady-state interval instead.
-        log.warn("Failed to check whether the master of a suspended cluster 
exists.", e);
-        return completeAndDefaultRequeue();
+        return SuspendUtils.retryAfterCheckFailure(context, e, "master and 
workers");

Review Comment:
   **[P1]** With `"master and workers"`, the event message renders as follows, 
and the log message starts the same way.
   
   ```
   Failed to check whether the master and workers of the suspended SparkCluster 
was requested, will retry. KubernetesClientException: ...
   ```
   
   The verb doesn't agree with the subject, and `isMasterRequested` reads only 
the master `StatefulSet`, so the workers are never checked. The new docs row, 
the Javadoc of `REASON_SUSPEND_CHECK_FAILED` and the sibling message at L249 
(`Failed to check whether the master exists ...`) all say `master`, and the log 
message which this replaces (`Failed to check whether the master of a suspended 
cluster exists.`) was accurate as well.
   
   ```suggestion
           return SuspendUtils.retryAfterCheckFailure(context, e, "master");
   ```
   
   `ClusterInitStepTest` L899 needs the same change (`"master of the suspended 
SparkCluster"`).



##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java:
##########
@@ -88,4 +91,37 @@ static ReconcileProgress holdForSuspend(final BaseContext<?> 
context, final Stri
     return completeAndRequeueAfter(
         Duration.ofSeconds(SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS.getValue()));
   }
+
+  /**
+   * Reports a failed check of whether the resources of a suspended resource 
were requested, and
+   * returns the progress to retry it with. Whether they are live is unknown 
then, so the resource
+   * is neither held, which would claim in an event that none was requested 
and release the Kueue
+   * quota of a running driver or master, nor started. Like a failed Kueue 
admission request, a
+   * transport level failure is not published, since writing an event would 
only add load to an API
+   * server that is often the cause of it. Anything else is, since a suspended 
resource may have no
+   * persisted status to show it and would otherwise be retried without any 
signal.
+   *
+   * @param context The context of the suspended resource.
+   * @param e The failure to report.
+   * @param requested The resources whose request could not be checked, as 
named in the event and
+   *     the log, e.g. {@code "driver"}.
+   * @return The progress to return, requeued after the steady-state interval.
+   */
+  static ReconcileProgress retryAfterCheckFailure(
+      final BaseContext<?> context, final KubernetesClientException e, final 
String requested) {
+    String what =
+        "Failed to check whether the "
+            + requested
+            + " of the suspended "
+            + context.getResource().getKind()
+            + " was requested";
+    log.warn("{}, will retry.", what, e);
+    if (!ReconcilerUtils.isTransientError(e)) {

Review Comment:
   **[P3]** Nothing retracts this warning once the check succeeds again. The 
recovering `holdForSuspend` publishes the same `SuspendHeld` message as before, 
which `ConfigurableEventRecorder` drops if it was published within 
`minIntervalSeconds` (300s), and the next one comes only after 
`suspendHoldRequeueIntervalSeconds` (1800s). For example, for a suspended 
`SparkCluster` on its first attempt, whose master is read from the API server 
on every reconciliation:
   
   - t=0s: It is held, and `SuspendHeld` is published.
   - t=60s: A spec edit triggers a reconciliation, the read fails with 403, and 
`SuspendCheckFailed` is published. It is requeued after 120s.
   - t=180s: The read succeeds, and `SuspendHeld` with the same message is 
dropped.
   - t=1980s: `SuspendHeld` is published again.
   
   So, for about 30 minutes, the newest event says "will retry" although the 
check succeeded, while before this PR the newest event stayed the accurate 
`SuspendHeld`. The trigger is narrow, but since `holdForSuspend` already 
retracts a stale `KueueAdmissionPending` in its message, it may be worth 
handling here too, e.g. by saying so in the recovering `SuspendHeld`, or at 
least by documenting it.



##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java:
##########
@@ -78,8 +78,7 @@ public ReconcileProgress reconcile(
         // Whether the master is live is unknown, not answered. Holding would 
claim in an event
         // that none was requested, and would release the Kueue quota of a 
running master, so
         // look again with the steady-state interval instead.
-        log.warn("Failed to check whether the master of a suspended cluster 
exists.", e);
-        return completeAndDefaultRequeue();
+        return SuspendUtils.retryAfterCheckFailure(context, e, "master and 
workers");
       }
       if (!masterRequested) {
         // Unlike a first attempt, a cluster resumed from Suspended has 
persisted this Submitted

Review Comment:
   **[P4]** For a cluster with a persisted history (`lastKey() > 0`, i.e. 
resumed from `Suspended` or requeued after an eviction), both outcomes of the 
lookup normally end in `Suspended`: a requested master completes the 
initialization and is suspended from `RunningHealthy` by `ClusterSuspendStep`, 
and an unrequested one goes to `Suspended` right here. Since 
`ClusterSuspendStep` deletes the `StatefulSet`s by name and releases the 
`Workload` only after the pods are gone, whether or not the cluster went 
through `RunningHealthy`, we could check `lastKey() > 0` before 
`isMasterRequested` and skip the lookup for such a cluster. Then the lookup and 
`SuspendCheckFailed` remain only for the first attempt, which is exactly the 
case without a persisted status that this PR targets. Currently, while the read 
keeps failing (e.g. 403), such a cluster republishes `SuspendCheckFailed` every 
120s, and its persisted status keeps saying `Cluster is resumed as spec.suspend 
is set to false.`, which is wh
 at this comment wants to avoid.



##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java:
##########
@@ -78,8 +78,7 @@ public ReconcileProgress reconcile(
         // Whether the master is live is unknown, not answered. Holding would 
claim in an event
         // that none was requested, and would release the Kueue quota of a 
running master, so
         // look again with the steady-state interval instead.
-        log.warn("Failed to check whether the master of a suspended cluster 
exists.", e);
-        return completeAndDefaultRequeue();
+        return SuspendUtils.retryAfterCheckFailure(context, e, "master and 
workers");
       }
       if (!masterRequested) {

Review Comment:
   **[P2]** When this check finds the master, e.g. since the status update to 
`RunningHealthy` was lost, `holdForKueueAdmission` calls `isMasterRequested` 
again (L229 and L235). This double lookup predates this PR, but now that the 
lookup of a suspended cluster has its own reason, a failure of the second read 
is reported differently: as `KueueAdmissionRequestFailed` (`Failed to check 
whether the master exists before requesting Kueue admission`), with a 5-second 
retry of a transient failure, instead of `SuspendCheckFailed` with the default 
interval, as the new docs row says. In the rare case where the second read 
returns `null`, a suspended cluster would even request the Kueue admission. 
`AppInitStep` avoids this by keeping `driverRequested` outside the suspend 
branch and passing it to `holdForKueueAdmission`. Could we do the same here, 
i.e. `masterRequested || isMasterRequested(context)` at L229 and L235?



##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java:
##########
@@ -88,4 +91,37 @@ static ReconcileProgress holdForSuspend(final BaseContext<?> 
context, final Stri
     return completeAndRequeueAfter(
         Duration.ofSeconds(SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS.getValue()));
   }
+
+  /**
+   * Reports a failed check of whether the resources of a suspended resource 
were requested, and
+   * returns the progress to retry it with. Whether they are live is unknown 
then, so the resource
+   * is neither held, which would claim in an event that none was requested 
and release the Kueue
+   * quota of a running driver or master, nor started. Like a failed Kueue 
admission request, a
+   * transport level failure is not published, since writing an event would 
only add load to an API
+   * server that is often the cause of it. Anything else is, since a suspended 
resource may have no
+   * persisted status to show it and would otherwise be retried without any 
signal.

Review Comment:
   **[P6]** This rationale applies to `holdForSuspend` above as well, which is 
not changed by this PR, so a follow-up JIRA issue is fine. 
`KueueWorkloadUtils.releaseWorkload` swallows a failed `Workload` deletion, and 
the hold then publishes a normal `SuspendHeld` without the release addendum and 
requeues after 30 minutes, without any warning. For example, if the Role of the 
operator lacks `delete` on `workloads`, or an admission webhook fails the 
deletion, the pending `Workload` of a suspended resource can be admitted into 
quota which nothing uses, while only the operator log says so. The dequeued 
path reports the same failure as `KueueAdmissionRequestFailed` (see 
`failedReleaseOfPendingKueueWorkloadHoldsDequeuedCluster`), and the reason 
given in the Javadoc of `releaseWorkload`, "the Workload is garbage collected 
with its owner anyway", doesn't hold for a suspended owner.



##########
docs/configuration.md:
##########
@@ -93,6 +93,7 @@ In addition, the operator publishes the following `Warning` 
events.
 | `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. |
 | `ClusterRequestFailed` | Applying the Services, StatefulSets, NetworkPolicy, 
HorizontalPodAutoscaler or PodDisruptionBudget of a `SparkCluster` fails in a 
way which may yet succeed, such as a throttled request or an internal server 
error from an admission webhook, so it is retried with the default interval 
instead of failing the cluster. Transport-level errors are skipped. |
 | `SuspendReleaseFailed` | Deleting the master and worker StatefulSets, the 
HorizontalPodAutoscaler or PodDisruptionBudget of the workers, or the Kueue 
`Workload` of a `SparkCluster` suspended while running fails, or its pods 
cannot be listed. A failure which may clear on its own, such as a timeout, is 
skipped. Every failure is retried with the default interval. |
+| `SuspendCheckFailed` | Whether the driver of a suspended `SparkApplication`, 
or the master of a suspended `SparkCluster`, was requested cannot be checked, 
so the resource is neither held, which would release its Kueue `Workload`, nor 
started. Transport-level errors are skipped. Every failure is retried with the 
default interval. |

Review Comment:
   **[P5]** This row covers any failure to check, but only a 
`KubernetesClientException` reaches `retryAfterCheckFailure`. For a 
`SparkApplication`, a driver spec which cannot be built fails the same check 
differently. For example, when a suspended application has a live driver pod in 
the informer cache and its `sparkConf` was edited so that the spec no longer 
builds, `SparkAppContext.getCurrentAttemptDriverPod` (L117) throws a 
`SparkException` out of the reconciliation. It then gets `ReconcileError` on 
the first attempt only and the JOSDK retries, but neither `SuspendCheckFailed` 
nor the default interval, as 
`suspendedAppWithCachedDriverAndUnbuildableSpecKeepsKueueWorkload` asserts. How 
about narrowing it to a failed read, like the `KueueAdmissionRequestFailed` row?
   
   ```suggestion
   | `SuspendCheckFailed` | The driver of a suspended `SparkApplication`, or 
the master of a suspended `SparkCluster`, cannot be read to check whether it 
was requested, so the resource is neither held, which would release its Kueue 
`Workload`, nor started. Transport-level errors are skipped. Every such failure 
is retried with the default interval. |
   ```



##########
docs/spark_custom_resources.md:
##########
@@ -544,7 +544,10 @@ published when enabled. Since the status is not there to 
fall back on, it is rep
 minutes by default while the hold lasts, so that it outlives the event 
retention of the API
 server. An application held later, in `ScheduledToRestart`, keeps the status 
its previous attempt
 already wrote and gets the same event, since that status says that a restart 
is due, not that the
-next attempt is withheld.
+next attempt is withheld. If the operator cannot check whether the driver (or 
master) was requested

Review Comment:
   **[P5]** Same as in `configuration.md`, a driver spec which cannot be built 
is not reported by `SuspendCheckFailed`.
   
   ```suggestion
   next attempt is withheld. If the driver (or master) cannot be read to check 
whether it was requested
   ```



##########
spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java:
##########
@@ -562,6 +562,38 @@ void suspendedAppWithUnverifiableDriverIsNotHeld() {
         application.getStatus().getCurrentState().getCurrentStateSummary());
   }
 
+  @ParameterizedTest
+  @ValueSource(ints = {403, 429, 500})
+  void suspendedAppWithPersistentlyUnverifiableDriverPublishesEvent(int code) {
+    // Unlike a transport level failure, a persistent one is reported, since a 
suspended app has no
+    // persisted status to show it. The event must not claim that no driver 
was requested.
+    AppInitStep appInitStep = new AppInitStep();
+    SparkAppContext mockContext = mock(SparkAppContext.class);
+    SparkAppStatusRecorder recorder = mock(SparkAppStatusRecorder.class);
+    SparkApplication application = new SparkApplication();
+    application.setMetadata(applicationMetadata);
+    application.getSpec().setSuspend(true);
+    when(mockContext.getResource()).thenReturn(application);
+    when(mockContext.getCurrentAttemptDriverPod())
+        .thenThrow(new KubernetesClientException("rejected", code, null));
+    when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
+
+    ReconcileProgress progress = appInitStep.reconcile(mockContext, recorder);
+
+    Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(), 
progress);
+    verify(mockContext, never()).getDriverPodSpec();
+    verifyNoInteractions(recorder);
+    ArgumentCaptor<EventRecord> event = 
ArgumentCaptor.forClass(EventRecord.class);
+    verify(eventRecorder).record(event.capture());
+    Assertions.assertEquals(EventType.WARNING, event.getValue().type());
+    Assertions.assertEquals(EventUtils.REASON_SUSPEND_CHECK_FAILED, 
event.getValue().reason());
+    Assertions.assertTrue(
+        event.getValue().message().contains("driver of the suspended 
SparkApplication"),
+        event.getValue().message());
+    Assertions.assertFalse(
+        event.getValue().message().contains("would not be requested"), 
event.getValue().message());

Review Comment:
   **[P7]** These assertions pin neither the full message nor its cause. For 
instance, both new tests still pass when `+ EventUtils.describe(e)` is dropped 
from `retryAfterCheckFailure`, and the `contains` check did not reveal the P1 
message. Also, the `assertFalse(...)` can hardly fail, since the reason is 
asserted above and the template is fixed. How about comparing the full message, 
like the `SuspendHeld` tests in this file?
   
   ```java
       Assertions.assertEquals(
           "Failed to check whether the driver of the suspended 
SparkApplication was requested, "
               + "will retry. KubernetesClientException: rejected",
           event.getValue().message());
   ```
   
   The same applies to `ClusterInitStepTest` L898-902.



##########
spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java:
##########
@@ -562,6 +562,38 @@ void suspendedAppWithUnverifiableDriverIsNotHeld() {
         application.getStatus().getCurrentState().getCurrentStateSummary());
   }
 
+  @ParameterizedTest
+  @ValueSource(ints = {403, 429, 500})
+  void suspendedAppWithPersistentlyUnverifiableDriverPublishesEvent(int code) {
+    // Unlike a transport level failure, a persistent one is reported, since a 
suspended app has no
+    // persisted status to show it. The event must not claim that no driver 
was requested.
+    AppInitStep appInitStep = new AppInitStep();
+    SparkAppContext mockContext = mock(SparkAppContext.class);
+    SparkAppStatusRecorder recorder = mock(SparkAppStatusRecorder.class);
+    SparkApplication application = new SparkApplication();
+    application.setMetadata(applicationMetadata);
+    application.getSpec().setSuspend(true);
+    when(mockContext.getResource()).thenReturn(application);
+    when(mockContext.getCurrentAttemptDriverPod())
+        .thenThrow(new KubernetesClientException("rejected", code, null));
+    when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
+
+    ReconcileProgress progress = appInitStep.reconcile(mockContext, recorder);
+
+    Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(), 
progress);
+    verify(mockContext, never()).getDriverPodSpec();
+    verifyNoInteractions(recorder);
+    ArgumentCaptor<EventRecord> event = 
ArgumentCaptor.forClass(EventRecord.class);
+    verify(eventRecorder).record(event.capture());

Review Comment:
   **[P8]** nit. This file and `ClusterInitStepTest` already have 
`captureEvents(int)`, which every other event assertion uses, including 
`refusedDriverLookupBeforeKueueAdmissionPublishesEvent`. `EventRecord event = 
captureEvents(1).get(0);` is equivalent and avoids the repeated 
`event.getValue()`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to