Skip to content

feat: spilling hash join operator (opt-in, inner joins) - #2108

Draft
andygrove wants to merge 15 commits into
apache:mainfrom
andygrove:feat/spilling-hash-join
Draft

feat: spilling hash join operator (opt-in, inner joins)#2108
andygrove wants to merge 15 commits into
apache:mainfrom
andygrove:feat/spilling-hash-join

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Relates to #2025.

This does not Closes #2025: the operator introduced here is opt-in and disabled
by default, so it does not change the default behaviour that OOMs. It provides a
mechanism that resolves that OOM when enabled, and is intended as the first step
toward addressing the issue.

Rationale for this change

DataFusion's HashJoinExec does not spill. Its build side is collected fully
into memory; when a Partitioned build partition exceeds the memory reservation
it returns ResourcesExhausted (or the process OOMs). This is the failure in
#2025 — a Partitioned build side on TPC-H Q18 that cannot fit and cannot spill,
and does not shrink with target_partitions.

This PR adds a from-scratch spilling hybrid hash join operator for Ballista.
It runs as a plain in-memory hash join when the build side fits, and degrades
gracefully to disk when it does not, so a large build side completes instead of
failing. It is a hybrid join (as in Spark/Postgres), not pure Grace: buckets
that fit stay resident and only the overflow is spilled, so the in-memory fast
path pays no spill cost.

What changes are included in this PR?

A new SpillingHashJoinExec operator (ballista-core) plus the scheduler
plumbing to substitute it for eligible joins.

Operator (ballista/core/src/execution_plans/spilling_hash_join/):

  • Sub-partitions each task's build side into P buckets by a hash of the join
    keys (fixed seed, independent of the shuffle hash).
  • Hybrid spill: buckets are kept resident against a MemoryReservation; when
    try_grow fails, the largest resident bucket is spilled to disk (Arrow IPC via
    the runtime DiskManager). Resident buckets are probed streaming.
  • Probe rows for spilled buckets are routed to a probe-side spill; after the
    probe stream ends, spilled buckets are drained one at a time (build table
    built, probed, dropped) so peak memory is bounded to a single bucket.
  • If a single spilled bucket's build side still does not fit, it is recursively
    re-partitioned with a depth-varied seed; an irreducible single-join-key bucket
    returns a clear error rather than OOMing, panicking, or hanging.
  • Serialises scheduler → executor through BallistaPhysicalExtensionCodec.

Scheduler:

  • SpillingHashJoinRule / maybe_substitute_spilling_hash_join substitutes an
    eligible HashJoinExec for the spilling operator. Eligibility is strict:
    Inner join, PartitionMode::Partitioned, no projection, no residual filter,
    and NullEquality::NullEqualsNothing.
  • Applied in both the default (DefaultDistributedPlanner) and AQE planners,
    after broadcast promotion — so a small build side that can be promoted to a
    CollectLeft broadcast keeps that shuffle-avoiding plan (a CollectLeft join
    is not Partitioned, hence ineligible), and only the remaining large
    Partitioned hash joins are made spillable.

Config: ballista.execution.spilling_hash_join.enabled (default false) and
ballista.execution.spilling_hash_join.partitions (default 16).

Scope (v1): inner joins only. All other join types are left to the existing
planning path unchanged. Outer/semi/anti and broadcast-mode support are follow-ups.

Testing: the operator is validated against a DataFusion HashJoinExec
(Inner, Partitioned) oracle on hash-co-located inputs, with output compared
under (a) fully resident, (b) forced build-side spill, (c) forced two-sided
spill, and (d) forced recursive re-partition — down to a ~1 KiB memory pool — plus
a clean-error test for single-key skew. Codec round-trip and planner-substitution
(including broadcast-wins and AQE dynamic-join-not-clobbered cases) are covered by
plan-string tests. TPC-H plan-stability goldens are unchanged (the feature is a
strict no-op when disabled).

Note: this operator has not yet been benchmarked end-to-end on a cluster.
The next step is an SF10+ A/B run with the flag on vs. off under both AQE on and
AQE off, and an end-to-end run of the Q18 case from #2025 at a memory setting
that OOMs today. Opening as a draft for design feedback ahead of those numbers.

Are there any user-facing changes?

Two new configuration settings (above), both defaulting to off/existing behaviour.
No public API changes and no change to default planning. With the feature disabled
(the default), behaviour is unchanged.

andygrove added 15 commits July 20, 2026 10:18
Add ProbeTable, the correctness core of the spilling hash join: builds an
inner-join hash table over one resident build-side bucket and probes it
against a probe-side batch, returning matched (build_row, probe_row) index
pairs. Also adds assemble_output to materialize the joined RecordBatch from
those index pairs.

Key equality is resolved via Arrow's row encoding (RowConverter/Rows) rather
than a per-DataType match, so Int32/Int64/Decimal128/Date32/Utf8 (and other
row-encodable types) all work without dedicated dispatch code. A shared
per-row null-key mask enforces NullEquality::NullEqualsNothing semantics,
since Arrow's row encoding otherwise treats two NULLs as equal.
Wire SpillingHashJoinExec::execute() to a real join stream: drain the
build (left) side fully into one resident ProbeTable per sub-partition
bucket, then probe the right side against those tables as it streams
in. All build buckets stay resident for now; spilling any of them to
disk under memory pressure is left to a later change.

The stream is built as a futures::stream::once future (drain + build)
that resolves to the probe stream and is flattened with try_flatten,
the same idiom SortShuffleWriterExec::execute already uses in this
crate for "do async setup, then hand back a stream" — this keeps the
build phase as plain async/await instead of a hand-rolled poll_next
state machine.

Adds an oracle test comparing SpillingHashJoinExec against
DataFusion's own HashJoinExec (Inner, Partitioned) over the same
hash-repartitioned inputs, so both joins see keys co-located the same
way and the comparison actually exercises the Partitioned-mode
contract instead of passing vacuously.
Spill both sides of SpillingHashJoinExec to disk and drain spilled
buckets one at a time so the join runs in memory bounded by the runtime
MemoryPool.

- Add per-bucket Arrow-IPC spill files (JoinSpillWriter/JoinSpillReader),
  with independent build-side and probe-side spill sets per task.
- Build phase reserves resident bucket memory against the pool and, on a
  rejected try_grow, evicts the largest resident bucket to disk; once a
  bucket cannot fit it stays spilled.
- Probe phase probes resident buckets and routes spilled buckets' probe
  rows to disk; the drain phase reads each spilled bucket back one at a
  time, recomputing hashes from the fixed partitioner seed, and drops
  each table before the next so peak drain memory is one bucket.
- Publish spill_count/spilled_bytes metrics; gate with tiny-pool oracle
  tests asserting output identical to a generous-pool HashJoinExec.
Pool-track the drain-phase build side of SpillingHashJoinExec and, when a
single spilled bucket's build side will not fit in memory, recursively
re-partition both its build and probe spills into finer sub-buckets under a
depth-varied hash seed (build and probe share the seed at each level so equal
keys stay co-located). Only one (sub-)bucket's build table is resident at any
depth. A build side for a single join key that exceeds the whole pool is
reported as a clean error instead of an OOM; a best-effort infallible
reservation covers residuals that fit the pool but lost a grow race with other
resident buckets. A depth cap bounds the recursion.
Add a SpillingHashJoinExecNode proto message and codec arms so
SpillingHashJoinExec survives the scheduler->executor plan round trip.
Following the ChaosExec convention, children are not embedded in the
proto message; datafusion-proto decodes them and passes them through
try_decode's inputs parameter. Join keys are serialized via the same
physical-expr helpers already used for shuffle hash-repartition exprs.
…dtrip

The roundtrip test previously built left and right inputs from the same
schema with identical Column exprs on both sides of each on pair, so a
bug swapping left_keys/right_keys through encode/decode would still
produce byte-identical Display output. Give left and right distinct
schemas (l_* vs r_* column names) so a side swap or mis-pairing now
changes the rendered on=[...] string and fails the test.
…e joins

Add SpillingHashJoinRule, a PhysicalOptimizerRule that swaps eligible
HashJoinExec nodes (Inner join, Partitioned mode, no projection, no
residual filter) for SpillingHashJoinExec. The rule is gated by
ballista.execution.spilling_hash_join.enabled read from the
BallistaConfig extension on ConfigOptions, defaulting to disabled when
the extension is absent, and uses
ballista.execution.spilling_hash_join.partitions (default 16) for the
number of spill sub-partitions.
…ast promotion

Broadcast promotion only matches HashJoinExec, so a join substituted to
SpillingHashJoinExec up front could never be promoted to a shuffle-avoiding
CollectLeft broadcast. Move the substitution to run per node immediately after
broadcast promotion inside plan_query_stages_internal, so a small build side is
offered a broadcast first and only joins left as Inner+Partitioned HashJoinExec
are substituted.

Factor the single-node eligibility and substitution into a shared
maybe_substitute_spilling_hash_join function reused by both SpillingHashJoinRule
and the distributed planner, keeping one definition of eligibility.
…lic api

Close four correctness/API gaps found in whole-branch review:

- Reject substitution when the source HashJoinExec uses
  NullEquality::NullEqualsNull (e.g. IS NOT DISTINCT FROM); the replacement
  operator hard-implements NullEqualsNothing semantics, so substituting here
  would silently drop null-key matches with no error.
- Reject non-Partitioned partition_mode in try_new; execute() always runs
  Partitioned semantics, so a constructed CollectLeft instance would silently
  mis-execute.
- Reject num_sub_partitions == 0 in try_new, before it can reach the
  build-side hash bucketing and panic on division by zero.
- Narrow ProbeTable/assemble_output/RowPartitioner/PartitionedBatch out of
  the crate's public API; nothing outside the module reaches them through
  the mod-level re-export, only stream.rs's existing super:: paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant