Skip to content

[WIP] Stack/pipelined shuffle pr8 rtm - #57692

Open
jerrypeng wants to merge 16 commits into
apache:masterfrom
jerrypeng:stack/pipelined-shuffle-pr8-rtm
Open

[WIP] Stack/pipelined shuffle pr8 rtm#57692
jerrypeng wants to merge 16 commits into
apache:masterfrom
jerrypeng:stack/pipelined-shuffle-pr8-rtm

Conversation

@jerrypeng

@jerrypeng jerrypeng commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR activates pipelined (streaming) shuffles for Real-Time Mode (RTM) stateful 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.

It is stacked on top of #57646 ("Fully decouple pipelined shuffles from the MapOutputTracker"); please review/merge that first. This PR's own diff is SQL/streaming only.

Concretely:

  • Build the dependency (ShuffleExchangeExec): a new pipelined: Boolean flag on ShuffleExchangeExec; 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 in an RTM plan (IncrementalExecution): a MarkPipelinedShuffleForRealTimeMode rule marks every ShuffleExchangeExec in a Real-Time Mode plan (detected structurally by a RealTimeStreamScanExec leaf) as pipelined. 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. The one unsupported shape (a pipelined shuffle read by more than one consumer -- fan-out) is rejected up front by the DAGScheduler; a reused shuffle requires referencing the same streaming source twice, which RTM already rejects (IDENTICAL_SOURCES_IN_UNION_NOT_SUPPORTED).
  • 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.

The net effect: a stateful streaming dedup 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 stateful 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. 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 stateful query that repartitions by key (e.g. streaming deduplication) is now admitted and runs over a pipelined shuffle in RTM, where it was previously rejected by the RTM operator allowlist. Batch and non-RTM streaming execution are unchanged (the marking rule is inert without a RealTimeStreamScanExec).

How was this patch tested?

  • RealTimeModePipelinedShuffleSuite (new): an end-to-end RTM stateful-dedup query over a pipelined shuffle produces correct results; and two fault-tolerance scenarios -- recovery from a task failure via checkpoint restart, and recovery from a commit-log write failure.
  • StreamRealTimeModeAllowlistSuite: updated for the new allowlist -- the generic operator-allowlist test drops ShuffleExchangeExec from the expected violations; the "repartition not allowed" test is removed (a single-shuffle repartition now runs in RTM, per SPARK-54237); the "stateful queries not allowed" test is narrowed to HashAggregateExec (still not allowlisted), verified against actual runtime behavior.
  • EnsureRequirementsSuite, PlannerSuite, DataFrameWindowFunctionsSuite, DatasetSuite, StreamingJoinSuite: updated for the ShuffleExchangeExec.pipelined field (defaulted false, so non-RTM plans are unchanged).

Was this patch authored or co-authored using generative AI tooling?

Co-authored with Claude Code (Opus 4.8)

jerrypeng added 16 commits July 30, 2026 16:10
…utput tracker

A pipelined shuffle streams records from a live producer stage to a co-scheduled
consumer stage, so the consumer must look up its producer tasks' locations while
both stages run. That routing lives in the StreamingShuffleOutputTracker, but
createShuffleMapStage only registered the shuffle with the MapOutputTracker (which,
for a transient pipelined shuffle, tracks an empty never-materialized output). As a
result a consumer task querying for writer locations found the shuffle unregistered
and failed.

Register a PipelinedShuffleDependency's shuffle with the StreamingShuffleOutputTracker
inside the same once-per-shuffleId guard as the MapOutputTracker registration. Inert
for a regular shuffle (and when no streaming tracker is configured).

Note: this is a latent gap in the co-scheduling layer independent of RTM; it belongs
with the pipelined-scheduling change earlier in the stack and should be re-homed there
(with a real-transport regression test) when the stack is next reorganized.

Co-authored-by: Isaac
… output tracker on cleanup

A pipelined shuffle is registered with the driver-only StreamingShuffleOutputTracker
in DAGScheduler.createShuffleMapStage, alongside its MapOutputTracker registration, but
nothing ever removed it. Over a long-running Real-Time Mode query -- which allocates a
fresh shuffle id per micro-batch -- the tracker's shuffleInfos map grows without bound,
and a re-registration of a reused id would hit the "registered twice" guard.

Unregister it in ContextCleaner.doCleanupShuffle, right beside the existing
mapOutputTrackerMaster.unregisterShuffle. The streaming tracker's state is driver-only
(the worker tracker caches nothing and just RPCs the master), so a direct driver-side
call is the correct counterpart -- unlike the MapOutputTracker, there is no per-executor
cache to invalidate, so this does not belong in the RemoveShuffle RPC handler. The call
is guarded by a new containsShuffle predicate so regular (non-pipelined) shuffles, which
are never registered here, are skipped without emitting a spurious "not registered"
warning on every shuffle cleanup.

Also correct the StreamingShuffleManager.unregisterShuffle comment, which previously
claimed the tracker was unregistered in BlockManagerStorageEndpoint's RemoveShuffle
handler -- untrue in OSS (that path routes to this no-op manager method).

Tests: a containsShuffle unit test, plus two ContextCleanerSuite integration tests (a
pipelined shuffle is unregistered on cleanup; a regular shuffle is left untouched and
quiet). Both integration tests were revert-checked -- each fails when its respective
production change (the unregister call, or the containsShuffle guard) is removed.

Co-authored-by: Isaac
…putTracker

A pipelined shuffle produces no durable, addressable map output: its
producer/consumer task-location routing lives entirely in the
StreamingShuffleOutputTracker, its availability is tracked on the stage
(pipelinedCompletedPartitions), and its reader locates writers through the
streaming tracker. Yet createShuffleMapStage still registered an empty
ShuffleStatus for it in the MapOutputTracker, purely to keep containsShuffle /
unregisterShuffle bookkeeping uniform. That coupled the streaming tracker's
lifecycle to MapOutputTracker state: registration and cleanup of the streaming
entry were both nested inside MapOutputTracker.containsShuffle guards, so a
future divergence between the two trackers could double-register (throwing
"registered twice") or leak the streaming entry.

Split the two trackers cleanly by dependency type, with no overlap:

- createShuffleMapStage: a PipelinedShuffleDependency registers ONLY with the
  StreamingShuffleOutputTracker (self-guarded on that tracker's own
  containsShuffle); a regular shuffle registers ONLY with the MapOutputTracker,
  exactly as before. No empty MapOutputTracker shell is created for a pipelined
  shuffle.

- ContextCleaner.doCleanupShuffle: two independent branches, each keyed on its
  own tracker's membership -- MapOutputTracker cleanup for a regular shuffle,
  a direct driver-side StreamingShuffleOutputTracker unregister for a pipelined
  one (no RemoveShuffle RPC: a streaming shuffle has no block-manager-served
  files). Neither branch depends on the other tracker's state.

This is safe because every MapOutputTracker path a pipelined shuffle could
reach is either already type-gated out (output registration, availability,
FetchFailed invalidation which routes to a group-atomic abort, push-merge,
indeterminate/checksum rollback, map-stage-job stats) or tolerant of the
entry's absence (executor/host-loss strips iterate shuffleStatuses and simply
skip it). Consumer task locality never reads the tracker for a shuffle
dependency (getPreferredLocsInternal recurses only through narrow deps).

Tests (ContextCleanerSuite): a pipelined shuffle registered ONLY in the
streaming tracker is still cleaned up by doCleanupShuffle even though it is
absent from the MapOutputTracker (revert-checked: fails without the streaming
cleanup branch); a regular shuffle takes the MapOutputTracker branch and never
touches the streaming tracker.

Co-authored-by: Isaac
… (before any state mutation) with no streaming tracker, and abort the group on a base-path pipelined FetchFailed

Three hardening fixes to "Fully separate pipelined shuffles from the
MapOutputTracker", all found by review of that change:

1. createShuffleMapStage registered a pipelined dependency only inside a
   streamingShuffleOutputTracker.foreach, with the MapOutputTracker registration
   in the else-if branch. If the streaming tracker were absent (a non-streaming
   custom incremental manager), the shuffle was registered in NEITHER tracker --
   a consumer would then fail with an opaque NullPointerException in the shuffle
   writer. Fail loud instead: a PipelinedShuffleDependency requires a
   StreamingShuffleOutputTracker. Crucially, resolve the tracker UP FRONT --
   before createShuffleMapStage mutates stageIdToStage / shuffleIdToMapStage /
   updateJobIdStageIdMaps -- so the fail-loud throw leaves no partial scheduler
   state. (handleJobSubmitted's catch does no rollback, so a throw after those
   mutations would leak a half-created stage; a re-submission would then reuse the
   stale cached stage and fail with a misleading cross-job-reuse error instead of
   re-throwing the real misconfiguration.)

2. A pipelined-group FetchFailed is normally intercepted by the group-atomic abort
   branch, whose guard is keyed on the failed stage's active job carrying
   hasPipelinedDependency. If a pipelined-shuffle FetchFailed reaches the base
   (non-group-atomic) path instead, that path is invalid for a pipelined shuffle
   in two ways: (a) its MapOutputTracker invalidation would throw
   ShuffleStatusNotFoundException (a pipelined shuffle is no longer registered
   there); and (b) its resubmit branch would enqueue a lone-stage resubmit of the
   transient producer, which cannot be re-read and would deadlock the group. Guard
   the whole base-path tail on mapStage.isPipelined and abort the group instead --
   matching the group-atomic branch's outcome.

Tests (revert-checked):
- PipelinedShuffleRoutingSuite: a job with a pipelined dependency and no streaming
  tracker fails loud with the IllegalStateException, and a re-submission re-throws
  the SAME error -- proving no partial stage leaked (revert-checked against the
  throw-after-mutation placement, which instead leaks and yields a cross-job-reuse
  error on re-submit).
- DAGSchedulerSuite: a FetchFailed for a pipelined shuffle reaching the base path
  (group-atomic guard forced to miss) aborts the group -- the job fails, the
  throwing MapOutputTracker calls are never invoked, and the transient producer is
  not resubmitted (revert-checked against both the no-guard and the
  invalidation-only-guard variants).

Co-authored-by: Isaac
…ale availability comment

- Reflow four 101-char comment lines in the FetchFailed group-abort branch and
  the unregisterOutputsOnFetchFailedExecutor scaladoc to <=100.
- Correct ShuffleMapStage.numAvailableOutputs: a pipelined shuffle is not
  registered with the MapOutputTracker at all (it was leftover "registered but
  empty" wording that contradicted this file's own scaladoc).

Comment-only; no behavior change.

Co-authored-by: Isaac


cloud-fan's nits on the merged predecessor PR, applied here per his request to
address them in the follow-up:
- DAGScheduler: reword the push-based-shuffle-merge rejection comment
  ("as the" -> "on a" pipelined shuffle) and the slot-check-disabled warning
  comment ("Keeps" -> "Limits the warning to once").
- TaskSetManagerSuite: the pipelined-decommission test comment said the shuffle
  is "NEVER registered in the MapOutputTracker". With the MapOutputTracker now
  fully decoupled that is literally true, but state it precisely: registered
  only with the StreamingShuffleOutputTracker, never the MapOutputTracker.

Comment-only; no behavior change.

Co-authored-by: Isaac
…ype via a common interface

Introduce a small ShuffleOutputTrackerMaster trait (registerShuffle / containsShuffle /
unregisterShuffle) implemented by both MapOutputTrackerMaster and
StreamingShuffleOutputTrackerMaster. DAGScheduler.createShuffleMapStage now resolves the owning
tracker by dependency type through a private outputTrackerMaster(shuffleDep) helper (which isolates
the fail-loud-if-no-streaming-tracker check) and registers through the common interface, replacing
the awkward Option[StreamingShuffleOutputTrackerMaster] + isInstanceOf/asInstanceOf and the
type-specific registration branches. Behavior is unchanged.

Co-authored-by: Isaac
…anup

Close two gaps in the pipelined-shuffle leak coverage:
- ContextCleanerSuite: a GC-triggered end-to-end cleanup test. A pipelined shuffle's only cleanup
  channel is the ContextCleaner weak-reference path fired when its PipelinedShuffleDependency is
  garbage-collected; the existing tests call doCleanupShuffle directly. The new test builds a real
  dependency, asserts cleanup does NOT fire while it is strongly referenced, then dereferences it
  and asserts GC drives the streaming-tracker unregister.
- DAGSchedulerSuite: a fan-in (two-producer) deferral leak test that drives a producer failure to
  full job teardown and asserts all scheduler state (including the two-parent deferral) is emptied.

Co-authored-by: Isaac
… a pipelined shuffle on cleanup

The ShuffleDependency constructor calls shuffleDriverComponents.registerShuffle for EVERY shuffle,
including a pipelined one, but the pipelined cleanup branch in ContextCleaner.doCleanupShuffle only
unregistered from the StreamingShuffleOutputTracker -- it skipped the matching removeShuffle. Benign
for the default (no-op registerShuffle) impl, but a custom ShuffleDriverComponents tracking
per-shuffle driver state would leak it across micro-batches. Call removeShuffle in the pipelined
branch too, matching the regular branch's ordering. (Found by Isaac review.)

Co-authored-by: Isaac
…dependency

Add a `pipelined` flag to ShuffleExchangeExec. When set, prepareShuffleDependency
constructs a PipelinedShuffleDependency instead of a regular ShuffleDependency, so
the DAGScheduler co-schedules the exchange's producer and consumer stages and routes
the shuffle to the streaming shuffle manager. The flag defaults to false, so every
existing shuffle is unchanged.

Modeling this as a node field (rather than out-of-band metadata) keeps the
pipelined/non-pipelined distinction part of the exchange's identity, so it
participates in exchange-reuse and canonicalization -- correct for a transient
shuffle that must not be reused as a durable one.

Adding the field shifts ShuffleExchangeExec's positional arity, so update the
pattern matches that destructure it positionally (EnsureRequirements, AQEUtils,
InsertSortForLimitAndOffset and several test suites) to account for the new field;
EnsureRequirements' repartition rewrite now uses copy() so the flag is preserved.

Co-authored-by: Isaac
…d shuffle

Enable a stateful streaming query (starting with deduplication) to run in Real-Time
Mode by pipelining its repartition shuffle:

- IncrementalExecution adds a Real-Time Mode preparation rule that marks every
  streaming shuffle exchange pipelined (ShuffleExchangeExec.pipelined = true). Real-Time
  Mode is detected structurally by a RealTimeStreamScanExec leaf. The producer (source
  scan) and consumer (stateful operator) stages are then co-scheduled and stream records
  through a transient shuffle instead of the consumer waiting for the producer to fully
  materialize. Inert for a non-RTM batch, so the ordinary microbatch path is unchanged.
  (AQE is already disabled for RTM batches upstream, so the pipelined shuffle is only ever
  submitted as a result job, never an AQE map-stage job.)

- RealTimeModeAllowlist admits the operators a streaming dropDuplicates plans into
  (StreamingDeduplicateExec, StateStoreRestoreExec, StateStoreSaveExec) plus
  ShuffleExchangeExec, so the query is no longer rejected before it runs.

Co-authored-by: Isaac
…e over a pipelined shuffle

Add an end-to-end test: a streaming dropDuplicates query in Real-Time Mode whose
repartition shuffle is pipelined. It asserts correct dedup output across two batches,
that every shuffle exchange in the executed plan is pipelined, and that the producer
and consumer stages are co-scheduled (>= 2 stages running at once). The Real-Time Mode
operator allowlist check is left enabled, so the test also covers admission of the
dedup plan's operators.

Co-authored-by: Isaac
… over a pipelined shuffle

Add a fault-tolerance case to the pipelined-shuffle Real-Time Mode suite: a stateful
dropDuplicates query whose repartition shuffle is pipelined, that hits a task failure
mid-batch and then restarts from checkpoint. It asserts the query fails on the task
error (Real-Time Mode does not retry tasks; a member failure aborts the whole
co-scheduled group), and that on restart the deduplication state recovered from the
last committed batch -- already-seen keys are not re-emitted.

Switches the suite to the manual-clock base so batch boundaries are deterministic
across the failure and restart, and uses CheckAnswerWithTimeout (which polls the sink
in real time) with advanceRealTimeClock to drive Real-Time Mode batches.

Co-authored-by: Isaac
… in Real-Time Mode

Add a second fault-tolerance case: a dedup query over a pipelined shuffle whose commit
log write is fault-injected (via FailureInjectionCheckpointFileManager) so a batch
cannot commit and the query fails. On restart from the same checkpoint, the committed
batch's deduplication state survives and the uncommitted batch is re-run, so its data
is still emitted.

The injected close() failure surfaces as the underlying IOException in this path (the
COMMIT_LOG_WRITE_FAILURE error-class wrapping is not open source), so the test expects
that.

Co-authored-by: Isaac
MarkPipelinedShuffleForRealTimeMode marks every shuffle exchange in an RTM
plan as pipelined, so a chain of several pipelined shuffles (e.g. two
repartitions, or a repartition feeding a keyed stateful operator) is
co-scheduled as one pipelined group. The one unsupported shape -- a pipelined
shuffle with more than one consumer (fan-out, e.g. a reused exchange) -- is
rejected up front by the DAGScheduler, so no ReusedExchangeExec reasoning is
needed here.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant