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

HeartSaVioR pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 86d8e8128d0e [SPARK-59844][SS][RTM] Preserve root causes for RTM async 
operation failures
86d8e8128d0e is described below

commit 86d8e8128d0eab5330c5f9194b5f4d7a0180f028
Author: Boyang Jerry Peng <[email protected]>
AuthorDate: Thu Oct 1 12:48:57 2026 +0900

    [SPARK-59844][SS][RTM] Preserve root causes for RTM async operation failures
    
    ### What changes were proposed in this pull request?
    
      This PR makes async progress tracking failures in Real-Time Mode report 
the error that actually interrupted the query.
    
      It:
    
      - Tracks the first interruption-triggering async error separately from 
the first error retained by `ErrorNotifier`.
      - Recursively unwraps `CompletionException` and `ExecutionException` 
layers while preserving an existing `SparkThrowable`.
      - Produces the same `STREAMING_ASYNC_OPERATION_FAILED` cause chain 
regardless of whether the query thread observes the interruption or the 
notifier error first.
      - Retains an independent earlier async error as suppressed context.
      - Prevents `ErrorNotifier` from creating cyclic or overlapping 
cause/suppressed graphs, including for non-RTM users.
      - Preserves fatal-error behavior by avoiding wrapping fatal errors 
observed during the notifier race.
    
      The resulting primary exception chain is:
    
      ```text
      STREAMING_ASYNC_OPERATION_FAILED
      └── STREAMING_CHECKPOINT_LOG_WRITE_FAILURE.{OFFSET_LOG|COMMIT_LOG}
          └── underlying storage exception
    ```
    
      ### Why are the changes needed?
    
      Async progress tracking writes checkpoint offset and commit logs on a 
background thread. In Real-Time Mode, a failure in that thread interrupts the 
query execution thread.
    
      ErrorNotifier retains the first asynchronous error it observes. If it had 
already retained an earlier, non-interrupting error, the query could report 
that earlier error instead of the
      checkpoint-log failure that caused the interruption. A race with the 
foreground error check could also make the reported exception shape depend on 
timing.
    
      Additionally, asynchronous futures can introduce nested 
CompletionException or ExecutionException wrappers that obscure an 
already-categorized Spark error. Attaching related exceptions
      as suppressed errors could create cyclic or overlapping throwable graphs, 
producing [CIRCULAR REFERENCE] in rendered stack traces.
    
      This change makes the primary cause deterministic and exposes the 
actionable checkpoint-storage exception while retaining independent diagnostic 
context.
    
      ### Does this PR introduce any user-facing change?
    
      No
      ### How was this patch tested?
    
      Added and ran focused tests covering:
    
      - First-error retention and conditional error publication in 
ErrorNotifier.
      - Concurrent suppression of independent errors.
      - Concurrent errors with overlapping throwable graphs.
      - RTM offset-log and commit-log write failures.
      - An earlier retained error followed by an interruption-triggering error.
      - Both interruption and notifier-race outcomes.
      - Preservation of fatal errors.
      - Nested CompletionException and ExecutionException wrappers.
      - Categorized failures that are themselves ExecutionException instances.
      - Throwable graphs rendered without [CIRCULAR REFERENCE].
    
      The following suites passed:
    
      - ErrorNotifierSuite: 8 tests
      - AsyncProgressTrackingRealTimeModeSuite: 17 tests
    
      build/sbt \
        'core/testOnly org.apache.spark.util.ErrorNotifierSuite' \
        'sql/testOnly 
org.apache.spark.sql.execution.streaming.AsyncProgressTrackingRealTimeModeSuite'
    
      ### Was this patch authored or co-authored using generative AI tooling?
    
      Co-authored with OpenAI Codex (GPT-5)
    
    Closes #59116 from jerrypeng/jerrypeng/streaming-async-root-cause.
    
    Authored-by: Boyang Jerry Peng <[email protected]>
    Signed-off-by: Jungtaek Lim <[email protected]>
    (cherry picked from commit b1f606c2c0b1e10e4a6f493cd68cc80b87a7379d)
    Signed-off-by: Jungtaek Lim <[email protected]>
---
 .../org/apache/spark/util/ErrorNotifier.scala      |  58 ++++++-
 .../org/apache/spark/util/ErrorNotifierSuite.scala |  48 ++++++
 .../AsyncProgressTrackingMicroBatchExecution.scala |  97 ++++++++---
 .../AsyncProgressTrackingRealTimeModeSuite.scala   | 178 +++++++++++++++++++--
 4 files changed, 336 insertions(+), 45 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/util/ErrorNotifier.scala 
b/core/src/main/scala/org/apache/spark/util/ErrorNotifier.scala
index 18ed7688f40a..a40e0c7bd524 100644
--- a/core/src/main/scala/org/apache/spark/util/ErrorNotifier.scala
+++ b/core/src/main/scala/org/apache/spark/util/ErrorNotifier.scala
@@ -17,6 +17,7 @@
 
 package org.apache.spark.util
 
+import java.util.{ArrayDeque, Collections, IdentityHashMap}
 import java.util.concurrent.atomic.AtomicReference
 
 import org.apache.spark.internal.Logging
@@ -36,20 +37,36 @@ import org.apache.spark.internal.Logging
 private[spark] class ErrorNotifier extends Logging {
 
   private val error = new AtomicReference[Throwable]
+  private val suppressionLock = new Object
 
   /**
-   * Record a fatal error. Only the first error is retained - subsequent calls
-   * are no-ops so cascading failures cannot mask the original cause.
+   * Record a fatal error. Only the first error is retained. Subsequent errors 
with disjoint
+   * throwable graphs are attached to it as suppressed exceptions so cascading 
failures cannot
+   * mask the original cause or create cyclic exception chains.
    */
   def markError(th: Throwable): Unit = {
+    if (!tryMarkError(th)) {
+      suppressionLock.synchronized {
+        val existing = error.get()
+        if (existing != null && existing != th &&
+            ErrorNotifier.haveDisjointThrowableGraphs(existing, th)) {
+          existing.addSuppressed(th)
+        }
+      }
+    }
+  }
+
+  /**
+   * Record an error only if none has been retained.
+   *
+   * @return true if this error was retained
+   */
+  def tryMarkError(th: Throwable): Boolean = {
     if (error.compareAndSet(null, th)) {
       logError(log"A fatal error has occurred.", th)
+      true
     } else {
-      // Attach subsequent errors as suppressed so they're not silently lost.
-      val existing = error.get()
-      if (existing != null && existing != th) {
-        existing.addSuppressed(th)
-      }
+      false
     }
   }
 
@@ -63,3 +80,30 @@ private[spark] class ErrorNotifier extends Logging {
     getError().foreach({th => throw th})
   }
 }
+
+private[spark] object ErrorNotifier {
+
+  /**
+   * @return true if the cause and suppressed graphs share no Throwable object 
by identity
+   */
+  def haveDisjointThrowableGraphs(first: Throwable, second: Throwable): 
Boolean = {
+    def collect(root: Throwable): java.util.Set[Throwable] = {
+      val graph = Collections.newSetFromMap(
+        new IdentityHashMap[Throwable, java.lang.Boolean]())
+      val pending = new ArrayDeque[Throwable]()
+      pending.push(root)
+      while (!pending.isEmpty) {
+        val current = pending.pop()
+        if (graph.add(current)) {
+          if (current.getCause != null) {
+            pending.push(current.getCause)
+          }
+          current.getSuppressed.foreach(pending.push)
+        }
+      }
+      graph
+    }
+
+    Collections.disjoint(collect(first), collect(second))
+  }
+}
diff --git a/core/src/test/scala/org/apache/spark/util/ErrorNotifierSuite.scala 
b/core/src/test/scala/org/apache/spark/util/ErrorNotifierSuite.scala
index 313857eb1de1..bfd4d680ab2d 100644
--- a/core/src/test/scala/org/apache/spark/util/ErrorNotifierSuite.scala
+++ b/core/src/test/scala/org/apache/spark/util/ErrorNotifierSuite.scala
@@ -63,6 +63,29 @@ class ErrorNotifierSuite extends SparkFunSuite {
     assert(err.getSuppressed.isEmpty)
   }
 
+  test("markError does not suppress an error whose graph contains the retained 
error") {
+    val notifier = new ErrorNotifier
+    val first = new RuntimeException("first")
+    val second = new RuntimeException("second", first)
+
+    notifier.markError(first)
+    notifier.markError(second)
+
+    assert(notifier.getError().contains(first))
+    assert(first.getSuppressed.isEmpty)
+  }
+
+  test("tryMarkError does not suppress an error when another error was 
retained first") {
+    val notifier = new ErrorNotifier
+    val first = new RuntimeException("first")
+    val second = new RuntimeException("second")
+
+    assert(notifier.tryMarkError(first))
+    assert(!notifier.tryMarkError(second))
+    assert(notifier.getError().contains(first))
+    assert(first.getSuppressed.isEmpty)
+  }
+
   test("concurrent markError retains exactly one error and suppresses the 
rest") {
     val notifier = new ErrorNotifier
     val numThreads = 8
@@ -84,4 +107,29 @@ class ErrorNotifierSuite extends SparkFunSuite {
     // Every losing error must be attached as suppressed on the winner, so 
none is lost.
     assert(winner.get.getSuppressed.toSet == errors.filterNot(_ eq 
winner.get).toSet)
   }
+
+  test("concurrent markError suppresses only one error when losing graphs 
overlap") {
+    val notifier = new ErrorNotifier
+    val first = new RuntimeException("first")
+    val sharedCause = new RuntimeException("shared")
+    val numThreads = 8
+    val errors = (0 until numThreads).map { i =>
+      new RuntimeException(s"err-$i", sharedCause)
+    }
+    val barrier = new CyclicBarrier(numThreads)
+    notifier.markError(first)
+
+    val threads = errors.map { error =>
+      new Thread(() => {
+        barrier.await()
+        notifier.markError(error)
+      })
+    }
+    threads.foreach(_.start())
+    threads.foreach(_.join())
+
+    assert(first.getSuppressed.length === 1)
+    assert(errors.contains(first.getSuppressed.head))
+    assert(!SparkErrorUtils.stackTraceToString(first).contains("CIRCULAR 
REFERENCE"))
+  }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/AsyncProgressTrackingMicroBatchExecution.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/AsyncProgressTrackingMicroBatchExecution.scala
index 11bda6221582..ddb5d702fd9f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/AsyncProgressTrackingMicroBatchExecution.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/AsyncProgressTrackingMicroBatchExecution.scala
@@ -17,8 +17,12 @@
 
 package org.apache.spark.sql.execution.streaming.runtime
 
+import java.util.{Collections, IdentityHashMap}
 import java.util.concurrent._
-import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.{AtomicLong, AtomicReference}
+
+import scala.annotation.tailrec
+import scala.util.control.NonFatal
 
 import org.apache.spark.{SparkIllegalArgumentException, SparkRuntimeException, 
SparkThrowable}
 import org.apache.spark.internal.LogKeys.{BATCH_ID, PRETTY_ID_STRING}
@@ -29,7 +33,7 @@ import 
org.apache.spark.sql.execution.streaming.{AvailableNowTrigger, OneTimeTri
 import org.apache.spark.sql.execution.streaming.checkpointing.{AsyncCommitLog, 
AsyncOffsetSeqLog, CommitMetadataBase, OffsetSeqBase, OffsetSeqLog}
 import 
org.apache.spark.sql.execution.streaming.operators.stateful.StateStoreWriter
 import org.apache.spark.sql.streaming.Trigger
-import org.apache.spark.util.{Clock, ThreadUtils}
+import org.apache.spark.util.{Clock, ErrorNotifier, ThreadUtils}
 
 /**
  * Class to execute micro-batches when async progress tracking is enabled
@@ -55,6 +59,10 @@ class AsyncProgressTrackingMicroBatchExecution(
   // to cache the batch id of the last batch written to storage
   private val lastBatchPersistedToDurableStorage = new AtomicLong(-1)
 
+  // Error that caused RTM to interrupt the query execution thread. 
ErrorNotifier retains the
+  // first async error, which may instead be an earlier failure from a 
non-interrupting operation.
+  private val interruptingAsyncError = new AtomicReference[Throwable]()
+
   // used to check during the first batch if the pipeline is stateful
   private var isFirstBatch: Boolean = true
 
@@ -149,7 +157,13 @@ class AsyncProgressTrackingMicroBatchExecution(
   private def interruptStreamExecutionWithError(th: Throwable): Unit = {
     logError(log"Interrupting stream execution due to error in async " +
       log"progress tracking for query ${MDC(PRETTY_ID_STRING, 
prettyIdString)}", th)
-    errorNotifier.markError(th)
+    if (interruptingAsyncError.compareAndSet(null, th)) {
+      // Do not attach the primary error to an earlier retained error. The 
final wrapper points to
+      // both, and sharing the primary across those branches breaks 
Throwable's rendered chain.
+      errorNotifier.tryMarkError(th)
+    } else {
+      errorNotifier.markError(th)
+    }
 
     // Immediately stop further processing.
     sparkSession.sparkContext.cancelJobGroup(runId.toString)
@@ -301,22 +315,35 @@ class AsyncProgressTrackingMicroBatchExecution(
   }
 
   /**
-   * Categorize a raw IO failure surfaced via an async log-write future, then 
route it through
-   * the standard async error handling path. CompletableFuture wraps the 
underlying cause in
-   * CompletionException, so unwrap before checking whether it has already 
been categorized.
-   * `wrapAsLogWriteFailure` is one of the [[StreamingErrors]] 
log-write-failure factories.
+   * Normalize and categorize a failure from an async log-write future, then 
route it through the
+   * standard async error handling path.
+   *
+   * @param wrapAsLogWriteFailure factory for an uncategorized log-write 
failure
    */
-  private def handleAsyncLogWriteError(
+  private[streaming] def handleAsyncLogWriteError(
       asyncWriteError: Throwable,
       batchId: Long,
       wrapAsLogWriteFailure: (Long, String, Throwable) => Throwable): Unit = {
-    val rootCause = asyncWriteError match {
-      case ce: CompletionException if ce.getCause != null => ce.getCause
-      case ee: ExecutionException if ee.getCause != null => ee.getCause
-      case other => other
+    val seenWrappers = Collections.newSetFromMap(
+      new IdentityHashMap[Throwable, java.lang.Boolean]())
+    @tailrec
+    def unwrapFutureFailure(error: Throwable): Throwable = {
+      if (!seenWrappers.add(error)) {
+        error
+      } else {
+        error match {
+          case _: SparkThrowable => error
+          case wrapper: CompletionException if wrapper.getCause != null =>
+            unwrapFutureFailure(wrapper.getCause)
+          case wrapper: ExecutionException if wrapper.getCause != null =>
+            unwrapFutureFailure(wrapper.getCause)
+          case other => other
+        }
+      }
     }
+    val rootCause = unwrapFutureFailure(asyncWriteError)
     val categorized = if (rootCause.isInstanceOf[SparkThrowable]) {
-      asyncWriteError
+      rootCause
     } else {
       wrapAsLogWriteFailure(batchId, resolvedCheckpointRoot, rootCause)
     }
@@ -332,16 +359,13 @@ class AsyncProgressTrackingMicroBatchExecution(
     try {
       super.runActivatedStream(sparkSessionForStream)
     } catch {
-      case e: Throwable
-        if StreamExecution.isInterruptionException(e, 
sparkSession.sparkContext) &&
-          errorNotifier.getError().isDefined =>
-        throw new SparkRuntimeException(
-          errorClass = "STREAMING_ASYNC_OPERATION_FAILED",
-          messageParameters = Map("message" -> 
errorNotifier.getError().get.getMessage),
-          cause = errorNotifier.getError().get
-        )
       case e: Throwable =>
-        throw e
+        throw getAsyncOperationFailure(
+          thrownError = e,
+          isInterruption = StreamExecution.isInterruptionException(
+            e, sparkSession.sparkContext),
+          interruptingError = Option(interruptingAsyncError.get()),
+          notifierError = errorNotifier.getError()).getOrElse(e)
     }
   }
 
@@ -466,6 +490,35 @@ object AsyncProgressTrackingMicroBatchExecution {
   val ASYNC_PROGRESS_TRACKING_OVERRIDE_SINK_SUPPORT_CHECK =
     "_asyncProgressTrackingOverrideSinkSupportCheck"
 
+  /**
+   * @return an async-operation wrapper with the interrupting error as its 
cause and any
+   *         independent earlier error as suppressed context, or None when the 
thrown error is
+   *         unrelated or fatal
+   */
+  private[streaming] def getAsyncOperationFailure(
+      thrownError: Throwable,
+      isInterruption: Boolean,
+      interruptingError: Option[Throwable],
+      notifierError: Option[Throwable]): Option[SparkRuntimeException] = {
+    val primaryError = interruptingError.orElse(notifierError)
+    val shouldWrap = (isInterruption && primaryError.isDefined) ||
+      (NonFatal(thrownError) && interruptingError.isDefined &&
+        notifierError.exists(_ eq thrownError))
+
+    primaryError.filter(_ => shouldWrap).map { error =>
+      val failure = new SparkRuntimeException(
+        errorClass = "STREAMING_ASYNC_OPERATION_FAILED",
+        messageParameters = Map("message" -> error.getMessage),
+        cause = error)
+      notifierError
+        .filterNot(_ eq error)
+        // A shared node renders as [CIRCULAR REFERENCE] after the first 
branch prints it.
+        .filter(ErrorNotifier.haveDisjointThrowableGraphs(error, _))
+        .foreach(failure.addSuppressed)
+      failure
+    }
+  }
+
   private def getAsyncProgressTrackingCheckpointingIntervalMs(
       extraOptions: Map[String, String],
       trigger: Trigger): Long = {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/AsyncProgressTrackingRealTimeModeSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/AsyncProgressTrackingRealTimeModeSuite.scala
index 453072245fb3..fc6bb91bd956 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/AsyncProgressTrackingRealTimeModeSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/AsyncProgressTrackingRealTimeModeSuite.scala
@@ -18,12 +18,16 @@
 package org.apache.spark.sql.execution.streaming
 
 import java.io.File
+import java.util.concurrent.{CompletionException, CountDownLatch, 
ExecutionException, TimeUnit}
 
 import org.scalatest.BeforeAndAfter
 import org.scalatest.matchers.should.Matchers
 import org.scalatest.time.{Seconds, Span}
 
-import org.apache.spark.{SparkException, SparkRuntimeException}
+import org.apache.spark.{SparkException, SparkRuntimeException, SparkThrowable}
+import org.apache.spark.errors.SparkCoreErrors
+import org.apache.spark.sql.{Encoder, SparkSession}
+import org.apache.spark.sql.connector.read.streaming.{Offset => OffsetV2, 
ReadLimit}
 import 
org.apache.spark.sql.execution.streaming.runtime.{AsyncProgressTrackingMicroBatchExecution,
 StreamExecution}
 import 
org.apache.spark.sql.execution.streaming.runtime.AsyncProgressTrackingMicroBatchExecution.{ASYNC_PROGRESS_TRACKING_CHECKPOINTING_INTERVAL_MS,
 ASYNC_PROGRESS_TRACKING_ENABLED}
 import org.apache.spark.sql.execution.streaming.runtime.MicroBatchExecution
@@ -33,13 +37,33 @@ import 
org.apache.spark.sql.execution.streaming.sources.MemorySink
 import 
org.apache.spark.sql.execution.streaming.state.{FailureInjectionCheckpointFileManager,
 FailureInjectionFileSystem}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.streaming.{OutputMode, 
StreamRealTimeModeManualClockSuiteBase}
-import org.apache.spark.util.Utils
+import org.apache.spark.util.{SparkErrorUtils, Utils}
 
 
 private class UnsupportedSink extends MemorySink {
   override def name(): String = "UnsupportedSink"
 }
 
+private class CategorizedExecutionException(cause: Throwable)
+  extends ExecutionException("categorized execution failure", cause) with 
SparkThrowable {
+
+  override def getCondition: String = "TEST_ERROR_CLASS"
+}
+
+private class BlockingLatestOffsetMemoryStream(
+    sparkSession: SparkSession,
+    latestOffsetEntered: CountDownLatch,
+    releaseLatestOffset: CountDownLatch)(
+    implicit encoder: Encoder[Int])
+  extends LowLatencyMemoryStream[Int](Int.MaxValue, sparkSession) {
+
+  override def latestOffset(startOffset: OffsetV2, limit: ReadLimit): OffsetV2 
= {
+    latestOffsetEntered.countDown()
+    releaseLatestOffset.await()
+    super.latestOffset(startOffset, limit)
+  }
+}
+
 class AsyncProgressTrackingRealTimeModeSuite
   extends StreamRealTimeModeManualClockSuiteBase with BeforeAndAfter with 
Matchers {
 
@@ -340,19 +364,11 @@ class AsyncProgressTrackingRealTimeModeSuite
             AddData(inputData, 10 until 20: _*),
             CheckAnswerWithTimeout(60000, 0 until 20: _*),
             advanceRealTimeClock,
-            ExpectFailure[Exception](
+            ExpectFailure[SparkRuntimeException](
               assertFailure = e => {
-                // The async log write failure surfaces in one of two valid 
ways, depending on a
-                // benign race in how the execution thread observes the error: 
either wrapped in
-                // STREAMING_ASYNC_OPERATION_FAILED (when the thread is 
interrupted mid-batch) or
-                // thrown directly (when the post-batch error check picks it 
up first). Unwrap the
-                // async wrapper if present, then assert on the categorized 
log write failure.
-                val categorized = e match {
-                  case sre: SparkRuntimeException
-                      if sre.getCondition == 
"STREAMING_ASYNC_OPERATION_FAILED" => sre.getCause
-                  case other => other
-                }
-                val sparkEx = categorized.asInstanceOf[SparkException]
+                val asyncFailure = e.asInstanceOf[SparkRuntimeException]
+                assert(asyncFailure.getCondition === 
"STREAMING_ASYNC_OPERATION_FAILED")
+                val sparkEx = 
asyncFailure.getCause.asInstanceOf[SparkException]
                 checkError(
                   sparkEx,
                   expectedErrorClass,
@@ -361,8 +377,7 @@ class AsyncProgressTrackingRealTimeModeSuite
                 // The original IOException must be preserved as the root 
cause.
                 assert(sparkEx.getCause.isInstanceOf[java.io.IOException])
                 assert(sparkEx.getCause.getMessage === "Fake File Stream Close 
Failure")
-              },
-              typeIsSuperClass = true)
+              })
           )
         } finally {
           
FailureInjectionFileSystem.removePathFromTempToInjectionState(checkpointDir.getPath)
@@ -371,6 +386,77 @@ class AsyncProgressTrackingRealTimeModeSuite
     }
   }
 
+  private def testInjectedAsyncFailure(
+      asyncWriteError: Throwable,
+      retainedError: Option[Throwable] = None,
+      batchId: Long = 7L)(
+      assertFailure: SparkRuntimeException => Unit): Unit = {
+    withTempDir { checkpointDir =>
+      val latestOffsetEntered = new CountDownLatch(1)
+      val releaseLatestOffset = new CountDownLatch(1)
+      val inputData = new BlockingLatestOffsetMemoryStream(
+        spark, latestOffsetEntered, releaseLatestOffset)
+
+      testStream(
+        inputData.toDF(),
+        outputMode = OutputMode.Update(),
+        sink = new ContinuousMemorySink(),
+        extraOptions = Map(
+          ASYNC_PROGRESS_TRACKING_ENABLED -> "true",
+          ASYNC_PROGRESS_TRACKING_CHECKPOINTING_INTERVAL_MS -> "0"))(
+        StartStream(checkpointLocation = checkpointDir.getAbsolutePath),
+        Execute { query =>
+          try {
+            assert(latestOffsetEntered.await(60, TimeUnit.SECONDS))
+            val execution = 
query.asInstanceOf[AsyncProgressTrackingMicroBatchExecution]
+            retainedError.foreach(execution.errorNotifier.markError)
+            execution.handleAsyncLogWriteError(
+              asyncWriteError, batchId, StreamingErrors.offsetLogWriteFailure)
+          } finally {
+            releaseLatestOffset.countDown()
+          }
+        },
+        ExpectFailure[SparkRuntimeException] { error =>
+          assertFailure(error.asInstanceOf[SparkRuntimeException])
+        })
+    }
+  }
+
+  private def testRetainedAndInterruptingErrors(
+      retainedError: Throwable,
+      writeError: Throwable,
+      expectedSuppressedErrors: Seq[Throwable]): Unit = {
+    testInjectedAsyncFailure(writeError, retainedError = Some(retainedError)) 
{ failure =>
+      assert(failure.getCondition === "STREAMING_ASYNC_OPERATION_FAILED")
+      val categorized = failure.getCause.asInstanceOf[SparkException]
+      checkError(
+        categorized,
+        StreamingErrors.OFFSET_LOG_WRITE_FAILURE,
+        parameters = Map("batchId" -> "7", "checkpointLocation" -> ".*"),
+        matchPVals = true)
+      assert(categorized.getCause eq writeError)
+      assert(failure.getSuppressed.toSeq === expectedSuppressedErrors)
+      assert(retainedError.getSuppressed.isEmpty)
+      assert(!SparkErrorUtils.stackTraceToString(failure).contains("CIRCULAR 
REFERENCE"))
+    }
+  }
+
+  private def testWrappedCategorizedFailure(
+      wrap: Throwable => Throwable,
+      preserveWrapper: Boolean = false): Unit = {
+    val categorizedError = StreamingErrors.offsetLogWriteFailure(
+      batchId = 1L,
+      checkpointLocation = "/checkpoint",
+      cause = new java.io.IOException("checkpoint authorization failure"))
+    val asyncWriteError = wrap(categorizedError)
+    val expectedCause = if (preserveWrapper) asyncWriteError else 
categorizedError
+
+    testInjectedAsyncFailure(asyncWriteError, batchId = 1L) { failure =>
+      assert(failure.getCondition === "STREAMING_ASYNC_OPERATION_FAILED")
+      assert(failure.getCause eq expectedCause)
+    }
+  }
+
   test("throw exceptions immediately on commit log write failure") {
     testAsyncLogWriteFailure("commits", 
StreamingErrors.COMMIT_LOG_WRITE_FAILURE)
   }
@@ -378,4 +464,64 @@ class AsyncProgressTrackingRealTimeModeSuite
   test("throw exceptions immediately on offset log write failure") {
     testAsyncLogWriteFailure("offsets", 
StreamingErrors.OFFSET_LOG_WRITE_FAILURE)
   }
+
+  test("report the async error that interrupted RTM instead of an earlier 
retained error") {
+    val retainedError = new java.io.IOException("earlier async purge failure")
+    testRetainedAndInterruptingErrors(
+      retainedError = retainedError,
+      writeError = new java.io.IOException("current offset write failure"),
+      expectedSuppressedErrors = Seq(retainedError))
+  }
+
+  test("wrap a non-fatal notifier error observed before the RTM interruption") 
{
+    val retainedError = new java.io.IOException("earlier async failure")
+    val interruptingError = new java.io.IOException("interrupting async 
failure")
+    val failure = 
AsyncProgressTrackingMicroBatchExecution.getAsyncOperationFailure(
+      thrownError = retainedError,
+      isInterruption = false,
+      interruptingError = Some(interruptingError),
+      notifierError = Some(retainedError)).get
+
+    assert(failure.getCause eq interruptingError)
+    assert(failure.getSuppressed.toSeq === Seq(retainedError))
+  }
+
+  test("do not wrap a fatal notifier error observed before the RTM 
interruption") {
+    val fatalError = SparkCoreErrors.outOfMemoryError(
+      requestedBytes = 1L,
+      receivedBytes = 0L,
+      consumerBreakdown = "test")
+    val failure = 
AsyncProgressTrackingMicroBatchExecution.getAsyncOperationFailure(
+      thrownError = fatalError,
+      isInterruption = false,
+      interruptingError = Some(new RuntimeException("interrupting async 
failure")),
+      notifierError = Some(fatalError))
+
+    assert(failure.isEmpty)
+  }
+
+  test("do not suppress a retained error already in the interrupting error 
graph") {
+    val retainedError = new java.io.IOException("earlier async failure")
+    testRetainedAndInterruptingErrors(
+      retainedError = retainedError,
+      writeError = retainedError,
+      expectedSuppressedErrors = Seq.empty)
+  }
+
+  Seq[(String, Throwable => Throwable)](
+    "CompletionException" -> ((error: Throwable) => new 
CompletionException(error)),
+    "ExecutionException" -> ((error: Throwable) => new 
ExecutionException(error)),
+    "mixed future wrappers" -> ((error: Throwable) =>
+      new CompletionException(new ExecutionException(error)))).foreach {
+    case (wrapperName, wrap) =>
+      test(s"unwrap a categorized failure nested in $wrapperName") {
+        testWrappedCategorizedFailure(wrap)
+      }
+  }
+
+  test("preserve a categorized failure that is also an ExecutionException") {
+    testWrappedCategorizedFailure(
+      error => new CategorizedExecutionException(error),
+      preserveWrapper = true)
+  }
 }


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

Reply via email to