Skip to content

[!DO NOT MERGE!][DOC] Pipelined shuffle dependency & concurrent stage scheduling spec discussion#57092

Open
jerrypeng wants to merge 8 commits into
apache:masterfrom
jerrypeng:pipelined-shuffle-spec
Open

[!DO NOT MERGE!][DOC] Pipelined shuffle dependency & concurrent stage scheduling spec discussion#57092
jerrypeng wants to merge 8 commits into
apache:masterfrom
jerrypeng:pipelined-shuffle-spec

Conversation

@jerrypeng

@jerrypeng jerrypeng commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

PR created only to facilitate the discussion of introducing pipelined shuffle dependency & concurrent stage scheduling capabilities into the DAGScheduler that started here:

#56055

…heduling spec

Baseline of the design spec for native concurrent stage scheduling in the
DAGScheduler, as reviewed on PR apache#56055. Subsequent commits layer
reviewer feedback one point at a time for a clean per-round diff.

Co-authored-by: Isaac
@jerrypeng

Copy link
Copy Markdown
Contributor Author

Reposting @cloud-fan feedback from #56055 (comment)

The spec is the right shape and answers the direction well — PSD as a binding contract (§2.1), the group as the failure/admission unit (§2.2), and activation by membership (§8, which removes the prototype's half-activated state). A few gaps before it's the implementation contract:

  1. Cross-group scheduling / starvation (§4) — @mridulm's (b), and the weakest part. §4 defines gang admission for one group but not how multiple groups (or a group vs. regular jobs) arbitrate — and a pipelined group holds its slots for the whole batch. Is admission FIFO? Can a large group be starved by a stream of small ones? When an admitted group has pinned its slots, does the next one wait or fail-fast (which means a second streaming query can't start on a busy cluster)? Are running groups' slots subtracted from "available" for the next check?

  2. "Available slots" needs a precise definition (§4). The prototype used defaultParallelism(), which returns spark.default.parallelism, not free slots — the barrier precedent uses sc.maxNumConcurrentTasks(rp) (checkBarrierStageWithNumSlots), which is the number to reuse. Also, sum(numTasks) ignores resource profiles: either state a group is single-resource-profile (and reject otherwise) or define per-profile accounting.

  3. Output-commit vs. group-atomic failure (§5). §5 runs output-commit immediately while deferring stage/job finish, so a result stage → task commits → sibling fails in the replay window → group reruns → double commit. RTM's sink is idempotent so RTM is safe, but as a generic primitive this needs either output-commit to also defer, or a stated requirement that in-group result-stage side effects be idempotent.

  4. Drop the fetch-failure framing in §6 — state it as a mechanism. Internal edges are streaming shuffle, so the base FetchFailed-means-resubmit concept doesn't apply inside a group; enumerating it implies a mechanism that doesn't exist. Better: any member task failure, for any reason, fails the group; the base single-stage resubmit path is disabled for members (that clause is what makes "all errors fail the group" actually true). Then note under §7 that a lost external durable input reruns the group rather than being recomputed in isolation — correct but coarser than base Spark, worth flagging as an intentional v1 simplification.

  5. Cross-job stage reuse (§3/§4). Each micro-batch is a new job, but the base scheduler reuses shuffle-map stages via shuffleIdToMapStage. What forces a pipelined edge to create a fresh stage rather than binding batch N to batch N-1's cached stage?

Minor: (a) fail-fast admission → for streaming, crash-loops on restart; the barrier path retries BarrierJobSlotsNumberCheckFailed N times — say whether admission is retried or terminal. (b) "pipelined" over StreamingShuffleDependency is the better name (not streaming-specific) — worth a sentence on why.

@jerrypeng

jerrypeng commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Reposting @tgravescs feedback from: #56055 (comment)

Overall I really like the new idea of having a Pipelined Shuffle Dependency and support it directly in DagScheduler and define the contract. This opens it up to be generally useful in other scenarios. I'm still going through some of the details but here are a few initial comments

I think we need to clarify some of the gang scheduling wording.

A group is admitted only if the cluster can currently run all tasks of all member stages concurrently;

This is a group with multiple pipelined stages (pipelined group), not normal group with a single stage (ie how it runs today) Or is this specifically specified somewhere that we need to do Gang Scheduling?

I think this opens up some possibilities in the scheduler but I'm not sure it also addresses being able to have multiple shuffle managers or multiple ways to do the shuffle. If you are mixing groups where one is regular and the other is a pipelined group, within the pipelined group you would want to use the StreamingShuffleManager but in the regular group you would use the regular shuffle manager. I think you can make a MultiShuffleManager like is in the streaming shuffle proposal but I think within that it needs to be able to dynamically change per group. It seems like the ShuffleDependency should have a way to indicate what type of shuffle is required. This might be getting more in the future but thinking about it here if we are defining contracts and new ShuffleDependencies.

…mit, failure framing

Layer the first round of reviewer feedback on PR apache#56055 onto the
baseline spec:

- Cross-group admission and starvation (new 4.1): capacity measured against free
  slots; a group that does not fit fails admission rather than queuing (no FIFO,
  no partial reservation); retry is the caller's decision, not the scheduler's.
- Slot check: reuse maxNumConcurrentTasks (not defaultParallelism); require a
  single resource profile per group, reject mixed (fail-fast).
- Completion: in-group result-stage side effects must be idempotent (the
  streaming at-least-once model); deferring commit noted as a future augmentation.
- Failure framing: state it as a mechanism -- single-stage resubmit is disabled
  for group members, so any member failure fails the group -- rather than
  enumerating a FetchFailed path that does not exist on a streaming edge; note the
  coarser-grained recovery vs. base Spark.
- Naming note: "pipelined" is deliberate, not "streaming"; the capability is
  general and streaming is only the first caller.
@jerrypeng jerrypeng force-pushed the pipelined-shuffle-spec branch from a2c71eb to 177a57e Compare July 7, 2026 23:16
@jerrypeng

Copy link
Copy Markdown
Contributor Author

@cloud-fan thank you for your detail feedback. I will address them inline.

  1. Cross-group scheduling / starvation (§4) — @mridulm's (b), and the weakest part. §4 defines gang admission for one group but not how multiple groups (or a group vs. regular jobs) arbitrate — and a pipelined group holds its slots for the whole batch. Is admission FIFO? Can a large group be starved by a stream of small ones? When an admitted group has pinned its slots, does the next one wait or fail-fast (which means a second streaming query can't start on a busy cluster)? Are running groups' slots subtracted from "available" for the next check?

Capacity is measured against free slots (running groups' and regular jobs' tasks subtracted from maxNumConcurrentTasks). A group that doesn't fit fails its admission attempt rather than queuing — no waiting queue, no partial reservation, so it matches barrier and can't deadlock on slots a sibling holds.

I think this is good enough for now. In the future, we can consider if we want to augment the spec with wait / queuing capabilities

"Available slots" needs a precise definition (§4). The prototype used defaultParallelism(), which returns spark.default.parallelism, not free slots — the barrier precedent uses sc.maxNumConcurrentTasks(rp) (checkBarrierStageWithNumSlots), which is the number to reuse. Also, sum(numTasks) ignores resource profiles: either state a group is single-resource-profile (and reject otherwise) or define per-profile accounting.

Switched the definition to the cluster's concurrent-task capacity — the value barrier's slot check uses (sc.maxNumConcurrentTasks(rp)) — explicitly not defaultParallelism(). On resource profiles: v1 requires a group to be single-profile and fail-fast rejects a mixed-profile group; per-profile accounting is a documented follow-up (the RTM shapes are single-profile).

Output-commit vs. group-atomic failure (§5). §5 runs output-commit immediately while deferring stage/job finish, so a result stage → task commits → sibling fails in the replay window → group reruns → double commit. RTM's sink is idempotent so RTM is safe, but as a generic primitive this needs either output-commit to also defer, or a stated requirement that in-group result-stage side effects be idempotent.

I will clarify this. The current proposal simply requires an in-group result stage's side effects to be idempotent — the standard streaming model, where a batch is re-delivered on recovery and the sink absorbs it; a group rerun is the same re-delivery. Deferring the commit to group completion is noted as a future augmentation if a non-idempotent committer ever needs support.

Drop the fetch-failure framing in §6 — state it as a mechanism. Internal edges are streaming shuffle, so the base FetchFailed-means-resubmit concept doesn't apply inside a group; enumerating it implies a mechanism that doesn't exist. Better: any member task failure, for any reason, fails the group; the base single-stage resubmit path is disabled for members (that clause is what makes "all errors fail the group" actually true). Then note under §7 that a lost external durable input reruns the group rather than being recomputed in isolation — correct but coarser than base Spark, worth flagging as an intentional v1 simplification.

I will clarify / refine this a bit more

Cross-job stage reuse (§3/§4). Each micro-batch is a new job, but the base scheduler reuses shuffle-map stages via shuffleIdToMapStage. What forces a pipelined edge to create a fresh stage rather than binding batch N to batch N-1's cached stage?

This can't happen across micro-batches: each micro-batch is a new job built from a fresh query plan (a new IncrementalExecution), whose exchanges mint new ShuffleDependency objects with new shuffleIds. Since shuffleIdToMapStage reuse is keyed by shuffleId, a new batch never matches — and so never binds to — a prior batch's stage. It's a consequence of existing Spark behavior rather than something the spec needs to add.

Minor: (a) fail-fast admission → for streaming, crash-loops on restart; the barrier path retries BarrierJobSlotsNumberCheckFailed N times — say whether admission is retried or terminal. (b) "pipelined" over StreamingShuffleDependency is the better name (not streaming-specific) — worth a sentence on why.

Clarified in the doc.

@jerrypeng

Copy link
Copy Markdown
Contributor Author

@tgravescs thank you for your detail feedback. I will address them inline.

This is a group with multiple pipelined stages (pipelined group), not normal group with a single stage (ie how it runs today) Or is this specifically specified somewhere that we need to do Gang Scheduling?

Yes let me clarify that point. Gang admission applies only to a pipelined (multi-stage) group, not to a normal single-stage stage. For a normal group the behavior is the same as today.

I think this opens up some possibilities in the scheduler but I'm not sure it also addresses being able to have multiple shuffle managers or multiple ways to do the shuffle. If you are mixing groups where one is regular and the other is a pipelined group, within the pipelined group you would want to use the StreamingShuffleManager but in the regular group you would use the regular shuffle manager. I think you can make a MultiShuffleManager like is in the streaming shuffle proposal but I think within that it needs to be able to dynamically change per group. It seems like the ShuffleDependency should have a way to indicate what type of shuffle is required. This might be getting more in the future but thinking about it here if we are defining contracts and new ShuffleDependencies.

Great question! I did mention this in the original design sketch for this here:

#56055 (comment)

"""
Pluggable shuffle implementation via config. The engine maps the capability to a concrete ShuffleManager. Today we dispatch between sort and streaming managers on a per-job property; the cleaner version selects per-dependency from incrementalHint, with the implementations configured:

  spark.shuffle.manager             = org.apache.spark.shuffle.sort.SortShuffleManager
  spark.shuffle.manager.incremental = org.apache.spark.shuffle.streaming.StreamingShuffleManager

A shuffle with incrementalHint = true is served by the configured incremental manager; everything else by the default — keeping the scheduler construct generic while the shuffle implementation stays pluggable.

"""

… scoping, per-dependency shuffle manager

- Gang admission (4): scope the all-or-nothing slot requirement to a pipelined
  group explicitly; add a sub-bullet stating a non-pipelined (singleton) group is
  unaffected and runs as a normal stage does today. Clarifies that gang scheduling
  applies only to a group of two or more stages connected by pipelined edges.
- Pipelined shuffle dependency (2.1): note the property is also the per-dependency
  selector for the shuffle implementation -- the shuffle layer maps a pipelined
  dependency to an incremental ShuffleManager and everything else to the default,
  so a mixed job routes each group to the right implementation per-dependency, not
  per-job. Add a config example (spark.shuffle.manager.incremental).
- Minor: drop the "distinguished in diagnostics" clause from the two-failure-reasons
  bullet (4.1).

Co-authored-by: Isaac
@jerrypeng

Copy link
Copy Markdown
Contributor Author

@cloud-fan @tgravescs @mridulm @viirya lets use this PR to discuss and iterate on the concurrent scheduling spec. thank you!

@mridulm

mridulm commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for the details @jerrypeng !
I am traveling/on vacation, so some quick thoughts/queries.

@cloud-fan and @tgravescs have excellent queries which covers a lot more than what I would have asked !
A few quick notes:

  • Can we call out that in a DAG, a PG can have multiple "output" (regular shuffle) when ResultStage is not part of it ?
    • Also, just as with barrier stage - a submitted DAG could have multiple pipelined group within it - with these PG's potentially running concurrently.
    • If this is not the case (rest of my comment assumes it is), why not ? I would look at this similar to barrier stages in a DAG
  • In general, shuffle is one to many (1 producer, many consumers) - are we restricting/enforcing it to 1:1 ? If no - what is the behavior if/when it is used across jobs (DAGs) ?
    • "Single ownership." section has a pipelined producer is not shared across jobs - how do we enforce this ?
  • Teardown is by group membership, not producer availability.
    • We do track shuffle loss when there is executor loss - should be possible to plumb that into PG as well.
    • Btw, this might be more strict than it needs to be ?
      • For example, if all output from a producer has been consumed successfully, and then the executor/node goes down -> does not actually impact the DAG ? would fetch failure be a better way to identify this failure mode ?
      • It is fine to start with a stronger formulation for simplicity btw !
  • "Regular shuffle internal to a group — fail-fast / unsupported." -> I did not understand why this needs to fail.
    • we would simply split the DAG into muliple PG ?
  • Section 9: "Push-based shuffle merge" -> can you clarify this why this cant be supported for input and/or output ?
    • Note - within the PG, this does not make sense anyway (would have resulted in splitting it).
    • Or do we mean push based shuffle cant be used for streaming shuffle (like regular shuffle cant be and only streaming shuffle can be ?) ? If yes, agree - it currently cant be.
  • Section 9 "Statically-indeterminate producer" and "Checksum-mismatch" -> I did not understand this : a child of PG could have failed when reading PG output, and so requires PG reexecution - and so might be impacted by (INDETERMINATE) parent.
    • Whether parent is indeterminate or not, the behavior would be same though - agree if that is what was meant by moot here.
  • "Cached/persisted RDD in a member's within-stage chain" -> how we do enforce this ?

Also ... from responses above:

A group that doesn't fit fails its admission attempt rather than queuing — no waiting queue, no partial reservation, so it matches barrier and can't deadlock on slots a sibling holds.

Just because at submission time there were insufficient resources to run it, does not mean that will continue to be the case. See existing barrier mode for insights.
I believe this is what Wenchen is also driving at as well.

Slot check

nit: This needs to factor in the stage requirements (resource profile/cores per task) * num partitions, across all stages in the group.

Output-commit

I am concerned about the formulation - commit handling tends to be tricky.

For the case when ResultStage is part of PG : why cant we not have similar behavior as what currently exists ?
(I am assuming this only applies when result stage is part of the PG - not side channel writes)

This can't happen across micro-batches: each micro-batch is a new job built from a fresh query plan (a new IncrementalExecution), whose exchanges mint new ShuffleDependency objects with new shuffleIds.

These are implementation details of a specific usecase.
From scheduler perspective, you can have reuse - unless we explicitly prevent/enforce it.

@cloud-fan cloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 blocking, 1 non-blocking, 0 nits.
The spec faithfully formalizes the model this thread converged on, and its claims about the reused machinery are accurate. Two issues, both in the load-bearing §4 / §4.1 admission definition.

Correctness (2)

  • PIPELINED_SHUFFLE_DEPENDENCY_SPEC.md:95 (blocking): §4 and §4.1 give two different definitions of "available slots" (total vs. free), and sc.maxNumConcurrentTasks returns total, not free — see inline
  • PIPELINED_SHUFFLE_DEPENDENCY_SPEC.md:126 (non-blocking): "matching barrier, which also fails its slot check rather than queuing" is inaccurate — barrier auto-retries N times before failing — see inline

Verification

Checked both against the tree. sc.maxNumConcurrentTasks(rp) -> CoarseGrainedSchedulerBackend.maxNumConcurrentTasks sums executor.totalCores over active executors with no subtraction of running tasks, so it is total capacity; barrier's checkBarrierStageWithNumSlots compares demand to that total. That confirms F2: §4.1's free-slots framing does not follow from the primitive §4 names. For F1, DAGScheduler's BarrierJobSlotsNumberCheckFailed handler re-posts JobSubmitted up to spark.scheduler.barrier.maxConcurrentTasksCheck.maxFailures (default 40) times before failing the job.

Comment thread PIPELINED_SHUFFLE_DEPENDENCY_SPEC.md Outdated
edges.
- **Slot check.** The group's aggregate concurrent-task demand — the sum of `numTasks` over member
stages — is compared against the number of available slots in the cluster (a slot is one task's
worth of capacity, so this is the maximum number of tasks that can run at once). If the group

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

§4 and §4.1 define "available slots" two different ways, and the primitive named here delivers §4's, not §4.1's.

  • §4 (this line): demand is compared against "the maximum number of tasks that can run at once", reusing sc.maxNumConcurrentTasks — that is total capacity.
  • §4.1: "Capacity is free slots, not total ... counts only slots not occupied by running tasks ... minus the tasks currently running" — that is free capacity.

sc.maxNumConcurrentTasks(rp) resolves to CoarseGrainedSchedulerBackend.maxNumConcurrentTasks, which sums executor.totalCores over active executors (via calculateAvailableSlots) and does not subtract running tasks. So it is total, and barrier's checkBarrierStageWithNumSlots compares demand against that total.

That makes §4.1's cross-group deadlock-freedom argument (admit only if the group fits in currently-free slots) not follow from what §4 specifies: an implementer who "reuses the barrier slot check" as §4 says gets total-capacity admission, under which two groups can each pass the check yet not co-fit — the partial-co-residency gang admission is meant to forbid.

Suggest defining free capacity explicitly as maxNumConcurrentTasks(rp) minus the tasks currently running under rp (a new computation, not barrier's check verbatim), and reconciling §4's "maximum number of tasks that can run at once" wording with §4.1's "minus the tasks currently running" so the two sections agree on which value the slot check uses.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I misunderstood your original comment about how maxNumConcurrentTasks works. I will correct this.

Comment thread PIPELINED_SHUFFLE_DEPENDENCY_SPEC.md Outdated
currently running.
- **No waiting queue, no partial reservation.** A group that doesn't fit fails its admission; it never
sits in a queue holding slots incrementally. This keeps the scheduler change minimal and cannot
deadlock (a group never occupies slots a sibling is blocked on), matching barrier, which also fails

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"matching barrier, which also fails its slot check rather than queuing" isn't accurate for barrier. On BarrierJobSlotsNumberCheckFailed, DAGScheduler re-posts JobSubmitted and retries the slot check up to spark.scheduler.barrier.maxConcurrentTasksCheck.maxFailures (default 40) times before failing the job — it does not simply fail rather than queue.

The fail-fast, no-scheduler-retry choice for a pipelined group is a fine v1 decision; it's just the "matches barrier" justification that's wrong (and it's the opposite of the barrier-retry point already raised in this thread). Suggest either dropping the comparison, or stating the contrast: unlike barrier, which auto-retries the slot check N times, a pipelined group's admission is terminal and retry is delegated to the caller's batch loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will update

@jerrypeng

Copy link
Copy Markdown
Contributor Author

@cloud-fan thank you for your review and feedback! I have addressed your comments. PTAL.

@jerrypeng

Copy link
Copy Markdown
Contributor Author

@mridulm thank you for taking the time to review this even when you are on vacation! Let me respond to your comments in line:

Can we call out that in a DAG, a PG can have multiple "output" (regular shuffle) when ResultStage is not part of it ?

Yup, this is and should be supported, will clarify.

Also, just as with barrier stage - a submitted DAG could have multiple pipelined group within it - with these PG's potentially running concurrently.

Yup, will explicitly mention.

In general, shuffle is one to many (1 producer, many consumers) - are we restricting/enforcing it to 1:1 ? If no - what is the behavior if/when it is used across jobs (DAGs) ?

Short answer, Yes.

You're right that shuffle is 1:many in general. Worth separating where that fan-out actually comes from: a pipelined edge only ever originates from the physical-planning rule marking an exchange, and one ShuffleDependency gets more than one consumer only via exchange/stage reuse — ReuseExchangeAndSubquery (self-join, shared subquery/CTE, self-union) or submitMapStage. Branching alone doesn't do it: without the reuse rule, two identical subtrees plan to two separate ShuffleExchangeExec instances, each with its own shuffleDependency (it's a lazy val per node), i.e. two independent 1:1 shuffles. So "fan-out of a pipelined edge" ≡ "a pipelined dependency got reused/shared."

That makes fan-out fundamentally incompatible with a transient incremental shuffle, not just unimplemented: reuse relies on a durable, independently-fetchable materialized output that N consumers each read on their own schedule. A transient shuffle is a live, once-through stream from a still-running producer — there's no stored copy to hand to a second consumer, and concurrent readers would need fan-out-aware backpressure that doesn't exist. Fan-out becomes well-defined only over a persistent/replayable medium (e.g. a Kafka-backed shuffle, where consumers replay from offsets) — which is the persistentHint axis the spec already lists as a non-goal and defers. So 1:many isn't off the table forever; it's gated on that persistent-shuffle capability, which isn't on the table now.

On how we do it? Single-ownership and 1:1 are the same mechanism — a pipelined ShuffleDependency is never reused or shared. A group is rejected fail-fast if a pipelined producer has more than one consuming stage or its stage carries more than one jobId. That single rule delivers both no-fan-out and no-cross-job-sharing. This also answers your
later point that, from the scheduler's perspective, reuse can happen unless explicitly prevented — agreed, so we prevent it explicitly rather than relying on a caller (e.g. a new micro-batch minting fresh shuffleIds) to avoid it incidentally.

Teardown is by group membership, not producer availability.
We do track shuffle loss when there is executor loss - should be possible to plumb that into PG as well.
Btw, this might be more strict than it needs to be ?
For example, if all output from a producer has been consumed successfully, and then the executor/node goes down -> does not actually impact the DAG ? would fetch failure be a better way to identify this failure mode ?
It is fine to start with a stronger formulation for simplicity btw !

What you called out make sense, though I think it is an optimization we may not need to do for v1. The fail fast mechanism should be good enough for now.

In regards to using FetchFailed errors to signal this scenario. The streaming reader should reuse the FetchFailed channel as the signal to the scheduler, rather than the scheduler inferring failure from executor loss. Executor loss is the over-eager signal — it fires even when the producer had finished and its output was fully consumed, so it can't tell a harmful loss from a harmless one. The consumer can: it's the one reading, so it knows whether a producer's disappearance actually cost it data. Having the reader raise FetchFailed when (and only when) a read genuinely fails makes the consumer's real experience the trigger — a post-consumption producer loss produces no fetch failure and no teardown, which is exactly the precision you're after.

The one adaptation: a FetchFailed on a pipelined edge must route to group failure, not the base single-stage mapper-resubmit (which is disabled for group members, and which is itself the source of the resubmit-then-hang problem). So we reuse the FetchFailed signal and its plumbing, but the handler recognizes the pipelined edge and reruns the group rather than recomputing the one mapper. Recovery stays group-atomic (the transient edge can't be re-fetched); only the detection moves from "driver infers from executor loss" to "consumer reports via FetchFailed."

I will add a section in the spec to describe this as an optimization post-v1

"Regular shuffle internal to a group — fail-fast / unsupported." -> I did not understand why this needs to fail.
we would simply split the DAG into muliple PG ?

Yes, you are right. That was mine intention but perhaps this bullet point added confusion. The intention was simply to indicate this scenario i.e. a regular shuffle being part of a PG is not possible. In reality, like you mentioned and what is mentioned in section 3, it will just split the DAG into multipe PGs.

Though we should implement a check to make sure such a invariants hold.

Section 9: "Push-based shuffle merge" -> can you clarify this why this cant be supported for input and/or output ?

Section 9 describes unsupported interactions with existing mechanism within a PG

So for "Push-based shuffle merge" it simply means it cannot be used as an incremental shuffle in a PG. Of course it can be used as input or output for a PG just like a regular shuffle as you mentioned.

Section 9 "Statically-indeterminate producer" and "Checksum-mismatch" -> I did not understand this : a child of PG could have failed when reading PG output, and so requires PG reexecution - and so might be impacted by (INDETERMINATE) parent.

Yes, it is simply describing the incapability of these mechanism to exist within a PG. It should work as it should outside of a PG.

"Cached/persisted RDD in a member's within-stage chain" -> how we do enforce this ?

Enforced at stage/group creation: we walk each member stage's within-stage RDD chain — the narrow-dependency lineage, stopping at shuffle boundaries — and reject if any RDD has a non-NONE storage level (RDD.getStorageLevel). It reuses the DAGScheduler's existing within-stage traversal, so it's the same primitive the scheduler already uses, not new machinery. The shuffle-boundary stop is what makes the scope right: a cached complete input reached across a materialized shuffle (e.g. the static side of a stream-static join, or a broadcast) is outside the within-stage chain and correctly not flagged — only a cached RDD inside a member's own stage, which would freeze partial incremental output, is rejected.

TBH, its is safe to use a cached/persisted RDD in a PG but not sure if it would ever be useful especially since structured streaming queries don't such caching. I guess if you want to reuse the results from an RDD part of the PG in another spark job? I will drop this from the incompatibility matrix for now.

Just because at submission time there were insufficient resources to run it, does not mean that will continue to be the case. See existing barrier mode for insights. I believe this is what Wenchen is also driving at as well.

So my proposal here in the spec at least for v1 is that any retries are left to the caller. There is no built-in retry or queuing mechanism. I think that is good enough for v1. We can always augment the spec later to include retries.

Slot check

nit: This needs to factor in the stage requirements (resource profile/cores per task) * num partitions, across all stages in the group.

Will make that more clear.

Output-commit

I am concerned about the formulation - commit handling tends to be tricky. For the case when ResultStage is part of > PG : why cant we not have similar behavior as what currently exists ?
(I am assuming this only applies when result stage is part of the PG - not side channel writes)

Let me clarify this. There is actually no change to the existing output-commit path. The only genuinely new integration point is state cleanup: a member whose tasks all succeeded leaves a fully-populated authorizedCommitters array (the coordinator only clears a slot when the holder fails), so on group teardown we must drop each member's coordinator state — stageEnd / fresh stage ids on rerun — or the rerun's commits would be denied against the dead attempt's holders.

These are implementation details of a specific usecase.
From scheduler perspective, you can have reuse - unless we explicitly prevent/enforce it.

I think you are referring a response of mine to @cloud-fan question: "Cross-job stage reuse (§3/§4). Each micro-batch is a new job, but the base scheduler reuses shuffle-map stages via shuffleIdToMapStage. What forces a pipelined edge to create a fresh stage rather than binding batch N to batch N-1's cached stage?"

We are talking about the structured streaming use case there.

Though I think you question is more general about exchange / shuffle re-use? If so, that this not support in a PG since the assumption currently is that the data in an incremental shuffle is transient thus cannot be re-used.

jerrypeng added 3 commits July 9, 2026 06:30
…ti-group PGs, fan-out ownership, commit path, invariants

Addresses @mridulm's review comments on the spec:

- PG outputs (S2.2, S5, S7): a group need not contain a ResultStage; it may
  have multiple materialized output edges feeding downstream groups, like a
  barrier stage embedded in a larger DAG.
- Multiple concurrent PGs per DAG (S3): disjoint pipelined-edge sets form
  distinct groups that may run concurrently, subject to S4.1 slot arbitration.
- Single ownership / 1:1 fan-out (S4, S9): enforced explicitly by never reusing
  a pipelined ShuffleDependency (bypass shuffleIdToMapStage), not by relying on
  streaming re-planning. Reuse must be prevented explicitly; from the
  scheduler's view a shuffle-map stage can be reused across jobs unless forbidden.
- Output-commit (S5): the commit path is unchanged from base Spark --
  OutputCommitCoordinator arbitration never contends inside a group (speculation
  rejected, single-stage resubmit disabled), so each partition has one attempt.
  The only integration point is clearing stale commit-coordinator state on group
  teardown.
- Failure refinement (S6): post-v1, use a consumer-driven FetchFailed signal so a
  post-consumption producer loss does not force a rerun; on a pipelined edge it
  routes to group failure, not single-stage resubmit.
- Internal regular shuffle (S7, S9): reclassified from fail-fast to an invariant
  that should hold by construction (S3) but is still checked defensively.
- Push-based merge (S9): clarified it is rejected only as the incremental
  (pipelined) shuffle; a PG's regular input/output edges may still use it.
- S9 scoping: all rejected idioms are scoped within a PG; a group's regular
  input/output edges are ordinary shuffles and unaffected.

Co-authored-by: Isaac
…uestion on member StageCompleted timing

Remove the open question asking whether SparkListenerStageCompleted for a
member should be deferred to group commit or emitted in real time. The S5.1
table already specifies the behavior (deferred to group commit), so the
trailing open-question paragraph was redundant.

Co-authored-by: Isaac
…unchanged)

A readability pass over the pipelined-shuffle spec. No semantic changes --
verified by sharded adversarial review (162 normative claims checked against
the original; zero critical drift). Changes are wording, structure, and
de-duplication only:

- Unify the group abbreviation to PG throughout (was a mix of G and PG).
- Fix a dangling reference in the S9 fan-out row: it pointed at a
  persistentHint axis in S2 that S2 never defined; reworded to 'a deferred
  capability v1 does not provide' (kind and reason unchanged).
- Keep the free-vs-total slot-check rationale in one place (S4.1); drop the
  duplicated derivation from S4's 'How capacity is measured'.
- Split the dense S5 output-commit bullet into two (idempotent commit +
  teardown state cleanup) and shorten the sentences.
- Unify S5.1 terminology on 'group completion' (was 'group commit' /
  'atomic commit' for the same milestone).
- Regroup the S9 table rows to match the preamble's taxonomy (moot,
  incompatible, invariant); same rows, same kinds.
- Add a short worked example in S2.2 (Z -> A -> B -> C) illustrating grouping,
  external input, concurrency, and materialized output.
- Remove assorted restatements across S2.1, S2.2, S4, S4.1, S6, S7.

Co-authored-by: Isaac
@jerrypeng jerrypeng requested a review from cloud-fan July 9, 2026 18:29
@jerrypeng

Copy link
Copy Markdown
Contributor Author

@cloud-fan @mridulm @tgravescs I have updated the spec incorporating your feedback Let me know if you have any more concerns.

@jerrypeng

Copy link
Copy Markdown
Contributor Author

@cloud-fan @mridulm @viirya @tgravescs I also created the first PR that just declares PipelinedShuffleDependency. I think that part is no controversial? Pls take a look:

#57179

@mridulm

mridulm commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for the response - the rest look good to me (I will review the full spec again later early next week)

A few follow ups:

In general, shuffle is one to many (1 producer, many consumers) - are we restricting/enforcing it to 1:1 ? If no - what is the behavior if/when it is used across jobs (DAGs) ?

Short answer, Yes.

You're right that shuffle is 1:many in general. Worth separating where that fan-out actually comes from: a pipelined edge only ever originates from the physical-planning rule marking an exchange, and one ShuffleDependency gets more than one consumer only via exchange/stage reuse — ReuseExchangeAndSubquery (self-join, shared subquery/CTE, self-union) or submitMapStage. Branching alone doesn't do it: without the reuse rule, two identical subtrees plan to two separate ShuffleExchangeExec instances, each with its own shuffleDependency (it's a lazy val per node), i.e. two independent 1:1 shuffles. So "fan-out of a pipelined edge" ≡ "a pipelined dependency got reused/shared."

That makes fan-out fundamentally incompatible with a transient incremental shuffle, not just unimplemented: reuse relies on a durable, independently-fetchable materialized output that N consumers each read on their own schedule. A transient shuffle is a live, once-through stream from a still-running producer — there's no stored copy to hand to a second consumer, and concurrent readers would need fan-out-aware backpressure that doesn't exist. Fan-out becomes well-defined only over a persistent/replayable medium (e.g. a Kafka-backed shuffle, where consumers replay from offsets) — which is the persistentHint axis the spec already lists as a non-goal and defers. So 1:many isn't off the table forever; it's gated on that persistent-shuffle capability, which isn't on the table now.

We should not assume PG/streaming shuffle is used only from sql/RTM - the constructs can be directly depended on - and I would expect some interesting usecases to develop as well.

On how we do it? Single-ownership and 1:1 are the same mechanism — a pipelined ShuffleDependency is never reused or shared. A group is rejected fail-fast if a pipelined producer has more than one consuming stage or its stage carries more than one jobId. That single rule delivers both no-fan-out and no-cross-job-sharing. This also answers your later point that, from the scheduler's perspective, reuse can happen unless explicitly prevented — agreed, so we prevent it explicitly rather than relying on a caller (e.g. a new micro-batch minting fresh shuffleIds) to avoid it incidentally.

Shuffle dependency is tied to the DAG, while stage is tied to a specific job execution of the dag.
Making streaming shuffle id tied to a a job execution, as proposed, should have interesting implications - I have not thought through it - but it is good to call this out explicitly.

"Cached/persisted RDD in a member's within-stage chain" -> how we do enforce this ?

Enforced at stage/group creation: we walk each member stage's within-stage RDD chain — the narrow-dependency lineage, stopping at shuffle boundaries — and reject if any RDD has a non-NONE storage level (RDD.getStorageLevel). It reuses the DAGScheduler's existing within-stage traversal, so it's the same primitive the scheduler already uses, not new machinery. The shuffle-boundary stop is what makes the scope right: a cached complete input reached across a materialized shuffle (e.g. the static side of a stream-static join, or a broadcast) is outside the within-stage chain and correctly not flagged — only a cached RDD inside a member's own stage, which would freeze partial incremental output, is rejected.

TBH, its is safe to use a cached/persisted RDD in a PG but not sure if it would ever be useful especially since structured streaming queries don't such caching. I guess if you want to reuse the results from an RDD part of the PG in another spark job? I will drop this from the incompatibility matrix for now.

This enforcement sounds good to me.
Note - there might be similar interaction with checkpoint as well, that would need to be addressed.

Just because at submission time there were insufficient resources to run it, does not mean that will continue to be the case. See existing barrier mode for insights. I believe this is what Wenchen is also driving at as well.

So my proposal here in the spec at least for v1 is that any retries are left to the caller. There is no built-in retry or queuing mechanism. I think that is good enough for v1. We can always augment the spec later to include retries.

This would not work except for simple scenarios (linear chain of prefix* -> PG -> suffix*) - users would need to provision nontrivial resources to ensure there is sufficient capacity to run all stages which can run concurrently with a PG (including other PGs), and the PG itself.

Given this is already support for barrier scheduling, it should be possible to adapt for PG as well ?

Output-commit

I am concerned about the formulation - commit handling tends to be tricky. For the case when ResultStage is part of > PG : why cant we not have similar behavior as what currently exists ?
(I am assuming this only applies when result stage is part of the PG - not side channel writes)

Let me clarify this. There is actually no change to the existing output-commit path. The only genuinely new integration point is state cleanup: a member whose tasks all succeeded leaves a fully-populated authorizedCommitters array (the coordinator only clears a slot when the holder fails), so on group teardown we must drop each member's coordinator state — stageEnd / fresh stage ids on rerun — or the rerun's commits would be denied against the dead attempt's holders.

commit state is only for result stage ? And result stage is what determines job termination ?
In case of PG as well, if result stage terminates - I would expect all other stages to get torn down ?

In other words, cleanup is triggered by result stage completion, and tied to commit -> which is what happens today as well ?
I am trying to see what I am missing here - I dont know enough about streaming nuances to know if there are any sub cases I might be missing !

@jerrypeng

Copy link
Copy Markdown
Contributor Author

@mridulm thank you for your feedback! Responding in line

We should not assume PG/streaming shuffle is used only from sql/RTM - the constructs can be directly depended on - and I would expect some interesting usecases to develop as well.

Agreed on the framing, and working through it changed my answer on fan-out — for the better. Two things:

On generality: you're right that PG / pipelined shuffle is a generic DAGScheduler construct and I shouldn't justify its properties via SQL/RTM specifics (ReuseExchangeAndSubquery, etc.).

On fan-out — I'm walking back "incompatible." 1:N (one producer, many consumers) is a supported model; v1 just defers it. The mechanism differs from a regular shuffle, but the model is the same. Concretely, a consumer's PG membership picks its transport:

  • In-PG consumers read the incremental edge — a live stream, so they must be co-scheduled with the producer (all live at once). This needs multicast (one producer pushing each record to N reader channels) plus per-consumer backpressure; that isn't built in the streaming shuffle today, but it isn't fundamentally hard either. Deferred.
  • Out-of-PG consumers read a materialized edge and fetch it by coordinate the normal way, whenever they run.
  • A producer can write both at once — tee each record to the live in-PG readers and to a durable materialized output for out-of-PG readers. So fan-out to a mix of co-scheduled and later consumers is expressible; it's not an "impossible" case.
  • Why this is consistency-clean (not "different failure semantics"): an out-of-PG consumer of the materialized edge is sequenced after group commit (§5 group-observable completion; §7 materialize-before-read). So during a group's internal replay — a sibling fails, the group reruns, the in-PG live readers re-absorb — the materialized consumer hasn't started yet and cannot observe an intermediate attempt. When the group commits, it reads exactly the single committed version, identical to any regular shuffle downstream. There's no window where the producer re-delivers to the transient readers but not the materialized one. The mechanism that guarantees this: a member's materialized output is written as it runs but its map-output registration is deferred to group commit, so a failed attempt's blocks are simply orphaned (never registered) — exactly like a resubmitted regular map stage today.

I would like to keep 1:1 mapping for now and defer 1:N later but like I mentioned it is definitely possible to extend it.

Shuffle dependency is tied to the DAG, while stage is tied to a specific job execution of the dag.
Making streaming shuffle id tied to a a job execution, as proposed, should have interesting implications - I have not thought through it - but it is good to call this out explicitly.

Agreed, it's worth stating precisely because the scheduler genuinely permits reuse. The root reason it must be prevented here is simple: a pipelined shuffle is transient — once-through, nothing retained — so unlike a regular shuffle there's no durable output for a second job to reuse. Reuse across jobs is therefore unsound for it, so we make run-once an enforced invariant rather than an assumption.

I'll add a spec subsection making this explicit.

This enforcement sounds good to me.
Note - there might be similar interaction with checkpoint as well, that would need to be addressed.

checkpoint is cache-adjacent but splits into two cases, and I'll handle them differently:

  • Local checkpoint (localCheckpoint()) is cache — it forces a non-NONE storage level and stores in the block manager (just also truncates lineage). Ephemeral, whole-partition, nothing durable escapes — same safe bucket as .cache()/.persist(), not rejected.
  • Reliable checkpoint (checkpoint()) is genuinely different, and rather than leave it as a subtle "allowed but hazardous" case, v1 will fail-fast on it — cleaner and less confusing. It writes a durable, lineage-truncated snapshot, which (1) reintroduces exactly the cross-time reuse a transient edge forbids (§4), and (2) is written by a separate post-success job that recomputes the RDD from scratch, which for a PG member means re-reading its already-vanished transient input.

I will document it.

This would not work except for simple scenarios (linear chain of prefix* -> PG -> suffix*) - users would need to provision nontrivial resources to ensure there is sufficient capacity to run all stages which can run concurrently with a PG (including other PGs), and the PG itself.

Given this is already support for barrier scheduling, it should be possible to adapt for PG as well ?

We could but can we defer it to v2? Initial callers such as RTM would not need such feature. I will mention it explicitly in the spec

In other words, cleanup is triggered by result stage completion, and tied to commit -> which is what happens today as well ?
I am trying to see what I am missing here - I dont know enough about streaming nuances to know if there are any sub cases I might be missing !

The distinction I'm drawing is that a PG is an atomic scheduling unit — there are no per-task or per-partition replays within it. That matters because OutputCommitCoordinator is built on the opposite assumption: it arbitrates commits per task attempt, authorizing exactly one attempt per partition and denying any later request for a partition that has already committed.

That's sound today because a committed partition is never recomputed — a stage rerun (e.g. on fetch failure) only recomputes the missing partitions, so a task that already committed never needs to commit again, and the permanent "deny" is exactly right. A PG breaks that assumption. Because the group is atomic, a failure anywhere reruns the whole group — including a result stage whose tasks already succeeded and committed. So committed
partitions do get rerun, and those rerun tasks must be allowed to commit again — but the coordinator still holds the previous attempt's authorized committers and would deny them (success clears nothing; only a failed holder's slot is cleared today).

So to let the rerun's tasks commit, we either (a) rerun the members under fresh stage ids — a fresh coordinator StageState with no prior committers — or (b) reset the committed state for those stages in the OutputCommitCoordinator (e.g. stageEnd on teardown) before the rerun.

…t deferred, reuse layers, checkpoint, admission retry, commit reset

Addresses @mridulm's follow-up comments on the spec:

- Fan-out / generality (S4, S5, S9): reframe single-ownership at the construct
  level (the pipelined dependency and PG are generic DAGScheduler constructs, not
  SQL/RTM-specific). Split fan-out into two cases: cross-time / cross-job reuse
  stays incompatible (a transient once-through edge has no retained output to
  reuse), but concurrent branching (1:N co-scheduled consumers) is reclassified
  from incompatible to DEFERRED -- a supported model not built in v1 (needs
  multicast + per-consumer backpressure). A producer may also tee a durable
  materialized edge for out-of-group consumers; S5 gains deferred output
  registration so that dual-write is consistency-clean.

- Cross-job reuse enforcement (S4): a pipelined shuffle is transient, so reuse
  across jobs is unsound and must be prevented at BOTH layers the scheduler would
  otherwise reuse a shuffle -- shuffleIdToMapStage stage reuse AND the
  MapOutputTracker-availability skip -- not just the former.

- Checkpoint (S9): reliable RDD.checkpoint() in a member's within-stage chain is
  incompatible (durable lineage-truncated snapshot -> cross-time reuse; and a
  post-success recompute of a now-vanished transient input). Fail fast on
  checkpointData being a ReliableRDDCheckpointData. Cache / persist / local
  checkpoint remain whole-partition/ephemeral and allowed.

- Admission retry (S4.1): v1 leaves retry to the caller, but that only suffices
  for a simple linear-chain PG run by a caller with its own restart loop. Name
  that limitation, and add scheduler-side PG-granular admission retry (mirroring
  barrier's transient-shortfall retry) as a post-v1 refinement.

- Output-commit (S5): a PG is an atomic unit with no per-task replay, whereas
  OutputCommitCoordinator permanently denies re-commit of a committed partition
  (sound today because only missing partitions are recomputed). A group-atomic
  rerun reruns already-committed result tasks, so the rerun must reset
  per-partition commit authorization -- via fresh stage ids or stageEnd on
  teardown.

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.

3 participants