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

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 8debe32663ad [SPARK-58508][CORE][SS] Activate pipelined shuffles for 
Real-Time Mode streaming queries to support Stateful Queries
8debe32663ad is described below

commit 8debe32663ad988684a2ecfb11351a0c9c6641e0
Author: Boyang Jerry Peng <[email protected]>
AuthorDate: Wed Aug 5 14:26:10 2026 +0800

    [SPARK-58508][CORE][SS] Activate pipelined shuffles for Real-Time Mode 
streaming queries to support Stateful Queries
    
    ### What changes were proposed in this pull request?
    
    This PR activates **pipelined (streaming) shuffles for Real-Time Mode (RTM) 
streaming queries**. It is the payload PR of the pipelined-shuffle stack: 
earlier PRs built the transient-shuffle machinery (group-atomic scheduling, 
MapOutputTracker decoupling) as dormant infrastructure with no production 
caller; this PR is the first to actually **construct and use** a 
`PipelinedShuffleDependency`.
    
    The `core/` dependency it builds on, #57646 ("Fully decouple pipelined 
shuffles from the MapOutputTracker"), has merged, so this PR now applies 
directly to `master` and its diff is SQL/streaming only.
    
    Concretely:
    
    - **Build the dependency (`ShuffleExchangeExec`)**: a new `pipelined: 
Boolean` flag; when set, it constructs a `PipelinedShuffleDependency` instead 
of a regular `ShuffleDependency`. The dependency type is the entire opt-in -- 
routing to the streaming shuffle manager and pipelined-group co-scheduling in 
the DAGScheduler both follow from it. Plumbed through `EnsureRequirements`, 
`AQEUtils`, and `InsertSortForLimitAndOffset`.
    - **Mark shuffles on the streaming path (`IncrementalExecution`)**: a 
`MarkPipelinedShuffleForRealTimeMode` rule marks the `ShuffleExchangeExec`s 
whose subtree reaches a `RealTimeStreamScanExec`. Inert for a non-RTM batch. A 
chain of several pipelined shuffles (e.g. two repartitions, or a repartition 
feeding a keyed stateful operator) is handled -- the whole all-pipelined job is 
co-scheduled as one pipelined group. Shuffles in a *static* subtree (for 
instance the static side of a broa [...]
    - **Extend the RTM allowlist (`RealTimeModeAllowlist`)**: admit 
`ShuffleExchangeExec` plus the stateful operators it enables -- 
`StateStoreRestoreExec`, `StateStoreSaveExec`, `StreamingDeduplicateExec` -- as 
supported members of a pipelined group rather than materialization barriers. 
Admitting shuffles is qualified by partitioning: a **range-partitioned** 
shuffle is still rejected, because building its `RangePartitioner` runs a 
separate job that samples the input to compute range boun [...]
    - **Make `repartition(n)` usable in RTM (`MicroBatchExecution`, 
`ShuffleExchangeExec`)**: `repartition(n)` produces a `RoundRobinPartitioning`, 
and by default (SPARK-23207) Spark inserts a deterministic local sort before a 
round-robin shuffle so a retried task cannot reassign rows and lose data. That 
sort is **fully blocking** -- it drains its entire input before emitting a row 
-- so on an unbounded RTM stream the producer never emits and the query returns 
no rows at all, with no erro [...]
    
    The net effect: a stateful streaming query runs in Real-Time Mode with its 
input repartitioned through a transient, incrementally-read shuffle, instead of 
the consumer waiting for the producer to fully materialize.
    
    ### Why are the changes needed?
    
    Real-Time Mode aims for low end-to-end latency by co-scheduling a query's 
producer and consumer stages so records stream through rather than 
materializing at a shuffle boundary. Until now RTM rejected shuffles and the 
stateful operators behind them (they were not in the allowlist), so a keyed 
stateful query could not run in RTM at all. This PR turns on the 
pipelined-shuffle path -- built and made safe by the earlier PRs in the stack 
-- for exactly those queries.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, for Real-Time Mode streaming queries: a query that repartitions by key 
(e.g. streaming deduplication) is now admitted and runs over a pipelined 
shuffle, where it was previously rejected by the RTM operator allowlist. RTM 
also now forces `spark.sql.execution.sortBeforeRepartition=false` for its own 
(isolated) session, overriding an explicit `true` and logging a warning -- 
honouring it would hang the query forever, and it guards against a task retry 
that does not happen in RTM. Bat [...]
    
    ### How was this patch tested?
    
    New end-to-end RTM tests in `StreamRealTimeModeSuite` / 
`StreamRealTimeModeWithManualClockSuite` (the suites shared with the Databricks 
runtime, rather than a new standalone file):
    
    - stateful dedup runs in RTM over a pipelined shuffle and genuinely 
co-schedules its stages (asserted from a `SparkListener`, counting only the 
stages of the query under test);
    - multi-key dedup, and an explicit repartition-by-key;
    - a chain of two shuffles (round-robin repartition feeding a keyed dedup) 
forms one co-scheduled pipelined group;
    - a chain of two round-robin repartitions -- the shape that the 
order-sensitivity exemption exists for;
    - an explicit `sortBeforeRepartition=true` does not stall the query;
    - a shuffle on the static side of a broadcast stream-static join is not 
marked pipelined;
    - multiple broadcast joins against the same static table, asserting the 
plan really does reuse the broadcast exchange (SC-209926);
    - recovery from a task failure via checkpoint restart, and from a 
commit-log write failure.
    
    `StreamRealTimeModeAllowlistSuite` also gains a rejection test: a 
range-partitioned shuffle (`repartitionByRange`) fails fast with the allowlist 
error rather than stalling.
    
    Updated for the new allowlist and the `pipelined` field: 
`StreamRealTimeModeAllowlistSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, 
`DataFrameWindowFunctionsSuite`, `DatasetSuite`, `StreamingJoinSuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Co-authored with Claude Code (Opus 4.8)
    
    Closes #57692 from jerrypeng/stack/pipelined-shuffle-pr8-rtm.
    
    Authored-by: Boyang Jerry Peng <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../execution/InsertSortForLimitAndOffset.scala    |   2 +-
 .../spark/sql/execution/adaptive/AQEUtils.scala    |   2 +-
 .../execution/exchange/EnsureRequirements.scala    |   6 +-
 .../execution/exchange/ShuffleExchangeExec.scala   |  65 +++-
 .../streaming/runtime/IncrementalExecution.scala   |  93 ++++-
 .../streaming/runtime/MicroBatchExecution.scala    |  25 ++
 .../streaming/runtime/RealTimeModeAllowlist.scala  |  39 +-
 .../spark/sql/DataFrameWindowFunctionsSuite.scala  |   2 +-
 .../scala/org/apache/spark/sql/DatasetSuite.scala  |   2 +-
 .../apache/spark/sql/execution/ExchangeSuite.scala |  28 ++
 .../apache/spark/sql/execution/PlannerSuite.scala  |   5 +-
 .../exchange/EnsureRequirementsSuite.scala         | 110 +++---
 .../StreamRealTimeModeAllowlistSuite.scala         |  52 ++-
 .../sql/streaming/StreamRealTimeModeSuite.scala    | 418 ++++++++++++++++++++-
 .../spark/sql/streaming/StreamingJoinSuite.scala   |   4 +-
 15 files changed, 737 insertions(+), 116 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/InsertSortForLimitAndOffset.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/InsertSortForLimitAndOffset.scala
index aa29128cda7e..88f0de2555f7 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/InsertSortForLimitAndOffset.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/InsertSortForLimitAndOffset.scala
@@ -44,7 +44,7 @@ object InsertSortForLimitAndOffset extends Rule[SparkPlan] {
           _,
           // Should not match AQE shuffle stage because we only target 
un-submitted stages which
           // we can still rewrite the query plan.
-          s @ ShuffleExchangeExec(SinglePartition, child, _, _),
+          s @ ShuffleExchangeExec(SinglePartition, child, _, _, _),
           _) if child.logicalLink.isDefined =>
         extractOrderingAndPropagateOrderingColumns(child) match {
           case Some((ordering, newChild)) =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEUtils.scala
index 578e0acd8052..cc3ca9073909 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEUtils.scala
@@ -30,7 +30,7 @@ object AQEUtils {
     // Project/Filter/LocalSort/CollectMetrics.
     // Note: we only care about `HashPartitioning` as `EnsureRequirements` can 
only optimize out
     // user-specified repartition with `HashPartitioning`.
-    case ShuffleExchangeExec(h: HashPartitioning, _, shuffleOrigin, _)
+    case ShuffleExchangeExec(h: HashPartitioning, _, shuffleOrigin, _, _)
         if shuffleOrigin == REPARTITION_BY_COL || shuffleOrigin == 
REPARTITION_BY_NUM =>
       val numPartitions = if (shuffleOrigin == REPARTITION_BY_NUM) {
         Some(h.numPartitions)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
index c632b3d841e6..8ca344e51194 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
@@ -272,8 +272,8 @@ case class EnsureRequirements(
             }
 
             child match {
-              case ShuffleExchangeExec(_, c, so, ps) =>
-                ShuffleExchangeExec(newPartitioning, c, so, ps)
+              case s: ShuffleExchangeExec =>
+                s.copy(outputPartitioning = newPartitioning)
               case gpe: GroupPartitionsExec => 
ShuffleExchangeExec(newPartitioning, gpe.child)
               case _ => ShuffleExchangeExec(newPartitioning, child)
             }
@@ -896,7 +896,7 @@ case class EnsureRequirements(
 
   def apply(plan: SparkPlan): SparkPlan = {
     val newPlan = plan.transformUp {
-      case operator @ ShuffleExchangeExec(upper: HashPartitioning, child, 
shuffleOrigin, _)
+      case operator @ ShuffleExchangeExec(upper: HashPartitioning, child, 
shuffleOrigin, _, _)
           if optimizeOutRepartition &&
             (shuffleOrigin == REPARTITION_BY_COL || shuffleOrigin == 
REPARTITION_BY_NUM) =>
         def hasSemanticEqualPartitioning(partitioning: Partitioning): Boolean 
= {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala
index dd829e697df6..0059473b3c65 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala
@@ -191,7 +191,8 @@ case class ShuffleExchangeExec(
     override val outputPartitioning: Partitioning,
     child: SparkPlan,
     shuffleOrigin: ShuffleOrigin = ENSURE_REQUIREMENTS,
-    advisoryPartitionSize: Option[Long] = None)
+    advisoryPartitionSize: Option[Long] = None,
+    pipelined: Boolean = false)
   extends ShuffleExchangeLike {
 
   private lazy val writeMetrics =
@@ -205,6 +206,17 @@ case class ShuffleExchangeExec(
 
   override def nodeName: String = "Exchange"
 
+  // `pipelined` is only meaningful for a Real-Time Mode plan, and the default 
arg string is
+  // positional, so printing it unconditionally would add a bare `false` to 
every shuffle in every
+  // plan. Show it only when set, and name it when shown.
+  override def stringArgs: Iterator[Any] = {
+    // `pipelined` is the last field, so drop it positionally rather than by 
value; argString drops
+    // the child on its own. Exchange's `[plan_id=...]` suffix is re-appended 
here.
+    val argsWithoutPipelined = productIterator.toSeq.dropRight(1).iterator
+    val pipelinedArg = if (pipelined) Iterator("isPipelined=true") else 
Iterator.empty
+    argsWithoutPipelined ++ pipelinedArg ++ Iterator(s"[plan_id=$id]")
+  }
+
   private lazy val serializer: Serializer =
     new UnsafeRowSerializer(child.output.size, longMetric("dataSize"))
 
@@ -252,7 +264,8 @@ case class ShuffleExchangeExec(
         child.output,
         outputPartitioning,
         serializer,
-        writeMetrics)
+        writeMetrics,
+        pipelined)
       metrics("numPartitions").set(dep.partitioner.numPartitions)
       val executionId = 
sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY)
       SQLMetrics.postDriverMetricUpdates(
@@ -346,7 +359,8 @@ object ShuffleExchangeExec {
       outputAttributes: Seq[Attribute],
       newPartitioning: Partitioning,
       serializer: Serializer,
-      writeMetrics: Map[String, SQLMetric])
+      writeMetrics: Map[String, SQLMetric],
+      pipelined: Boolean = false)
     : ShuffleDependency[Int, InternalRow, InternalRow] = {
     val part: Partitioner = newPartitioning match {
       case RoundRobinPartitioning(numPartitions) => new 
HashPartitioner(numPartitions)
@@ -509,8 +523,16 @@ object ShuffleExchangeExec {
 
       // round-robin function is order sensitive if we don't sort the input.
       // Stateful partition assignment is order-sensitive when it depends on 
row visitation order.
-      val isOrderSensitive =
-        (isRoundRobin || isNullAwareHashPartitioning) && 
!SQLConf.get.sortBeforeRepartition
+      //
+      // A pipelined shuffle is exempt. Marking the map RDD order-sensitive 
only serves to make it
+      // INDETERMINATE when its own input is UNORDERED (see
+      // MapPartitionsRDD.getOutputDeterministicLevel), which tells the 
scheduler a retry cannot be
+      // trusted and the stage must be rolled back and recomputed. A pipelined 
stage is never
+      // retried (a pipelined task set gets a single attempt) and never 
recomputed, and the
+      // DAGScheduler rejects an indeterminate pipelined producer outright -- 
so keeping the flag
+      // would reject a chain of round-robin repartitions rather than protect 
anything.
+      val isOrderSensitive = (isRoundRobin || isNullAwareHashPartitioning) &&
+        !SQLConf.get.sortBeforeRepartition && !pipelined
       if (needToCopyObjectsBeforeShuffle(part)) {
         newRdd.mapPartitionsWithIndexInternal((_, iter) => {
           val getPartitionKey = getPartitionKeyExtractor()
@@ -537,15 +559,30 @@ object ShuffleExchangeExec {
       }
     }
     val dependency =
-      new ShuffleDependency[Int, InternalRow, InternalRow](
-        rddWithPartitionIds,
-        new PartitionIdPassthrough(part.numPartitions),
-        serializer,
-        shuffleWriterProcessor = createShuffleWriteProcessor(writeMetrics),
-        rowBasedChecksums = 
UnsafeRowChecksum.createUnsafeRowChecksums(checksumSize),
-        _checksumMismatchFullRetryEnabled = 
SQLConf.get.shuffleChecksumMismatchFullRetryEnabled,
-        checksumMismatchQueryLevelRollbackEnabled =
-          SQLConf.get.shuffleChecksumMismatchQueryLevelRollbackEnabled)
+      if (pipelined) {
+        // A pipelined shuffle is transient and incrementally readable: the 
DAGScheduler
+        // co-schedules its producer and consumer stages instead of 
materializing the shuffle
+        // first. The PipelinedShuffleDependency type is the entire opt-in -- 
routing to the
+        // streaming shuffle manager and pipelined-group co-scheduling both 
follow from it. The
+        // checksum-mismatch retry knobs are intentionally not carried over: a 
transient shuffle is
+        // never recomputed, so PipelinedShuffleDependency does not expose 
them (they stay off).
+        new PipelinedShuffleDependency[Int, InternalRow, InternalRow](
+          rddWithPartitionIds,
+          new PartitionIdPassthrough(part.numPartitions),
+          serializer,
+          shuffleWriterProcessor = createShuffleWriteProcessor(writeMetrics),
+          rowBasedChecksums = 
UnsafeRowChecksum.createUnsafeRowChecksums(checksumSize))
+      } else {
+        new ShuffleDependency[Int, InternalRow, InternalRow](
+          rddWithPartitionIds,
+          new PartitionIdPassthrough(part.numPartitions),
+          serializer,
+          shuffleWriterProcessor = createShuffleWriteProcessor(writeMetrics),
+          rowBasedChecksums = 
UnsafeRowChecksum.createUnsafeRowChecksums(checksumSize),
+          _checksumMismatchFullRetryEnabled = 
SQLConf.get.shuffleChecksumMismatchFullRetryEnabled,
+          checksumMismatchQueryLevelRollbackEnabled =
+            SQLConf.get.shuffleChecksumMismatchQueryLevelRollbackEnabled)
+      }
 
     dependency
   }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/IncrementalExecution.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/IncrementalExecution.scala
index 0d2e4a6941a0..f7b64af45a31 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/IncrementalExecution.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/IncrementalExecution.scala
@@ -36,8 +36,9 @@ import org.apache.spark.sql.classic.SparkSession
 import org.apache.spark.sql.errors.QueryExecutionErrors
 import org.apache.spark.sql.execution.{CommandExecutionMode, LocalLimitExec, 
QueryExecution, SerializeFromObjectExec, SparkPlan, SparkPlanner, SparkStrategy 
=> Strategy, UnaryExecNode}
 import org.apache.spark.sql.execution.aggregate.{HashAggregateExec, 
MergingSessionsExec, ObjectHashAggregateExec, SortAggregateExec, 
UpdatingSessionsExec}
+import org.apache.spark.sql.execution.datasources.v2.RealTimeStreamScanExec
 import 
org.apache.spark.sql.execution.datasources.v2.state.metadata.StateMetadataPartitionReader
-import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike
+import org.apache.spark.sql.execution.exchange.{ShuffleExchangeExec, 
ShuffleExchangeLike}
 import 
org.apache.spark.sql.execution.python.streaming.{FlatMapGroupsInPandasWithStateExec,
 TransformWithStateInPySparkExec}
 import org.apache.spark.sql.execution.streaming.{StreamingErrors, 
StreamingQueryPlanTraverseHelper}
 import 
org.apache.spark.sql.execution.streaming.checkpointing.{CheckpointFileManager, 
OffsetSeqMetadata, OffsetSeqMetadataBase}
@@ -663,7 +664,95 @@ class IncrementalExecution(
     }
   }
 
-  override def preparations: Seq[Rule[SparkPlan]] = state +: super.preparations
+  /**
+   * For a Real-Time Mode batch, mark the shuffle exchanges as pipelined so 
the DAGScheduler
+   * co-schedules a stateful query's producer (source scan) and consumer 
(stateful operator) stages
+   * as one pipelined group -- records stream through a transient shuffle 
instead of the consumer
+   * waiting for the producer to fully materialize. The exchange carries the 
decision as a field
+   * (see ShuffleExchangeExec.pipelined); the PipelinedShuffleDependency it 
then builds is the whole
+   * opt-in -- routing to the streaming shuffle manager and pipelined-group 
scheduling both follow
+   * from that dependency type.
+   *
+   * Real-Time Mode is detected structurally by a RealTimeStreamScanExec leaf 
(there is no
+   * RTM-specific plan flag). Inert for a non-RTM batch, so the ordinary 
microbatch path is
+   * unchanged.
+   *
+   * Marks every eligible shuffle exchange on the streaming path, so a plan 
with several pipelined
+   * shuffles in a chain (e.g. two repartitions, or a repartition feeding a 
keyed stateful operator)
+   * is handled: each becomes a PipelinedShuffleDependency and the whole 
all-pipelined job is
+   * co-scheduled as one pipelined group (the DAGScheduler treats an 
all-pipelined job's stage graph
+   * as a single group). There is no shuffle-count restriction. An exchange 
whose subtree does not
+   * reach the real-time scan is skipped -- the static side of a broadcast 
stream-static join must
+   * materialize, because it runs to completion rather than streaming. A 
partitioning the pipelined
+   * path cannot serve, such as range partitioning, is rejected up front by 
RealTimeModeAllowlist
+   * rather than being handled here.
+   *
+   * The walk does not descend into a ReusedExchangeExec (a leaf whose wrapped 
exchange is a field,
+   * not a tree child), so a REUSED shuffle exchange would keep 
pipelined=false while its standalone
+   * twin flips to true. That divergence is not reachable: a reused shuffle 
requires
+   * referencing the same streaming source more than once (self-join / 
self-union / CTE read twice),
+   * which Real-Time Mode rejects when the query starts (MicroBatchExecution,
+   * IDENTICAL_SOURCES_IN_UNION_NOT_SUPPORTED) before this rule runs. The only 
ReusedExchangeExec
+   * that reaches an RTM plan wraps a BROADCAST exchange (multiple broadcast 
joins on the same
+   * static table, SC-209926), which this rule does not match.
+   *
+   * Fan-out -- one shuffle read by more than one consumer -- is a limitation 
that marking a shuffle
+   * here introduces, not one that was already there. A regular materialized 
shuffle serves any
+   * number of consumers; a pipelined one is transient and read once, so the 
DAGScheduler rejects
+   * fan-out for a PipelinedShuffleDependency specifically 
(checkPipelinedGroupsSupportedInRDDGraph,
+   * inert for a regular ShuffleDependency). So a fan-out query that runs fine 
unmarked is rejected
+   * once this rule marks it. The check runs in handleJobSubmitted, so it 
rejects the batch's job
+   * rather than the query: such a query fails the same way on every batch 
instead of failing once
+   * when it is planned. A plan-time guard here would improve only where the 
failure is reported,
+   * not whether the query can run.
+   */
+  object MarkPipelinedShuffleForRealTimeMode extends Rule[SparkPlan] {
+    override def apply(plan: SparkPlan): SparkPlan = {
+      val isRealTimeMode = plan.exists(_.isInstanceOf[RealTimeStreamScanExec])
+      if (!isRealTimeMode) {
+        plan
+      } else {
+        markStreamingPath(plan)._1
+      }
+    }
+
+    /**
+     * Marks the shuffles that are on the streaming path -- those whose 
subtree reaches a
+     * [[RealTimeStreamScanExec]] -- and returns the rewritten plan along with 
whether this
+     * subtree reaches one.
+     *
+     * A plan can hold a static subtree alongside the streaming one: the 
static side of a
+     * broadcast stream-static join is planned in the same physical plan and 
may contain its own
+     * shuffle. That shuffle materializes normally and is not part of the 
pipelined group -- and
+     * cannot be, since a static side runs to completion rather than 
streaming. Marking it
+     * pipelined would pull it into the group and demand slots for stages that 
must instead
+     * finish, which fails admission (CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT). 
This mirrors the
+     * streaming-path detection the operator allowlist uses 
(RealTimeModeAllowlist), which only
+     * inspects nodes whose subtree reaches the real-time scan; marking a 
wider set than the
+     * allowlist checks would flip shuffles it never validated.
+     */
+    private def markStreamingPath(plan: SparkPlan): (SparkPlan, Boolean) = 
plan match {
+      case rts: RealTimeStreamScanExec => (rts, true)
+      case p if p.children.isEmpty => (p, false)
+      case p =>
+        val results = p.children.map(markStreamingPath)
+        val onStreamingPath = results.exists(_._2)
+        val newPlan = p.withNewChildren(results.map(_._1))
+        newPlan match {
+          case s: ShuffleExchangeExec if onStreamingPath && !s.pipelined =>
+            // A bare case-class copy does not carry the node's tags, which is 
where the logical
+            // link lives, so copy them over the way the tree transforms do.
+            val marked = s.copy(pipelined = true)
+            marked.copyTagsFrom(s)
+            (marked, true)
+          case other => (other, onStreamingPath)
+        }
+    }
+
+  }
+
+  override def preparations: Seq[Rule[SparkPlan]] =
+    state +: (super.preparations :+ MarkPipelinedShuffleForRealTimeMode)
 
   /** no need to try-catch again as this is already done once */
   override def assertAnalyzed(): Unit = analyzed
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
index de84c9d15a6e..39fb0038ad4a 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
@@ -371,6 +371,31 @@ class MicroBatchExecution(
             messageParameters = Map("className" -> s.getClass.getName)
           )
       }
+
+      // `repartition(n)` uses RoundRobinPartitioning, and by default 
(SPARK-23207) Spark inserts a
+      // local sort before a round-robin shuffle so the row-to-partition 
assignment is deterministic
+      // -- otherwise a retried task could emit rows in a different order and 
lose data. That sort
+      // is fully blocking: it drains its whole input before emitting a row. A 
Real-Time Mode task
+      // reads an unbounded stream, so the input never ends, the producer 
never emits, and the query
+      // makes no progress at all. Note this sort is not a SortExec node (it 
lives inside
+      // ShuffleExchangeExec's RDD), so the Real-Time Mode operator allowlist 
cannot catch it.
+      //
+      // Determinism is not needed here: Real-Time Mode does not retry tasks 
(TaskSetManager caps a
+      // pipelined task set at one attempt, and its group is aborted as a unit 
rather than
+      // recomputed), so the hazard the sort guards against does not arise.
+      //
+      // This overrides an explicit `true` as well. Honouring it would 
reintroduce the hang above,
+      // and a query that never produces a row is worse than silently ignoring 
a config that only
+      // guards against a retry that cannot happen here. Log at warning level 
when overriding an
+      // explicit setting so the operator can see their config is not the one 
in force.
+      if 
(sparkSessionForStream.conf.contains(SQLConf.SORT_BEFORE_REPARTITION.key) &&
+        sparkSessionForStream.conf.get(SQLConf.SORT_BEFORE_REPARTITION)) {
+        logWarning(log"Ignoring ${MDC(LogKeys.CONFIG, 
SQLConf.SORT_BEFORE_REPARTITION.key)}=true " +
+          log"for this Real-Time Mode query: the local sort it inserts before 
a round-robin " +
+          log"repartition never completes on an unbounded stream. It is not 
needed because a " +
+          log"Real-Time Mode task is not retried.")
+      }
+      sparkSessionForStream.conf.set(SQLConf.SORT_BEFORE_REPARTITION.key, 
"false")
     }
 
     // Initializing TriggerExecutor relies on `sources`, hence calling this 
after initializing
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
index 7ae557797f36..2cae2f7ee2ad 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
@@ -19,9 +19,11 @@ package org.apache.spark.sql.execution.streaming.runtime
 
 import org.apache.spark.SparkIllegalArgumentException
 import org.apache.spark.internal.{Logging, LogKeys, MessageWithContext}
+import org.apache.spark.sql.catalyst.plans.physical.{Partitioning, 
RangePartitioning}
 import org.apache.spark.sql.connector.catalog.Table
 import org.apache.spark.sql.execution.SparkPlan
 import org.apache.spark.sql.execution.datasources.v2.RealTimeStreamScanExec
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
 import org.apache.spark.sql.execution.streaming.operators.stateful._
 
 object RealTimeModeAllowlist extends Logging {
@@ -55,7 +57,17 @@ object RealTimeModeAllowlist extends Logging {
     "org.apache.spark.sql.execution.datasources.v2.WriteToDataSourceV2Exec",
     "org.apache.spark.sql.execution.exchange.BroadcastExchangeExec",
     "org.apache.spark.sql.execution.exchange.ReusedExchangeExec",
+    // A pipelined (streaming) shuffle repartitions a stateful query's input 
by key. In Real-Time
+    // Mode the DAGScheduler co-schedules the shuffle's producer and consumer 
stages via a
+    // PipelinedShuffleDependency (see IncrementalExecution's 
pipelined-shuffle rule), so the
+    // exchange is a supported member of a pipelined group rather than a 
materialization barrier.
+    "org.apache.spark.sql.execution.exchange.ShuffleExchangeExec",
     "org.apache.spark.sql.execution.joins.BroadcastHashJoinExec",
+    // Streaming deduplication and the state-store access operators it plans 
into. These run in the
+    // pipelined-shuffle consumer stage, keyed by the same columns the shuffle 
repartitions on.
+    
"org.apache.spark.sql.execution.streaming.operators.stateful.StateStoreRestoreExec",
+    
"org.apache.spark.sql.execution.streaming.operators.stateful.StateStoreSaveExec",
+    
"org.apache.spark.sql.execution.streaming.operators.stateful.StreamingDeduplicateExec",
     classOf[EventTimeWatermarkExec].getName
   )
 
@@ -120,12 +132,37 @@ object RealTimeModeAllowlist extends Logging {
     collectNodesWhoseSubtreeHasRTS(root)._2
   }
 
+  /**
+   * Whether this operator is supported in Real-Time Mode.
+   *
+   * Membership in `allowedOperators` is the general rule, but a shuffle needs 
a second look: only
+   * some partitionings can run in Real-Time Mode. A `RangePartitioning` 
cannot, because building
+   * its `RangePartitioner` runs a separate job that samples the input to 
compute range bounds (see
+   * `ShuffleExchangeExec.prepareShuffleDependency`), and that job cannot 
complete while the source
+   * keeps producing. Rejecting it here fails the query fast with the usual 
allowlist error instead
+   * of letting it stall on a sampling job that never finishes.
+   */
+  private def isAllowed(node: SparkPlan): Boolean = {
+    allowedOperators.contains(node.getClass.getName) && (node match {
+      case e: ShuffleExchangeExec => supportsPartitioning(e.outputPartitioning)
+      case _ => true
+    })
+  }
+
+  /**
+   * Whether a shuffle with this partitioning is supported in Real-Time Mode. 
See `isAllowed` for
+   * why range partitioning is not.
+   */
+  private def supportsPartitioning(partitioning: Partitioning): Boolean = {
+    !partitioning.isInstanceOf[RangePartitioning]
+  }
+
   def checkAllowedPhysicalOperator(operator: SparkPlan, throwException: 
Boolean): Unit = {
     val nodesToCheck = collectRealtimeNodes(operator)
     val violations = nodesToCheck
       .collect {
         case node =>
-          if (allowedOperators.contains(node.getClass.getName)) {
+          if (isAllowed(node)) {
             None
           } else {
             Some(node.getClass.getName)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
index c1d1f13915a6..136dc57fdfcf 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
@@ -1360,7 +1360,7 @@ class DataFrameWindowFunctionsSuite extends 
SharedSparkSession
     def isShuffleExecByRequirement(
         plan: ShuffleExchangeExec,
         desiredClusterColumns: Seq[String]): Boolean = plan match {
-      case ShuffleExchangeExec(op: HashPartitioning, _, ENSURE_REQUIREMENTS, 
_) =>
+      case ShuffleExchangeExec(op: HashPartitioning, _, ENSURE_REQUIREMENTS, 
_, _) =>
         partitionExpressionsColumns(op.expressions) === desiredClusterColumns
       case _ => false
     }
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/DatasetSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/DatasetSuite.scala
index 3b28cae31a13..096eee137b32 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/DatasetSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DatasetSuite.scala
@@ -1942,7 +1942,7 @@ class DatasetSuite extends SharedSparkSession
         val agg = cp.groupBy($"id" % 2).agg(count($"id"))
 
         agg.queryExecution.executedPlan.collectFirst {
-          case ShuffleExchangeExec(_, _: RDDScanExec, _, _) =>
+          case ShuffleExchangeExec(_, _: RDDScanExec, _, _, _) =>
           case BroadcastExchangeExec(_, _: RDDScanExec) =>
         }.foreach { _ =>
           fail(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/ExchangeSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/ExchangeSuite.scala
index b7798b0bde5d..c6c262379d58 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/execution/ExchangeSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/execution/ExchangeSuite.scala
@@ -238,4 +238,32 @@ class ExchangeSuite extends SharedSparkSession {
         "ReusedExchangeExec should reuse an existing exchange")
     }
   }
+
+  test("ShuffleExchangeExec string args show `pipelined` only when it is set") 
{
+    val plan = spark.range(10).selectExpr("id % 4 AS 
key").queryExecution.executedPlan
+    val partitioning = HashPartitioning(Seq(Literal(1)), 4)
+
+    // The default: nothing about pipelining is printed, so plan output (and 
every golden file that
+    // captures it) is unchanged by the existence of the field.
+    val nonPipelined = ShuffleExchangeExec(partitioning, plan).simpleString(10)
+    assert(!nonPipelined.contains("pipelined") && 
!nonPipelined.contains("false"),
+      s"a non-pipelined exchange should not mention pipelining, but was: 
$nonPipelined")
+
+    // When set, it is printed with a name rather than as a bare positional 
`true`.
+    val pipelined = ShuffleExchangeExec(partitioning, plan, pipelined = 
true).simpleString(10)
+    assert(pipelined.contains("isPipelined=true"),
+      s"a pipelined exchange should report it, but was: $pipelined")
+
+    // `stringArgs` drops `pipelined` by position, so it must stay the last 
constructor field --
+    // otherwise a newly added field would be dropped instead and the bare 
`false` would come back.
+    val exchange = ShuffleExchangeExec(partitioning, plan)
+    assert(exchange.productElementName(exchange.productArity - 1) == 
"pipelined",
+      "`pipelined` must remain the last constructor field of 
ShuffleExchangeExec, because " +
+        "stringArgs drops it positionally")
+
+    // Exchange's plan_id suffix is re-appended by the override and must 
survive.
+    Seq(nonPipelined, pipelined).foreach { s =>
+      assert(s.contains("[plan_id="), s"the plan_id suffix should be 
preserved, but was: $s")
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/PlannerSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/PlannerSuite.scala
index 71192e92af0d..41b74c6d4ff2 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/execution/PlannerSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/execution/PlannerSuite.scala
@@ -733,10 +733,11 @@ class PlannerSuite extends SharedSparkSession with 
AdaptiveSparkPlanHelper {
     outputPlan match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
              SortExec(_, _,
-               
ShuffleExchangeExec(HashPartitioning(leftPartitioningExpressions, _), _, _, _), 
_),
+               
ShuffleExchangeExec(HashPartitioning(leftPartitioningExpressions, _),
+               _, _, _, _), _),
              SortExec(_, _,
                
ShuffleExchangeExec(HashPartitioning(rightPartitioningExpressions, _),
-               _, _, _), _), _) =>
+               _, _, _, _), _), _) =>
         assert(leftKeys === smjExec.leftKeys)
         assert(rightKeys === smjExec.rightKeys)
         assert(leftKeys === leftPartitioningExpressions)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
index 17d00ec055e0..95085b71bc25 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
@@ -60,7 +60,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
     EnsureRequirements.apply(smjExec1) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
         SortExec(_, _, DummySparkPlan(_, _, _: PartitioningCollection, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _), 
_) =>
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(leftKeys === Seq(exprA, exprB))
         assert(rightKeys === Seq(exprB, exprA))
       case other => fail(other.toString)
@@ -71,7 +71,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: Nil, exprB :: exprA :: Nil, Inner, None, plan2, plan1)
     EnsureRequirements.apply(smjExec2) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
         SortExec(_, _, DummySparkPlan(_, _, _: PartitioningCollection, _, _), 
_), _) =>
         assert(leftKeys === Seq(exprB, exprA))
         assert(rightKeys === Seq(exprA, exprB))
@@ -84,7 +84,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprD :: exprC :: Nil, exprB :: exprA :: Nil, Inner, None, plan1, plan1)
     EnsureRequirements.apply(smjExec3) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
         SortExec(_, _, DummySparkPlan(_, _, _: PartitioningCollection, _, _), 
_), _) =>
         assert(leftKeys === Seq(exprC, exprD))
         assert(rightKeys === Seq(exprA, exprB))
@@ -126,8 +126,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
     )
     EnsureRequirements.apply(smjExec2) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-      SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _), _) 
=>
+      SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), _),
+      SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), _), 
_) =>
         assert(leftKeys === Seq(exprC, exprB, exprD))
         assert(rightKeys === Seq(exprD, exprA, exprC))
       case other => fail(other.toString)
@@ -145,7 +145,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: Nil, exprC :: exprB :: Nil, Inner, None, plan1, plan2)
     EnsureRequirements.apply(smjExec1) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
         SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _), _) 
=>
         assert(leftKeys === Seq(exprB, exprA))
         assert(rightKeys === Seq(exprB, exprC))
@@ -159,7 +159,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: Nil, exprC :: exprB :: Nil, Inner, None, plan1, plan3)
     EnsureRequirements.apply(smjExec2) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
         SortExec(_, _, DummySparkPlan(_, _, _: PartitioningCollection, _, _), 
_), _) =>
         assert(leftKeys === Seq(exprB, exprA))
         assert(rightKeys === Seq(exprB, exprC))
@@ -173,7 +173,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
     EnsureRequirements.apply(smjExec3) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
         SortExec(_, _, DummySparkPlan(_, _, _: PartitioningCollection, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _), 
_) =>
+        SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(leftKeys === Seq(exprB, exprC))
         assert(rightKeys === Seq(exprB, exprA))
       case other => fail(other.toString)
@@ -319,7 +319,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(leftKeys, rightKeys, _, _,
         SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _), _), 
_) =>
+        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _, _), 
_), _) =>
           assert(leftKeys === Seq(exprA, exprB))
           assert(rightKeys === Seq(exprC, exprD))
           assert(p.expressions == Seq(exprC))
@@ -335,7 +335,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _), _),
+        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _, _), 
_),
         SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _), _) 
=>
           assert(leftKeys === Seq(exprA, exprB))
           assert(rightKeys === Seq(exprC, exprD))
@@ -353,7 +353,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(leftKeys, rightKeys, _, _,
         SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _), _), 
_) =>
+        SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _, _), 
_), _) =>
           assert(leftKeys === Seq(exprA, exprB))
           assert(rightKeys === Seq(exprC, exprD))
           assert(p.expressions == Seq(exprC))
@@ -372,7 +372,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
       SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _), _), _) 
=>
+      SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _, _), _), 
_) =>
         assert(leftKeys === Seq(exprA, exprB, exprB))
         assert(rightKeys === Seq(exprA, exprC, exprC))
         assert(p.expressions == Seq(exprA, exprC, exprA))
@@ -388,7 +388,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(leftKeys, rightKeys, _, _,
       SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _), _), _) 
=>
+      SortExec(_, _, ShuffleExchangeExec(p: HashPartitioning, _, _, _, _), _), 
_) =>
         assert(leftKeys === Seq(exprA, exprB, exprB))
         assert(rightKeys === Seq(exprA, exprC, exprD))
         assert(p.expressions == Seq(exprA, exprC, exprA))
@@ -444,8 +444,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == 5)
           assert(right.numPartitions == 5)
         case other => fail(other.toString)
@@ -462,7 +462,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
         SortExec(_, _, DummySparkPlan(_, _, left: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == 10)
           assert(right.numPartitions == 10)
           assert(right.expressions == Seq(exprC, exprD))
@@ -481,8 +481,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == 5)
           assert(left.expressions == Seq(exprA, exprB))
           assert(right.numPartitions == 5)
@@ -492,7 +492,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
         SortExec(_, _, DummySparkPlan(_, _, left: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == 1)
           assert(right.numPartitions == 1)
           assert(right.expressions == Seq(exprC))
@@ -510,8 +510,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == conf.numShufflePartitions)
           assert(left.expressions == Seq(exprA, exprB))
           assert(right.numPartitions == conf.numShufflePartitions)
@@ -529,7 +529,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
         SortExec(_, _, DummySparkPlan(_, _, left: PartitioningCollection, _, 
_), _),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.numPartitions == 10)
           assert(right.numPartitions == 10)
           assert(right.expressions == Seq(exprA))
@@ -545,7 +545,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         Inner, None, plan1, plan2)
       applyEnsureRequirementsWithSubsetKeys(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
         SortExec(_, _, DummySparkPlan(_, _, right: PartitioningCollection, _, 
_), _), _) =>
           assert(left.numPartitions == 20)
           assert(left.expressions == Seq(exprC))
@@ -587,7 +587,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
         SortExec(_, _, DummySparkPlan(_, _, left: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.expressions === Seq(exprA, exprB))
           assert(right.expressions === Seq(exprC, exprD))
           assert(left.numPartitions == 6)
@@ -606,7 +606,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
         SortExec(_, _, DummySparkPlan(_, _, right: HashPartitioning, _, _), 
_), _) =>
           assert(left.expressions === Seq(exprA, exprB))
           assert(right.expressions === Seq(exprC, exprD))
@@ -624,7 +624,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
         SortExec(_, _, DummySparkPlan(_, _, right: HashPartitioning, _, _), 
_), _) =>
           assert(left.expressions === Seq(exprA, exprB))
           assert(right.expressions === Seq(exprC, exprD))
@@ -644,7 +644,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
         SortExec(_, _, DummySparkPlan(_, _, right: HashPartitioning, _, _), 
_), _) =>
           assert(left.expressions === Seq(exprA, exprB))
           assert(right.expressions === Seq(exprC, exprD))
@@ -666,8 +666,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         exprA :: exprB :: Nil, exprC :: exprD :: Nil, Inner, None, plan1, 
plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(left.expressions === Seq(exprA, exprB))
           assert(right.expressions === Seq(exprC, exprD))
           assert(left.numPartitions == conf.numShufflePartitions)
@@ -691,7 +691,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
           EnsureRequirements.apply(smjExec) match {
             case SortMergeJoinExec(leftKeys, rightKeys, _, _,
             SortExec(_, _, DummySparkPlan(_, _, left: HashPartitioning, _, _), 
_),
-            SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, 
_), _), _) =>
+            SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, 
_, _), _), _) =>
               assert(leftKeys === Seq(exprA, exprB))
               assert(rightKeys === Seq(exprC, exprD))
               assert(left.numPartitions == 9)
@@ -714,8 +714,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       var smjExec = SortMergeJoinExec(exprA :: Nil, exprC :: Nil, Inner, None, 
plan1, plan2)
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(leftKeys === Seq(exprA))
           assert(rightKeys === Seq(exprC))
           assert(left.numPartitions == 20)
@@ -733,7 +733,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       EnsureRequirements.apply(smjExec) match {
         case SortMergeJoinExec(leftKeys, rightKeys, _, _,
         SortExec(_, _, DummySparkPlan(_, _, _: HashPartitioning, _, _), _),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
           assert(leftKeys === Seq(exprA))
           assert(rightKeys === Seq(exprC))
           assert(right.numPartitions == 10)
@@ -765,8 +765,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         } else {
           EnsureRequirements.apply(smjExec) match {
             case SortMergeJoinExec(leftKeys, rightKeys, _, _,
-            SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, 
_), _),
-            SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, 
_), _), _) =>
+            SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, 
_, _), _),
+            SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, 
_, _), _), _) =>
               assert(leftKeys === Seq(exprA))
               assert(rightKeys === Seq(exprC))
               assert(left.numPartitions == 5)
@@ -875,8 +875,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: exprB :: Nil, exprA :: exprC :: exprC :: Nil, Inner, 
None, plan1, plan2)
     EnsureRequirements.apply(smjExec) match {
       case SortMergeJoinExec(_, _, _, _,
-        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), 
_),
-        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+        SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, 
_), _),
+        SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, 
_), _), _) =>
         assert(left.expressions === Seq(exprA, exprB, exprB))
         assert(right.expressions === Seq(exprA, exprC, exprC))
       case other => fail(other.toString)
@@ -951,8 +951,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: exprC :: Nil, exprA :: exprB :: exprC :: Nil, Inner, 
None, plan1, plan2)
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(_, _, _, _,
-      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, _), 
_),
+      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(left.expressions === Seq(exprA, exprB, exprC))
         assert(right.expressions === Seq(exprA, exprB, exprC))
       case other => fail(other.toString)
@@ -969,8 +969,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: exprB :: Nil, exprA :: exprC :: exprC :: Nil, Inner, 
None, plan1, plan2)
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(_, _, _, _,
-      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, _), 
_),
+      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(left.expressions === Seq(exprA, exprB, exprB))
         assert(right.expressions === Seq(exprA, exprC, exprC))
       case other => fail(other.toString)
@@ -987,8 +987,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: exprB :: Nil, exprA :: exprC :: exprC :: Nil, Inner, 
None, plan1, plan2)
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(_, _, _, _,
-      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, _), 
_),
+      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(left.expressions === Seq(exprA, exprB, exprB))
         assert(right.expressions === Seq(exprA, exprC, exprC))
       case other => fail(other.toString)
@@ -1008,8 +1008,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
       exprA :: exprB :: exprB :: Nil, exprA :: exprC :: exprC :: Nil, Inner, 
None, plan1, plan2)
     applyEnsureRequirementsWithSubsetKeys(smjExec) match {
       case SortMergeJoinExec(_, _, _, _,
-      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _), _),
-      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _), 
_), _) =>
+      SortExec(_, _, ShuffleExchangeExec(left: HashPartitioning, _, _, _, _), 
_),
+      SortExec(_, _, ShuffleExchangeExec(right: HashPartitioning, _, _, _, _), 
_), _) =>
         assert(left.expressions === Seq(exprA, exprB, exprB))
         assert(right.expressions === Seq(exprA, exprC, exprC))
       case other => fail(other.toString)
@@ -1130,7 +1130,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
         case ShuffledHashJoinExec(_, _, _, _, _,
         DummySparkPlan(_, _, left: KeyedPartitioning, _, _),
         ShuffleExchangeExec(KeyedPartitioning(attrs, pks, _, _),
-        DummySparkPlan(_, _, SinglePartition, _, _), _, _), _) =>
+        DummySparkPlan(_, _, SinglePartition, _, _), _, _, _), _) =>
           assert(left.expressions == a1 :: Nil)
           assert(attrs == a1 :: Nil)
           assert(partitionKeys == pks.map(_.row))
@@ -1234,8 +1234,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
 
       EnsureRequirements.apply(join) match {
         case SortMergeJoinExec(_, _, _, _,
-          SortExec(_, _, ShuffleExchangeExec(p1: HashPartitioning, _, _, _), 
_),
-          SortExec(_, _, ShuffleExchangeExec(p2: HashPartitioning, _, _, _), 
_), _) =>
+          SortExec(_, _, ShuffleExchangeExec(p1: HashPartitioning, _, _, _, 
_), _),
+          SortExec(_, _, ShuffleExchangeExec(p2: HashPartitioning, _, _, _, 
_), _), _) =>
           // Both sides should be shuffled to default partitions
           assert(p1.numPartitions == 10)
           assert(p2.numPartitions == 10)
@@ -1259,8 +1259,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
 
       EnsureRequirements.apply(join) match {
         case SortMergeJoinExec(_, _, _, _,
-          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
-          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), 
_), _) =>
+          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
+          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_), _) =>
           // Both sides shuffled due to key mismatch
         case other => fail(s"Expected shuffles on both sides, but got: $other")
       }
@@ -1278,7 +1278,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
 
       EnsureRequirements.apply(join) match {
         case SortMergeJoinExec(_, _, _, _,
-          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
+          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
           SortExec(_, _, _: DummySparkPlan, _), _) =>
           // Left side shuffled, right side kept as-is
         case other => fail(s"Expected shuffle on the left side, but got: 
$other")
@@ -1296,8 +1296,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
 
       EnsureRequirements.apply(join) match {
         case SortMergeJoinExec(_, _, _, _,
-          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), _),
-          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _), 
_), _) =>
+          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_),
+          SortExec(_, _, ShuffleExchangeExec(_: HashPartitioning, _, _, _, _), 
_), _) =>
           // Both sides shuffled due to canCreatePartitioning = false
         case other => fail(s"Expected shuffles on both sides, but got: $other")
       }
@@ -1371,8 +1371,8 @@ class EnsureRequirementsSuite extends SharedSparkSession {
 
       EnsureRequirements.apply(join) match {
         case SortMergeJoinExec(_, _, _, _,
-          SortExec(_, _, ShuffleExchangeExec(p1: HashPartitioning, _, _, _), 
_),
-          SortExec(_, _, ShuffleExchangeExec(p2: HashPartitioning, _, _, _), 
_), _) =>
+          SortExec(_, _, ShuffleExchangeExec(p1: HashPartitioning, _, _, _, 
_), _),
+          SortExec(_, _, ShuffleExchangeExec(p2: HashPartitioning, _, _, _, 
_), _), _) =>
           // Both sides should be shuffled because partition keys not in join 
keys
           assert(p1.numPartitions == 10)
           assert(p2.numPartitions == 10)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeAllowlistSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeAllowlistSuite.scala
index a9b8d54a6c61..de4af97656a6 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeAllowlistSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeAllowlistSuite.scala
@@ -70,7 +70,6 @@ class StreamRealTimeModeAllowlistSuite extends 
StreamRealTimeModeE2ESuiteBase {
             "errorType" -> "operator",
             "message" -> (
               "org.apache.spark.sql.execution.SortExec, " +
-                "org.apache.spark.sql.execution.exchange.ShuffleExchangeExec, 
" +
                 "org.apache.spark.sql.execution.joins.SortMergeJoinExec are"
               )
           )
@@ -107,31 +106,34 @@ class StreamRealTimeModeAllowlistSuite extends 
StreamRealTimeModeE2ESuiteBase {
     }
   }
 
-  // TODO(SPARK-54237) : Remove this test after RTM can shuffle to multiple 
stages
-  test("repartition not allowed") {
-      val inputData = LowLatencyMemoryStream[Int](2)
+  // A repartitionByRange produces a range-partitioned shuffle. Building its 
RangePartitioner runs a
+  // separate job that samples the input to compute range bounds, which cannot 
complete while the
+  // source keeps producing, so it must be rejected up front rather than 
stalling the query.
+  test("range-partitioned shuffle not allowed") {
+    val inputData = LowLatencyMemoryStream[Int](2)
 
-      val df = inputData.toDF()
-        .select(col("value").as("key"))
-        .repartition(4, col("key"))
+    val df = inputData.toDF()
+      .select(col("value").as("key"))
+      .repartitionByRange(3, col("key"))
+      .select(col("key"))
 
-      val query = runStreamingQuery("repartition_allowlist", df)
+    val query = runStreamingQuery("range_shuffle_allowlist", df)
 
-      eventually(timeout(60.seconds)) {
-        checkError(
-          exception = 
query.exception.get.getCause.asInstanceOf[SparkIllegalArgumentException],
-          condition = 
"STREAMING_REAL_TIME_MODE.OPERATOR_OR_SINK_NOT_IN_ALLOWLIST",
-          parameters = Map(
-            "errorType" -> "operator",
-            "message" -> (
-                "org.apache.spark.sql.execution.exchange.ShuffleExchangeExec 
is"
-              )
-          )
+    eventually(timeout(60.seconds)) {
+      checkError(
+        exception = 
query.exception.get.getCause.asInstanceOf[SparkIllegalArgumentException],
+        condition = 
"STREAMING_REAL_TIME_MODE.OPERATOR_OR_SINK_NOT_IN_ALLOWLIST",
+        parameters = Map(
+          "errorType" -> "operator",
+          "message" -> 
"org.apache.spark.sql.execution.exchange.ShuffleExchangeExec is"
         )
-      }
+      )
+    }
   }
 
-  // TODO(SPARK-54236) : Remove this test after RTM supports stateful queries
+  // A stateful aggregation is still rejected under RTM because 
HashAggregateExec is not in the
+  // allowlist, even though the pipelined shuffle and the StateStore operators 
now are. When RTM
+  // supports aggregation (SPARK-54236), remove this test.
   test("stateful queries not allowed") {
     val inputData = LowLatencyMemoryStream[Int](2)
 
@@ -141,7 +143,7 @@ class StreamRealTimeModeAllowlistSuite extends 
StreamRealTimeModeE2ESuiteBase {
       .count()
       .select(concat(col("key"), lit("-"), col("count")))
 
-    val query = runStreamingQuery("repartition_allowlist", df)
+    val query = runStreamingQuery("stateful_allowlist", df)
 
     eventually(timeout(60.seconds)) {
       checkError(
@@ -149,13 +151,7 @@ class StreamRealTimeModeAllowlistSuite extends 
StreamRealTimeModeE2ESuiteBase {
         condition = 
"STREAMING_REAL_TIME_MODE.OPERATOR_OR_SINK_NOT_IN_ALLOWLIST",
         parameters = Map(
           "errorType" -> "operator",
-          "message" -> (
-            "org.apache.spark.sql.execution.aggregate.HashAggregateExec, " +
-              "org.apache.spark.sql.execution.exchange.ShuffleExchangeExec, " +
-              "org.apache.spark.sql.execution.streaming" +
-              ".operators.stateful.StateStoreRestoreExec, " +
-              
"org.apache.spark.sql.execution.streaming.operators.stateful.StateStoreSaveExec 
are"
-            )
+          "message" -> 
"org.apache.spark.sql.execution.aggregate.HashAggregateExec is"
         )
       )
     }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeSuite.scala
index 0f584d818c06..689d89e19e00 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamRealTimeModeSuite.scala
@@ -17,18 +17,23 @@
 
 package org.apache.spark.sql.streaming
 
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicInteger
+import java.io.IOException
+import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
+import java.util.concurrent.atomic.{AtomicInteger, AtomicReference}
 
 import scala.concurrent.duration.Duration
 
 import org.scalatest.concurrent.PatienceConfiguration.Timeout
 
-import org.apache.spark.{SparkIllegalArgumentException, 
SparkIllegalStateException, TaskContext}
+import org.apache.spark.{SparkException, SparkIllegalArgumentException, 
SparkIllegalStateException, TaskContext}
+import org.apache.spark.scheduler.{SparkListener, SparkListenerJobStart, 
SparkListenerStageCompleted, SparkListenerStageSubmitted}
+import org.apache.spark.sql.execution.datasources.v2.RealTimeStreamScanExec
+import org.apache.spark.sql.execution.exchange.{ReusedExchangeExec, 
ShuffleExchangeExec}
 import org.apache.spark.sql.execution.streaming.RealTimeTrigger
-import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.execution.streaming.runtime.{MemoryStream, 
StreamExecution}
 import org.apache.spark.sql.execution.streaming.sources.{ContinuousMemorySink, 
LowLatencyMemoryStream}
-import org.apache.spark.sql.functions.udf
+import 
org.apache.spark.sql.execution.streaming.state.{FailureInjectionCheckpointFileManager,
 FailureInjectionFileSystem}
+import org.apache.spark.sql.functions.{broadcast, concat, lit, udf}
 import org.apache.spark.sql.internal.SQLConf
 
 class StreamRealTimeModeSuite extends StreamRealTimeModeSuiteBase {
@@ -209,6 +214,74 @@ class StreamRealTimeModeSuite extends 
StreamRealTimeModeSuiteBase {
       StopStream
     )
   }
+
+  test("pipelined shuffle: a static-side shuffle is not marked pipelined") {
+    // A broadcast stream-static join can carry a shuffle on its STATIC side, 
in a subtree with no
+    // RealTimeStreamScanExec. That shuffle must materialize normally: a 
static side runs to
+    // completion rather than streaming, so pulling it into the pipelined 
group would demand
+    // concurrent slots for a stage that must instead finish, failing 
admission. Only shuffles on
+    // the streaming path are marked, matching what the operator allowlist 
actually validates.
+    withSQLConf(
+      SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10485760") {
+      val staticData = spark.range(0, 3).selectExpr("id as sk", "id * 10 as 
sv")
+        .repartition(3, $"sk")
+      val inputData = LowLatencyMemoryStream[(String, Int)](2)
+      val streamDf = inputData.toDF().select($"_1".as("key"), 
$"_2".cast("long").as("sk"))
+      val result = streamDf
+        .repartition(2, $"sk")
+        .join(broadcast(staticData), Seq("sk"), "left")
+        .select($"key")
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        AddData(inputData, ("a", 1), ("b", 2)),
+        StartStream(),
+        CheckAnswerWithTimeout(60000, "a", "b"),
+        Execute { q =>
+          val exchanges = q.lastExecution.executedPlan.collect {
+            case s: ShuffleExchangeExec => s
+          }
+          // The streaming-side repartition is pipelined; a static-side 
shuffle, if it survives
+          // into this plan, must not be.
+          val streamingSide = exchanges.filter(_.exists {
+            case _: RealTimeStreamScanExec => true
+            case _ => false
+          })
+          assert(streamingSide.nonEmpty, "expected a shuffle on the streaming 
path")
+          assert(streamingSide.forall(_.pipelined),
+            "a streaming-path shuffle must be pipelined")
+          
assert(exchanges.filterNot(streamingSide.contains).forall(!_.pipelined),
+            "a static-side shuffle must not be marked pipelined")
+        },
+        StopStream
+      )
+    }
+  }
+
+  test("multiple broadcast joins with the same static table run in Real-Time 
Mode") {
+    // Two broadcast joins against the SAME static table -- exchange reuse 
collapses the second
+    // broadcast into a ReusedExchangeExec. This is the supported reuse shape 
in Real-Time Mode: a
+    // reused BROADCAST exchange (allowlisted), NOT a reused shuffle. The RTM 
marking rule only
+    // marks ShuffleExchangeExec, so it correctly leaves the broadcast reuse 
alone.
+    val inputData = LowLatencyMemoryStream.singlePartition[Int]
+    val staticData = Seq((1, "a"), (2, "b"), (3, "c")).toDF("key", "value")
+    val df = inputData.toDS().toDF("key").join(broadcast(staticData), 
Seq("key"), "left")
+    val df2 = df.join(broadcast(staticData), Seq("key"), "left")
+    testStream(df2, OutputMode.Update, Map.empty, new ContinuousMemorySink())(
+      AddData(inputData, 1, 2, 3),
+      StartStream(),
+      CheckAnswerWithTimeout(30000, (1, "a", "a"), (2, "b", "b"), (3, "c", 
"c")),
+      Execute { q =>
+        val plan = q.lastExecution.executedPlan
+        // Verify the shape this test is about, not just the answer: the 
second broadcast is reused,
+        // and the marking rule left it alone because it only marks shuffle 
exchanges.
+        assert(plan.exists(_.isInstanceOf[ReusedExchangeExec]),
+          s"expected the second broadcast to be reused, got:\n$plan")
+        assert(plan.collect { case s: ShuffleExchangeExec => s }.isEmpty,
+          "a broadcast stream-static join should introduce no shuffle 
exchange")
+      },
+      StopStream
+    )
+  }
 }
 
 class StreamRealTimeModeWithManualClockSuite extends 
StreamRealTimeModeManualClockSuiteBase {
@@ -393,4 +466,339 @@ class StreamRealTimeModeWithManualClockSuite extends 
StreamRealTimeModeManualClo
       StopStream
     )
   }
+
+  // 
========================================================================================
+  // Pipelined (streaming) shuffle: a stateful/repartition Real-Time Mode 
query whose shuffle is a
+  // PipelinedShuffleDependency, so the producer (source scan) and consumer 
stages are co-scheduled
+  // and stream records through a transient shuffle instead of the consumer 
waiting for the producer
+  // to fully materialize.
+  // 
========================================================================================
+
+  override def beforeEach(): Unit = {
+    super.beforeEach()
+    StreamRealTimeModeSuite.failTasks = false
+  }
+
+  /** Assert every shuffle exchange in the query's last executed plan is 
pipelined. */
+  private def assertAllExchangesPipelined(q: StreamExecution): Unit = {
+    val exchanges = q.lastExecution.executedPlan.collect { case s: 
ShuffleExchangeExec => s }
+    assert(exchanges.nonEmpty, "expected at least one shuffle exchange in the 
plan")
+    assert(exchanges.forall(_.pipelined),
+      "expected all Real-Time Mode shuffle exchanges to be pipelined, got: " +
+        exchanges.map(e => s"pipelined=${e.pipelined}").mkString(", "))
+  }
+
+  test("pipelined shuffle: stateful dedup runs in Real-Time Mode and 
co-schedules its stages") {
+    // Track, from the driver, whether the producer (source scan) and consumer 
(dedup) stages of the
+    // pipelined group were ever RUNNING simultaneously. A sequential 
producer-then-consumer
+    // schedule never exceeds one running stage at a time; >= 2 proves genuine 
co-scheduling.
+    val runningStages = ConcurrentHashMap.newKeySet[Int]()
+    val maxConcurrentStages = new AtomicInteger(0)
+    val queryStageIds = ConcurrentHashMap.newKeySet[Int]()
+    // Count only stages belonging to the query under test. The suite shares 
one SparkContext, so a
+    // stage from any other streaming query would otherwise satisfy the 
co-scheduling assertion
+    // below even if this query's producer and consumer actually ran one after 
the other. The id is
+    // captured from the query once it is running, and every job is matched 
against it.
+    val queryId = new AtomicReference[String](null)
+    val listener = new SparkListener {
+      override def onJobStart(e: SparkListenerJobStart): Unit = {
+        // StreamExecution tags every streaming job with its query id.
+        val id = queryId.get()
+        if (id != null && 
e.properties.getProperty(StreamExecution.QUERY_ID_KEY) == id) {
+          e.stageIds.foreach(queryStageIds.add(_))
+        }
+      }
+      override def onStageSubmitted(e: SparkListenerStageSubmitted): Unit = {
+        if (queryStageIds.contains(e.stageInfo.stageId)) {
+          runningStages.add(e.stageInfo.stageId)
+          maxConcurrentStages.accumulateAndGet(runningStages.size(), Math.max)
+        }
+      }
+      override def onStageCompleted(e: SparkListenerStageCompleted): Unit = {
+        runningStages.remove(e.stageInfo.stageId)
+      }
+    }
+    spark.sparkContext.addSparkListener(listener)
+    try {
+      val inputData = LowLatencyMemoryStream[(String, Int)]
+      // scan --shuffle(repartition by key)--> streaming dropDuplicates --> 
sink.
+      val result = 
inputData.toDF().select($"_1".as("key")).dropDuplicates("key").select($"key")
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        AddData(inputData, ("a", 1), ("b", 1), ("c", 1), ("a", 2), ("b", 2), 
("c", 2)),
+        StartStream(),
+        // StartStream only launches the query -- the stream runs on its own 
thread and can submit
+        // the first batch's jobs before the id is recorded here, in which 
case those jobs are not
+        // attributed. Rather than race, the assertion below relies on the 
SECOND batch, which is
+        // driven after this point and so is always observed in full.
+        Execute(q => queryId.set(q.id.toString)),
+        CheckAnswerWithTimeout(60000, "a", "b", "c"),
+        advanceRealTimeClock,
+        WaitUntilBatchProcessed(0),
+        AddData(inputData, ("a", 3), ("b", 3), ("c", 3), ("d", 1)),
+        CheckAnswerWithTimeout(60000, "a", "b", "c", "d"),
+        Execute { q =>
+          assertAllExchangesPipelined(q)
+          assert(maxConcurrentStages.get() >= 2,
+            s"expected >= 2 stages running concurrently, saw max 
${maxConcurrentStages.get()}")
+        },
+        StopStream
+      )
+    } finally {
+      spark.sparkContext.removeSparkListener(listener)
+    }
+  }
+
+  test("pipelined shuffle: multi-key dedup runs in Real-Time Mode over a 
pipelined shuffle") {
+    // dropDuplicates on more than one column: the hash-partitioning is by the 
composite key.
+    // Confirms the pipelined path is not specific to a single dedup column.
+    val inputData = LowLatencyMemoryStream[(String, Int)]
+    val result = inputData.toDF().select($"_1".as("k1"), $"_2".as("k2"))
+      .dropDuplicates("k1", "k2")
+      .select(concat($"k1", lit("-"), $"k2").as("out"))
+    testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+      // (a,1) twice -> once; (a,2) is a distinct composite key -> also 
emitted.
+      AddData(inputData, ("a", 1), ("a", 1), ("a", 2), ("b", 1)),
+      StartStream(),
+      CheckAnswerWithTimeout(60000, "a-1", "a-2", "b-1"),
+      advanceRealTimeClock,
+      WaitUntilBatchProcessed(0),
+      AddData(inputData, ("a", 1), ("a", 2), ("b", 2)),
+      CheckAnswerWithTimeout(60000, "a-1", "a-2", "b-1", "b-2"),
+      Execute { q => assertAllExchangesPipelined(q) },
+      StopStream
+    )
+  }
+
+  test("pipelined shuffle: an explicit repartition-by-key runs in Real-Time 
Mode") {
+    // A stateless repartition (no dedup): the ShuffleExchangeExec is still 
marked pipelined in RTM.
+    val inputData = LowLatencyMemoryStream[Int]
+    val result = inputData.toDF().repartition(4, $"value").select($"value")
+    testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+      AddData(inputData, 1, 2, 3, 4, 5),
+      StartStream(),
+      CheckAnswerWithTimeout(60000, 1, 2, 3, 4, 5),
+      Execute { q => assertAllExchangesPipelined(q) },
+      advanceRealTimeClock,
+      WaitUntilBatchProcessed(0),
+      AddData(inputData, 6, 7),
+      CheckAnswerWithTimeout(60000, 1, 2, 3, 4, 5, 6, 7),
+      StopStream
+    )
+  }
+
+  test("pipelined shuffle: a chain of two shuffles is a single co-scheduled 
pipelined group") {
+    // A round-robin repartition (RoundRobinPartitioning) feeding a 
dropDuplicates (HashPartitioning
+    // on the dedup key) produces TWO distinct shuffle exchanges -- neither 
distribution satisfies
+    // the other, so they are not collapsed. BOTH must be marked pipelined and 
the all-pipelined job
+    // co-schedules as one group (>= 2 stages running at once). This is the 
multi-shuffle-per-
+    // group scenario: more than one pipelined shuffle in a single Real-Time 
Mode job.
+    val runningStages = ConcurrentHashMap.newKeySet[Int]()
+    val maxConcurrentStages = new AtomicInteger(0)
+    val queryStageIds = ConcurrentHashMap.newKeySet[Int]()
+    // Count only stages belonging to the query under test. The suite shares 
one SparkContext, so a
+    // stage from any other streaming query would otherwise satisfy the 
co-scheduling assertion
+    // below even if this query's producer and consumer actually ran one after 
the other. The id is
+    // captured from the query once it is running, and every job is matched 
against it.
+    val queryId = new AtomicReference[String](null)
+    val listener = new SparkListener {
+      override def onJobStart(e: SparkListenerJobStart): Unit = {
+        // StreamExecution tags every streaming job with its query id.
+        val id = queryId.get()
+        if (id != null && 
e.properties.getProperty(StreamExecution.QUERY_ID_KEY) == id) {
+          e.stageIds.foreach(queryStageIds.add(_))
+        }
+      }
+      override def onStageSubmitted(e: SparkListenerStageSubmitted): Unit = {
+        if (queryStageIds.contains(e.stageInfo.stageId)) {
+          runningStages.add(e.stageInfo.stageId)
+          maxConcurrentStages.accumulateAndGet(runningStages.size(), Math.max)
+        }
+      }
+      override def onStageCompleted(e: SparkListenerStageCompleted): Unit = {
+        runningStages.remove(e.stageInfo.stageId)
+      }
+    }
+    spark.sparkContext.addSparkListener(listener)
+    // Keep the shuffle partition count small: the whole group (scan + 2 
shuffles) must fit in the
+    // test cluster's slots at once (gang admission), so a 2-shuffle chain at 
the default 200
+    // partitions would fail with CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT.
+    try {
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+        val inputData = LowLatencyMemoryStream[(String, Int)]
+        val result = inputData.toDF().select($"_1".as("key"))
+          .repartition(4)          // RoundRobinPartitioning -> shuffle #1
+          .dropDuplicates("key")   // HashPartitioning(key) -> shuffle #2
+          .select($"key")
+        testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+          AddData(inputData, ("a", 1), ("b", 1), ("c", 1), ("a", 2)),
+          StartStream(),
+          // StartStream only launches the query -- the stream runs on its own 
thread and can submit
+          // the first batch's jobs before the id is recorded here, in which 
case those jobs are not
+          // attributed and contribute nothing to maxConcurrentStages. So do 
not assert on the first
+          // batch: record the id, let batch 0 finish, then drive a SECOND 
batch that is guaranteed
+          // to run entirely after this point, and assert on that one.
+          Execute(q => queryId.set(q.id.toString)),
+          CheckAnswerWithTimeout(60000, "a", "b", "c"),
+          advanceRealTimeClock,
+          WaitUntilBatchProcessed(0),
+          AddData(inputData, ("d", 1), ("e", 1), ("d", 2)),
+          CheckAnswerWithTimeout(60000, "a", "b", "c", "d", "e"),
+          Execute { q =>
+            val exchanges = q.lastExecution.executedPlan.collect {
+              case s: ShuffleExchangeExec => s
+            }
+            assert(exchanges.size >= 2, s"expected >= 2 shuffle exchanges, got 
${exchanges.size}")
+            assert(exchanges.forall(_.pipelined),
+              "every exchange in the chain must be pipelined, got: " +
+                exchanges.map(_.pipelined).mkString(", "))
+            assert(maxConcurrentStages.get() >= 2,
+              s"a 2-shuffle chain must co-schedule >= 2 stages, saw 
${maxConcurrentStages.get()}")
+          },
+          StopStream
+        )
+      }
+    } finally {
+      spark.sparkContext.removeSparkListener(listener)
+    }
+  }
+
+  test("pipelined shuffle: a chain of two round-robin repartitions runs in 
Real-Time Mode") {
+    withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+      val inputData = LowLatencyMemoryStream[(String, Int)](2)
+      // Two round-robin repartitions with a map between them; consecutive 
repartitions collapse to
+      // the last one, so the map is what keeps both shuffles in the plan. 
Both are pipelined, and
+      // the second one's producer reads the first one's output -- an 
UNORDERED input. A round-robin
+      // shuffle is order-sensitive once the deterministic local sort is off, 
which would escalate
+      // that producer to INDETERMINATE and get the group rejected; a 
pipelined shuffle is exempt
+      // from the order-sensitive marking precisely so this shape runs.
+      val result = inputData.toDS()
+        .repartition(3)
+        .map(row => row)
+        .repartition(2)
+        .map(row => row)
+        .toDF().select($"_1".as("key"))
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        AddData(inputData, ("a", 1), ("b", 2), ("c", 3)),
+        StartStream(),
+        CheckAnswerWithTimeout(60000, "a", "b", "c"),
+        Execute { q =>
+          val exchanges = q.lastExecution.executedPlan.collect {
+            case s: ShuffleExchangeExec => s
+          }
+          assert(exchanges.size >= 2, s"expected >= 2 shuffle exchanges, got 
${exchanges.size}")
+          assert(exchanges.forall(_.pipelined),
+            "every exchange in the chain must be pipelined, got: " +
+              exchanges.map(_.pipelined).mkString(", "))
+        },
+        StopStream
+      )
+    }
+  }
+
+  test("pipelined shuffle: an explicit sortBeforeRepartition=true does not 
stall the query") {
+    // The deterministic local sort before a round-robin repartition never 
drains an unbounded
+    // stream, so Real-Time Mode overrides this config even when it is set 
explicitly -- honouring
+    // it would hang the query forever. See MicroBatchExecution.
+    withSQLConf(
+      SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+      SQLConf.SORT_BEFORE_REPARTITION.key -> "true") {
+      val inputData = LowLatencyMemoryStream[(String, Int)](2)
+      val result = inputData.toDF().select($"_1".as("key"))
+        .repartition(4)
+        .dropDuplicates("key")
+        .select($"key")
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        AddData(inputData, ("a", 1), ("b", 1), ("c", 1), ("a", 2)),
+        StartStream(),
+        CheckAnswerWithTimeout(60000, "a", "b", "c"),
+        StopStream
+      )
+    }
+  }
+
+  test("pipelined shuffle: dedup recovers from a task failure via checkpoint 
restart") {
+    withTempDir { checkpointDir =>
+      // A UDF that throws on demand, placed after the dedup so the failure 
lands in the pipelined
+      // consumer stage while the query runs. RTM does not retry tasks, so one 
failure fails it.
+      val failUDF = udf { (key: String) =>
+        if (StreamRealTimeModeSuite.failTasks) {
+          throw new RuntimeException(s"forced task failure on $key")
+        }
+        key
+      }
+      val inputData = LowLatencyMemoryStream[(String, Int)]
+      val result = 
inputData.toDF().select($"_1".as("key")).dropDuplicates("key")
+        .select(failUDF($"key").as("key"))
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        AddData(inputData, ("a", 1), ("b", 1), ("a", 2)),
+        StartStream(checkpointLocation = checkpointDir.getAbsolutePath),
+        CheckAnswerWithTimeout(60000, "a", "b"),
+        advanceRealTimeClock,
+        WaitUntilBatchProcessed(0),
+        Execute { _ => StreamRealTimeModeSuite.failTasks = true },
+        AddData(inputData, ("c", 1)),
+        advanceRealTimeClock,
+        ExpectFailure[SparkException] { ex =>
+          val msg = 
Option(ex.getCause).map(_.getMessage).getOrElse(ex.getMessage)
+          assert(msg != null && msg.contains("forced task failure"),
+            s"expected a forced task failure, got: $msg")
+        }
+      )
+      // Restart from the same checkpoint: batch-0 dedup state must survive 
(a, b not re-emitted),
+      // only the genuinely-new c, d appear -- recovery to the last committed 
batch.
+      StreamRealTimeModeSuite.failTasks = false
+      testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+        StartStream(checkpointLocation = checkpointDir.getAbsolutePath),
+        AddData(inputData, ("a", 3), ("b", 3), ("c", 3), ("d", 1)),
+        CheckAnswerWithTimeout(60000, "c", "d"),
+        advanceRealTimeClock,
+        StopStream
+      )
+    }
+  }
+
+  test("pipelined shuffle: dedup recovers from a commit-log write failure") {
+    withSQLConf(
+      SQLConf.STREAMING_CHECKPOINT_FILE_MANAGER_CLASS.parent.key ->
+        classOf[FailureInjectionCheckpointFileManager].getName) {
+      withTempDir { checkpointDir =>
+        val injectionState = 
FailureInjectionFileSystem.registerTempPath(checkpointDir.getPath)
+        try {
+          val inputData = LowLatencyMemoryStream[(String, Int)]
+          val result = 
inputData.toDF().select($"_1".as("key")).dropDuplicates("key").select($"key")
+          // Batch 0 dedups a, b and commits. Then fail the close() of batch 
1's commit-log write so
+          // batch 1 cannot commit and the query fails after processing.
+          injectionState.createAtomicDelayCloseRegex = Seq(".*/commits/1")
+          testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+            AddData(inputData, ("a", 1), ("b", 1)),
+            StartStream(checkpointLocation = checkpointDir.getAbsolutePath),
+            CheckAnswerWithTimeout(60000, "a", "b"),
+            advanceRealTimeClock,
+            WaitUntilBatchProcessed(0),
+            AddData(inputData, ("c", 1)),
+            CheckAnswerWithTimeout(60000, "a", "b", "c"),
+            advanceRealTimeClock,
+            ExpectFailure[IOException]()
+          )
+          // Clear injection, restart: batch-0 state survives (a, b seen); 
uncommitted batch 1
+          // re-runs so its new key c is still emitted, plus a further new key 
d.
+          injectionState.createAtomicDelayCloseRegex = Seq.empty
+          testStream(result, OutputMode.Update, Map.empty, new 
ContinuousMemorySink())(
+            StartStream(checkpointLocation = checkpointDir.getAbsolutePath),
+            AddData(inputData, ("a", 2), ("b", 2), ("c", 2), ("d", 1)),
+            CheckAnswerWithTimeout(60000, "c", "d"),
+            advanceRealTimeClock,
+            StopStream
+          )
+        } finally {
+          
FailureInjectionFileSystem.removePathFromTempToInjectionState(checkpointDir.getPath)
+        }
+      }
+    }
+  }
+}
+
+/** Driver-side switch a UDF reads on executors to fail a task on demand 
(fault-tolerance tests). */
+object StreamRealTimeModeSuite {
+  @volatile var failTasks: Boolean = false
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingJoinSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingJoinSuite.scala
index c46f0076721b..c0983f338abe 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingJoinSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingJoinSuite.scala
@@ -677,8 +677,8 @@ abstract class StreamingInnerJoinBase extends 
StreamingJoinSuite {
 
         assert(query.lastExecution.executedPlan.collect {
           case j @ StreamingSymmetricHashJoinExec(_, _, _, _, _, _, _, _, _,
-            ShuffleExchangeExec(opA: HashPartitioning, _, _, _),
-            ShuffleExchangeExec(opB: HashPartitioning, _, _, _), _)
+            ShuffleExchangeExec(opA: HashPartitioning, _, _, _, _),
+            ShuffleExchangeExec(opB: HashPartitioning, _, _, _, _), _)
               if partitionExpressionsColumns(opA.expressions) === Seq("a", "b")
                 && partitionExpressionsColumns(opB.expressions) === Seq("a", 
"b")
                 && opA.numPartitions == numPartitions && opB.numPartitions == 
numPartitions => j


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

Reply via email to