This is an automated email from the ASF dual-hosted git repository.
LuciferYang pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.3 by this push:
new b45a8c7b4b65 [SPARK-57523][SQL] Declarative Pipelines should not retry
flows that fail due to streaming source changes
b45a8c7b4b65 is described below
commit b45a8c7b4b653d49eea8dcbaf2a07017f23fcc59
Author: YangJie <[email protected]>
AuthorDate: Thu Aug 13 14:46:37 2026 +0800
[SPARK-57523][SQL] Declarative Pipelines should not retry flows that fail
due to streaming source changes
### What changes were proposed in this pull request?
When a Declarative Pipelines streaming flow fails because its set of
sources changed since the last run, the failure is unrecoverable without a full
refresh. The retry decision in
`GraphExecution.determineFlowExecutionActionFromError` only considered the
retry count, so such a flow was retried `maxFlowRetryAttempts` times against
the same checkpoint, re-failing identically each time, and the run ended up
reporting the unrelated "has failed more than N times" reason.
This PR teaches `determineFlowExecutionActionFromError` - the method the
code already nominates as the single source of truth for retryability - to
recognize the source-change error and stop the flow immediately with a
dedicated `StreamingSourcesChanged` reason. The predicate is factored into
`PipelinesErrors.streamingSourcesChanged`, and the now-redundant special-case
branch in `checkStreamingErrorsAndRetry` is removed so the handling flows
through the single path.
The run-level failure now uses a dedicated `StreamingSourcesChangedFailure`
instead of reusing `QueryExecutionFailure`. Reusing it meant passing
`maxRetries = 0`, which prints the generic "has failed." text. That text also
collided with a real max-retries failure, since
`spark.sql.pipelines.maxFlowRetryAttempts` has no lower bound and a configured
0 takes the same branch.
### Why are the changes needed?
A streaming source-set change cannot be recovered by retrying against the
same checkpoint - every retry hits the identical `AssertionError` ("There are
[N] sources in the checkpoint offsets and now there are [M] sources requested
by the query. Cannot continue."). Retrying it wastes work, emits the same error
repeatedly, and reports a misleading terminal reason instead of the actual
cause.
### Does this PR introduce _any_ user-facing change?
Yes, in two ways. A flow whose streaming sources changed now fails
immediately instead of being retried `maxFlowRetryAttempts` times. The run also
terminates with `Run is FAILED since flow 'X' had streaming sources added or
removed. Perform a full refresh to rebuild it against the current sources.`
rather than "has failed more than N times". A full refresh is still required,
as before. This is a behavior change within the unreleased/maintained branches,
not relative to a different rel [...]
### How was this patch tested?
Extended `TriggeredGraphExecutionSuite` ("stream failure on deletes and
updates gives clear error") to assert the flow fails exactly once (not
`maxFlowRetryAttempts + 1` times) and that the run terminates with the
source-change message. Added `GraphExecutionSuite` with three session-less
tests. One checks `streamingSourcesChanged` against a wrapped source-change
error, a bare one, and errors it must not match. The other two check
`determineFlowExecutionActionFromError` on the source-c [...]
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #56588 from LuciferYang/sdp-streaming-source-change-no-retry.
Authored-by: YangJie <[email protected]>
Signed-off-by: yangjie01 <[email protected]>
(cherry picked from commit a1afa4ac6f4c8cdb153c71f1a2a88a9748dd9d53)
Signed-off-by: yangjie01 <[email protected]>
---
.../spark/sql/pipelines/graph/GraphExecution.scala | 38 +++++++--
.../sql/pipelines/graph/PipelinesErrors.scala | 50 ++++++------
.../sql/pipelines/graph/RunTerminationReason.scala | 15 ++++
.../sql/pipelines/graph/GraphExecutionSuite.scala | 90 ++++++++++++++++++++++
.../graph/TriggeredGraphExecutionSuite.scala | 13 ++++
5 files changed, 176 insertions(+), 30 deletions(-)
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphExecution.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphExecution.scala
index c687c7f01ed7..db1e6c546da1 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphExecution.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphExecution.scala
@@ -261,12 +261,32 @@ object GraphExecution extends Logging {
}
/**
- * Analyze the exception thrown by flow execution and figure out if we
should retry the execution,
- * or we need to reanalyze the flow entirely to resolve issues like schema
changes.
+ * Represents that the `FlowExecution` should be stopped because a streaming
flow's set of
+ * sources changed since the last run. This is unrecoverable without a full
refresh, so the flow
+ * must not be retried regardless of the remaining retry budget.
+ */
+ private case class StreamingSourcesChanged(
+ cause: Throwable,
+ flowDisplayName: String
+ ) extends FlowExecutionStopReason {
+ override lazy val runTerminationReason: RunTerminationReason = {
+ StreamingSourcesChangedFailure(flowDisplayName, Option(cause))
+ }
+ override lazy val failureMessage: String = {
+ s"Flow '$flowDisplayName' had streaming sources added or removed. It
will not be " +
+ s"retried. Please perform a full refresh to rebuild it against the
current sources."
+ }
+ }
+
+ /**
+ * Analyze the exception thrown by flow execution and decide whether to
retry the execution or
+ * stop it. The result is either RetryFlowExecution or StopFlowExecution;
this function does not
+ * reanalyze the flow itself.
* This should be the narrow waist for all exception analysis in flow
execution.
- * TODO: currently it only handles schema change and max retries, we should
aim to extend this to
- * include other non-retryable exception as well so we can have a single
SoT for all these error
- * matching logic.
+ * Currently it handles max retries and streaming source changes; other
non-retryable errors are
+ * still routed through the retry path.
+ * TODO: extend this to include other non-retryable exceptions as well so we
can have a single
+ * SoT for all these error matching logic.
* @param ex Exception to analyze.
* @param flowDisplayName The user facing flow name with the error.
* @param currentNumTries Number of times the flow has been tried.
@@ -278,8 +298,12 @@ object GraphExecution extends Logging {
currentNumTries: => Int,
maxAllowedRetries: => Int
): FlowExecutionAction = {
- val flowExecutionNonRetryableReasonOpt = if (currentNumTries >
maxAllowedRetries) {
- Some(MaxRetryExceeded(ex, flowDisplayName, maxAllowedRetries))
+ val error = ex
+ val flowExecutionNonRetryableReasonOpt = if
(PipelinesErrors.streamingSourcesChanged(error)) {
+ // Source-set changes need a full refresh, so they are never retried.
+ Some(StreamingSourcesChanged(error, flowDisplayName))
+ } else if (currentNumTries > maxAllowedRetries) {
+ Some(MaxRetryExceeded(error, flowDisplayName, maxAllowedRetries))
} else {
None
}
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/PipelinesErrors.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/PipelinesErrors.scala
index b194e9c235fb..ef2ac9732c99 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/PipelinesErrors.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/PipelinesErrors.scala
@@ -63,6 +63,30 @@ object PipelinesErrors extends Logging {
getExceptionChain(throwable).exists(check)
}
+ /**
+ * Returns true if `ex` (or any of its causes) indicates that a streaming
flow's set of sources
+ * changed since the last run. This is unrecoverable without a full refresh,
so a flow that fails
+ * with this error must not be retried.
+ *
+ * Structured Streaming reports this as a bare `AssertionError`, so the only
signal available
+ * here is the message text, produced by the source-count assertion in
+ *
`org.apache.spark.sql.execution.streaming.checkpointing.OffsetSeq.toStreamProgress`.
Since
+ * this predicate drives the retry decision, a change to that message
silently turns these
+ * failures back into retried ones; keep the two in sync. Giving that
assertion an error
+ * condition and matching on it here would remove the coupling.
+ */
+ private[graph] def streamingSourcesChanged(ex: Throwable): Boolean = {
+ checkCauses(
+ throwable = ex,
+ check = cause => {
+ cause.isInstanceOf[AssertionError] &&
+ cause.getMessage != null &&
+ cause.getMessage.contains("sources in the checkpoint offsets and now
there are") &&
+ cause.getMessage.contains("sources requested by the query. Cannot
continue.")
+ }
+ )
+ }
+
/**
* Checks an error for streaming specific handling. This is a pretty messy
signature as a result
* of unifying some divergences between the triggered caller in
TriggeredGraphExecution and the
@@ -87,28 +111,7 @@ object PipelinesErrors extends Logging {
maxRetries: Int,
onRetry: => Unit
): Unit = {
- if (PipelinesErrors.checkCauses(
- throwable = ex,
- check = ex => {
- ex.isInstanceOf[AssertionError] &&
- ex.getMessage != null &&
- ex.getMessage.contains("sources in the checkpoint offsets and now
there are") &&
- ex.getMessage.contains("sources requested by the query. Cannot
continue.")
- }
- )) {
- val message = s"""
- |Flow '${flow.displayName}' had streaming sources added or removed.
Please perform a
- |full refresh in order to rebuild '${flow.displayName}' against the
current set of
- |sources.
- |""".stripMargin
-
- env.flowProgressEventLogger.recordFailed(
- flow = flow,
- exception = ex,
- logAsWarn = false,
- messageOpt = Option(message)
- )
- } else if (flow.once && ex == null) {
+ if (flow.once && ex == null) {
// No need to do anything if this is a ONCE flow with no exception. That
just means it's done.
} else {
val actionFromError =
GraphExecution.determineFlowExecutionActionFromError(
@@ -120,7 +123,8 @@ object PipelinesErrors extends Logging {
actionFromError match {
// Simply retry
case GraphExecution.RetryFlowExecution => onRetry
- // Schema change exception
+ // Non-retryable stop reason (max retries exceeded, streaming sources
changed, ...).
+ // When shouldRethrow is true, this rethrows so the run stops eagerly
on these reasons.
case GraphExecution.StopFlowExecution(reason) =>
val msg = reason.failureMessage
if (reason.warnInsteadOfError) {
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/RunTerminationReason.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/RunTerminationReason.scala
index c95ce6a197ee..195042286ed9 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/RunTerminationReason.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/RunTerminationReason.scala
@@ -85,6 +85,21 @@ case class QueryExecutionFailure(
}
}
+/**
+ * Indicates that a run has failed because a streaming flow's set of sources
changed since the last
+ * run. The flow is not retried, since only a full refresh can recover it.
+ */
+case class StreamingSourcesChangedFailure(
+ flowName: String,
+ override val cause: Option[Throwable])
+ extends RunFailure {
+ override def isFatal: Boolean = false
+
+ override def message: String =
+ s"Run is $terminalState since flow '$flowName' had streaming sources added
or removed. " +
+ s"Perform a full refresh to rebuild it against the current sources."
+}
+
/** Abstract class used to identify failures related to failures stopping an
operation/timeouts. */
abstract class FailureStoppingOperation extends RunFailure {
diff --git
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/GraphExecutionSuite.scala
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/GraphExecutionSuite.scala
new file mode 100644
index 000000000000..f3d7108e8c22
--- /dev/null
+++
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/GraphExecutionSuite.scala
@@ -0,0 +1,90 @@
+/*
+ * 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.sql.pipelines.graph
+
+import org.apache.spark.SparkFunSuite
+
+/**
+ * Unit tests for the flow-execution decision logic:
`PipelinesErrors.streamingSourcesChanged` and
+ * `GraphExecution.determineFlowExecutionActionFromError`, the single source
of truth for whether a
+ * failed flow is retried.
+ *
+ * These tests are intentionally session-less - both entry points are pure
functions of a
+ * `Throwable` and two counters. End-to-end behavior (event levels, retry
counts, and the terminal
+ * run state a source change produces) is covered by
`TriggeredGraphExecutionSuite`.
+ */
+class GraphExecutionSuite extends SparkFunSuite {
+
+ /** The message Structured Streaming asserts with when a stream's source set
changes. */
+ private def sourceSetChangeMessage: String =
+ "There are [2] sources in the checkpoint offsets and now there are [3]
sources " +
+ "requested by the query. Cannot continue."
+
+ test("streamingSourcesChanged matches only a streaming source-set change
error") {
+ // Structured Streaming raises a bare AssertionError; it is usually
wrapped in another
+ // exception by the time the pipeline sees it, so the whole cause chain
has to be checked.
+ val sourceChange = new AssertionError(sourceSetChangeMessage)
+ assert(PipelinesErrors.streamingSourcesChanged(sourceChange))
+ assert(PipelinesErrors.streamingSourcesChanged(new
RuntimeException("wrapper", sourceChange)))
+ // Unrelated errors - including an AssertionError with a different message
- must not match.
+ assert(!PipelinesErrors.streamingSourcesChanged(new
RuntimeException("boom")))
+ assert(!PipelinesErrors.streamingSourcesChanged(new AssertionError("a
different assertion")))
+ }
+
+ test("determineFlowExecutionActionFromError stops on a source change before
checking retries") {
+ val sourceChange =
+ new RuntimeException("stream failed", new
AssertionError(sourceSetChangeMessage))
+
+ // The source-change check comes before the retry budget, so the flow
stops even with retries
+ // left, and the run-level reason names the source change rather than an
exhausted budget.
+ GraphExecution.determineFlowExecutionActionFromError(
+ ex = sourceChange,
+ flowDisplayName = "flow_a",
+ currentNumTries = 1,
+ maxAllowedRetries = 3) match {
+ case GraphExecution.StopFlowExecution(reason) =>
+ assert(reason.failureMessage.contains("streaming sources added or
removed"))
+ assert(
+ reason.runTerminationReason ==
+ StreamingSourcesChangedFailure("flow_a", Some(sourceChange)))
+ case other => fail(s"expected StopFlowExecution, got $other")
+ }
+ }
+
+ test("determineFlowExecutionActionFromError retries other errors until
budget is exhausted") {
+ val transient = new RuntimeException("transient")
+ // Retries remaining -> retry.
+ assert(
+ GraphExecution.determineFlowExecutionActionFromError(
+ ex = transient, flowDisplayName = "flow_a", currentNumTries = 1,
maxAllowedRetries = 3) ==
+ GraphExecution.RetryFlowExecution)
+ // Budget exhausted -> stop as max-retries-exceeded, not as a source
change.
+ GraphExecution.determineFlowExecutionActionFromError(
+ ex = transient,
+ flowDisplayName = "flow_a",
+ currentNumTries = 4,
+ maxAllowedRetries = 3) match {
+ case GraphExecution.StopFlowExecution(reason) =>
+ assert(!reason.failureMessage.contains("streaming sources added or
removed"))
+ assert(
+ reason.runTerminationReason ==
+ QueryExecutionFailure("flow_a", maxRetries = 3, Some(transient)))
+ case other => fail(s"expected StopFlowExecution, got $other")
+ }
+ }
+}
diff --git
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/TriggeredGraphExecutionSuite.scala
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/TriggeredGraphExecutionSuite.scala
index 313f86753222..9525a61dedd5 100644
---
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/TriggeredGraphExecutionSuite.scala
+++
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/TriggeredGraphExecutionSuite.scala
@@ -462,6 +462,8 @@ class TriggeredGraphExecutionSuite extends ExecutionTest
with SharedSparkSession
updateContext2.pipelineExecution.runPipeline()
updateContext2.pipelineExecution.awaitCompletion()
+ // A streaming source change is unrecoverable without a full refresh, so
the flow must not be
+ // retried: we should see exactly one failure rather than
maxFlowRetryAttempts + 1 of them.
assertFlowProgressEvent(
eventBuffer = updateContext2.eventBuffer,
identifier = fullyQualifiedIdentifier("input_table"),
@@ -469,6 +471,17 @@ class TriggeredGraphExecutionSuite extends ExecutionTest
with SharedSparkSession
expectedEventLevel = EventLevel.ERROR,
msgChecker = _.contains(
s"Flow '${eventLogName("input_table")}' had streaming sources added or
removed."
+ ),
+ expectedNumOfEvents = Option(1)
+ )
+
+ // The run should fail because of the source change, not because the flow
exhausted its retries.
+ assertRunProgressEvent(
+ eventBuffer = updateContext2.eventBuffer,
+ state = RunState.FAILED,
+ expectedEventLevel = EventLevel.ERROR,
+ msgChecker = _.contains(
+ s"flow '${eventLogName("input_table")}' had streaming sources added or
removed."
)
)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]