Expand description
A hash join that spills.
§The gap this closes
DataFusion 54’s HashJoinExec holds its entire build side in memory and has
no spill path. joins/hash_join/exec.rs carries a comment that reads as if
it did:
// Decide if we spill or not
let batch_size = get_record_batch_memory_size(&batch);
state.reservation.try_grow(batch_size)?;There is no branch under that comment — the ? propagates, and it is the
SF100 failure verbatim:
Resources exhausted: Failed to allocate additional 877.0 B for
HashJoinInput - 0.0 B remain available for the total memory poolcrate::spillable_join works around this by rewriting oversized hash
joins into sort-merge joins, which do spill. That trade is expensive:
sort-merge sorts both inputs in full even when almost all of the data
would have fitted in memory, and it cost q2 a 6.3x slowdown on the cluster.
§What this does instead
A classic hybrid grace hash join:
- Buffer the build side while it fits a budget. If the whole build side
fits, join in memory and never touch the disk — identical work to
HashJoinExec, which is the right algorithm in that case. - On overflow, hash-partition both inputs into
bucketsspill files on the join keys. - Join bucket by bucket: each bucket’s build side is small enough to hold, so each bucket is an ordinary in-memory hash join.
Only the buckets that overflow pay for disk, and nothing is ever sorted.
§Why the answer is the same
Rows join only to rows with equal join keys, and equal keys hash equal, so co-partitioning both sides on the same expressions with the same seed puts every row and all of its potential matches in the same bucket. The union over buckets is therefore the whole join — including the unmatched rows an outer join must emit, because a row’s bucket contains every row it could have matched, so “unmatched within the bucket” and “unmatched overall” are the same statement.
This is exactly the property the broadcast-join split bug violated: there the build side was replicated and the probe side split, so a task could call a row unmatched that another task had matched. Here both sides are partitioned by the same key, which is the safe case.
§Delegation, not reimplementation
Each bucket is joined by a real HashJoinExec, built from the original join
with its own builder, so join type, join filter, null equality,
null-awareness and the built-in projection are DataFusion’s semantics
unchanged. This operator only decides what data goes to which join; it
never reimplements what a join means.
Likewise the node delegates schema(), properties() and the distribution
requirements to the join it replaces, so substituting it cannot change
anything a parent plan observes.
§Known limits
- Skew. One key larger than the budget lands in one bucket and that
bucket is still an in-memory join. Recursive re-partitioning would need a
second hash seed, which
BatchPartitionerdoes not expose. The bucket count is therefore chosen generously, and an over-budget bucket is warned about by name injoin_bucket— with its size and the budget it broke — rather than being retried or passing silently. - Disk.
bucketstemporary files per side stay open while partitioning.
Structs§
- Grace
Hash Join Exec - A hash join that partitions to disk when its build side does not fit.
Constants§
- GRACE_
HASH_ JOIN_ BUCKETS_ ENV - Override the number of hash buckets the build side is partitioned into.
- GRACE_
HASH_ JOIN_ ENV - Turn the grace hash join on. Absent or not truthy, the engine keeps the sort-merge conversion.
Functions§
- bucket_
count - Buckets to partition into for a build side estimated at
build_bytesagainst a per-taskbudget. - enabled
- Whether the grace hash join is enabled for this process.