Shuffle Design#
Ballista uses a blocking shuffle: a query stage runs to completion and materializes its output to local storage before any downstream stage starts. This is the same model Apache Spark uses, and it is deliberately different from the pipelined shuffle used by engines such as Apache Flink, DataFusion Distributed, and Sail, where a downstream stage streams data from an upstream stage that is still running.
This page explains why Ballista made that choice, what it costs, and how to think about the trade-off when proposing changes to the exchange layer. For the mechanics of the shuffle writer and its tuning knobs, see the Shuffle Implementation section of the tuning guide.
The barrier, concretely#
Three pieces of the system implement the barrier:
The write side.
ShuffleWriterExec(andSortShuffleWriterExec) drain their input partitions concurrently, streaming each to an Arrow IPC file under{work_dir}/{job_id}/{stage_id}/{partition_id}/. A partition’sShuffleWritePartitionsummary — the row, batch, and byte counts that the scheduler records — does not exist until that file is closed.The scheduler. An
UnresolvedStagebecomes resolvable only when every input stage is marked complete:// ballista/scheduler/src/state/execution_stage.rs pub fn resolvable(&self) -> bool { self.inputs.iter().all(|(_, input)| input.is_complete()) }
Resolution then rewrites
UnresolvedShuffleExecnodes intoShuffleReaderExecnodes carrying concretePartitionLocations, and runs Ballista’sJoinSelectionoptimizer over the result.The read side.
ShuffleReaderExecfetches those locations, either directly from the local filesystem or from the producing executor’s Arrow Flight service (do_get, or the raw-blockIO_BLOCK_TRANSPORTaction).
A stage’s output is therefore a durable, addressable, re-readable artifact that outlives the task that produced it. Almost every property below follows from that one fact.
Why the barrier is there#
Producers and consumers never compete for slots#
Each executor advertises a fixed number of task slots. Under a blocking shuffle, a stage’s tasks hold slots only while that stage runs, so a stage with 1000 tasks executes perfectly well on a cluster with 32 slots — the scheduler simply feeds tasks in as slots free up.
Pipelined shuffle removes that freedom. If a consumer task streams from a producer task, both must be resident at the same time, which turns stage scheduling into a co-scheduling (gang scheduling) problem. Get it wrong and the cluster deadlocks: every slot is held by a consumer waiting on a producer that cannot be scheduled. Engines that pipeline either require enough capacity for the whole pipeline region, provision workers on demand, or fall back to materialization under pressure.
Ballista’s ability to run a query far wider than the cluster is a direct consequence of blocking.
Failures cost a task, not a query#
Because shuffle output survives the task that wrote it, Ballista can recover
from a lost executor by re-running only what was actually lost. When a fetch
fails, the scheduler receives a FetchPartitionError and does three things: it
drops every input partition the downstream stage was sourcing from the failed
executor, rolls back that downstream stage, and resubmits only the map tasks
needed to regenerate the dropped partitions. Map output that lives on surviving
executors is not recomputed, and unrelated stages are not touched. The same
machinery handles executors disappearing outright
(reset_stages_on_lost_executor, rollback_running_stage,
rerun_successful_stage in ballista/scheduler/src/state/execution_graph.rs).
Repeated failures are capped: once a stage exceeds its retry budget the job
fails rather than looping.
A pipelined engine has to work harder for the same guarantee. Without a durable copy of the producer’s output, recovery means either re-running the producer or restoring from a checkpoint, and checkpointing a distributed dataflow is a hard problem in its own right — Flink got there with asynchronous barrier snapshotting, but it took years of work, and it recovers by rewinding a whole pipeline region to the last snapshot rather than by re-running the handful of tasks that were actually lost. Engines that have not taken that on restart the query. DataFusion Distributed is explicit about this in its own documentation:
If any node fails mid-query, the whole query fails; there are no retries. There’s no persistence of intermediate results, so queries can’t checkpoint or resume from where they stopped.
For a query that runs for seconds, restarting is cheap and this hardly matters. For an ETL job that runs for an hour on a cluster where a spot instance disappears every few minutes, it is the difference between a job that finishes and one that never does.
The filesystem absorbs producer/consumer skew#
In a blocking shuffle, the buffer between stages is the disk. A producer never waits for a consumer: it writes its output, reports, and releases its slot. That decoupling is what lets the sort-based writer bound its own memory by spilling and still guarantee forward progress.
In a pipelined shuffle, the buffer is memory plus network, and backpressure is end-to-end. A slow consumer stalls its producer, which holds a slot and its working set while stalled. Shuffles far larger than cluster memory — the case Ballista is built for — need either a spill path or a remote shuffle service to stay safe, which is most of the blocking machinery reintroduced.
Note that Ballista does apply backpressure on the read side: the shuffle
governor (ballista.shuffle.reader.max_bytes_in_flight and
max_blocks_in_flight_per_address) bounds concurrent fetches. The point is that
this only shapes reader memory. It cannot stall a producer that has already
finished.
Completed stages yield exact statistics#
Stage resolution is where Ballista re-plans. to_resolved() runs
JoinSelection against the resolved plan, and with
ballista.planner.adaptive.enabled the AdaptiveExecutionGraph re-runs a set
of physical optimizer rules after each stage completes, using the exact row and
byte counts that stage produced. See
Adaptive Query Execution.
It is worth being precise about what the barrier buys here, because pipelining
does not rule out adaptivity. DataFusion Distributed performs AQE without a
barrier by injecting a SamplerExec below the producer’s exchange: it buffers
early batches, reports sampled row, byte, distinct, and null counts to the
coordinator, the coordinator sizes the next stage from that sample, and the
buffered batches then flow on into the consumer.
So the real distinction is exact versus sampled. Sampling is enough to size a stage or pick a join side. It is not enough for decisions that depend on a final answer — Ballista’s empty-stage elimination rule, which replaces a downstream exchange with an empty node and propagates emptiness up the plan, requires knowing a stage produced zero rows, and no sample can establish that before the stage ends.
Exact per-partition counts are also what makes skew mitigation tractable. The
AQE CoalescePartitionsRule already uses them to merge undersized shuffle
partitions before the next stage is scheduled, and the same information is what
a future rule would need to go the other way and split an oversized partition.
Skew is listed below as a cost of the barrier, but the barrier is also where the
statistics to fix it come from.
The two sources of statistics are not exclusive, either. Ballista’s range
repartition operators already sample in flight: RuntimeStatsExec maintains a
T-Digest over the routing expression, and the repartition operator snapshots it
on the first batch to pick its quantile cuts, so range boundaries adapt to the
data without waiting for a stage to finish. The same mechanism is what would let
a hot key be spread across several downstream tasks.
Executors can come and go between stages#
Because a stage’s inputs are files rather than live connections, cluster
membership only has to be stable for the duration of a single stage. Executors
can register between stages and pick up work immediately, which is what makes
autoscaling (including the KEDA scaler) straightforward. Shuffle files are
cleaned up after the job finishes, on the interval set by
finished-job-data-clean-up-interval-seconds.
For deployments that scale continuously with demand and run on spot capacity, this and partial re-execution above are the same property seen twice: a fleet whose size changes under the query only works if a departing executor costs a few re-run tasks and an arriving one can be handed work with no setup. Both follow from stage output being a file rather than a connection.
What the barrier costs#
The trade-off is real, and it is worth stating plainly.
Straggler stall. A stage finishes when its slowest task finishes. Until then no downstream work can start, even if 99% of the input is ready. On a skewed join key or a slow node, most of the cluster sits idle waiting. This is the single largest source of avoidable latency in Ballista today — and the word to note is avoidable: waiting on the whole stage is how the scheduler works now, not something materialization forces (see partial-input early start).
Write amplification. Every intermediate byte is written, then read, then often served over the network — even when the intermediate result is a few kilobytes. For small queries this dominates the runtime.
Higher interactive latency. The cost above is roughly fixed per stage boundary, so a query with many small stages pays it repeatedly. Pipelined engines report substantially better latency on interactive benchmarks, and the gap is structural, not an implementation detail. This is a gap relative to a pipelined engine, not an absolute verdict: Ballista is in production on workloads where queries return in around a second.
Local storage is required. Executors need writable local storage sized for the largest shuffle they will handle, plus cleanup. It does not have to be a physical disk — deployments on memory-rich instances point
work_dirat a RAM-backed filesystem and never touch one — but the space has to exist somewhere, and today that somewhere is attached to the executor rather than to a shuffle service.No path to streaming. A blocking barrier cannot express an unbounded input. Any future support for continuous queries would need a different exchange model.
Choosing a model#
Workload |
Blocking (Ballista) |
Pipelined |
|---|---|---|
Interactive queries, seconds, small intermediates |
Slower |
Faster |
Large batch and ETL, intermediates exceeding RAM |
Designed for it |
Needs spill or a shuffle service |
Cluster smaller than the query is wide |
Works |
Risks deadlock without co-scheduling |
Unreliable or preemptible nodes |
Partial re-execution |
Query restart |
Elastic or scale-to-zero clusters |
Natural fit |
Requires resident workers |
Unbounded or streaming input |
Not supported |
Natural fit |
Neither model is simply better. DataFusion Distributed reaches the same conclusion from the other side, recommending Ballista for “large, long-running batch or ETL that benefits from materializing intermediate results between stages” while targeting interactive analytics itself.
Directions that do not require abandoning the model#
The barrier is not all-or-nothing, and several ideas would recover part of the latency without giving up recovery or the ability to run wider than the cluster. These are open directions rather than commitments, and where one changes behavior it would be config-gated, leaving today’s model as the default:
Partial-input early start. Nothing about the design requires a consumer to wait for the whole producer stage; that is just what
resolvable()does today. A consumer task could begin once some producer tasks have finished, learning about additionalPartitionLocations incrementally. Where the cluster has spare capacity this overlaps stages and shortens the straggler stall, and it keeps files, retries, and slot accounting exactly as they are. The general form of this — deciding how much of the DAG is in flight at once rather than always running exactly one stage — is sometimes called bubble execution (#2320, #408).Hybrid exchange. Stream to a consumer when one is already running and fall back to writing files otherwise, in the spirit of Flink’s hybrid shuffle. This gets the latency win where capacity allows and degrades to today’s behavior where it does not (#1151, #2003).
In-memory shuffle. Small intermediates are written and read back through the filesystem regardless of size. Keeping them in a bounded in-memory buffer, spilling to files only once the buffer is exceeded, would remove most of the write amplification for short queries while leaving the durable path in place for the ones that need it. This looks like the largest single win available: simply pointing
work_dirat a RAM disk large enough to hold the whole shuffle already recovers a large share of the TPC-H gap against a pipelined engine, which says the cost being paid there is the filesystem round trip rather than the barrier itself (#2318).A cheaper shuffle format. The write side currently produces a file per output partition per task and a metadata round-trip per partition, which is a lot of small files and small reads at high partition counts. Writing one indexed file per task, coalescing batches across partitions, and trimming the per-partition metadata would cut the fixed cost of a stage boundary without changing where the data lives (#660).
Shuffle affinity. Schedule a consumer task on the executor that already holds most of its input, turning Flight fetches into local reads. This one can be prototyped without touching the core: task distribution is already pluggable, so a
TaskDistributionPolicy::Customimplementation can bind tasks to the executors holding theirPartitionLocations and be measured against the built-in bias and round-robin policies (#2319).Remote shuffle service. Offload shuffle storage to a service such as Apache Celeborn or Apache Uniffle (#1539), which decouples shuffle durability from executor lifetime and makes aggressive autoscaling safer.
Anyone proposing a change here should be explicit about which of the five properties above it preserves and which it trades away.