Skip to content

Hash join spill - #45

Merged
Vedin merged 8 commits into
embucket-sync-df53.0.0from
hash-join-spill
Jul 10, 2026
Merged

Hash join spill#45
Vedin merged 8 commits into
embucket-sync-df53.0.0from
hash-join-spill

Conversation

@Vedin

@Vedin Vedin commented Jul 10, 2026

Copy link
Copy Markdown

Which issue does this PR close?

  • Closes #.

Rationale for this change

What changes are included in this PR?

Are these changes tested?

Are there any user-facing changes?

Vedin and others added 8 commits July 8, 2026 14:30
Groundwork for the spillable hash join (no behavior change):
- Extract build_left_data() from collect_left_input() — the hash-table/
  ArrayMap/concat/bitmap/membership construction — so the same code will
  serve both the classic in-memory build and per-partition builds when
  the join spills.
- collect_left_input() now returns BuildPhaseOutput (single InMemory
  variant for now; Spilled arrives with the scatter phase).
- Register the build-side reservation with_can_spill(true) when the new
  execution.enable_hash_join_spill flag is on.
- New config knobs (default off/auto): enable_hash_join_spill,
  hash_join_spill_partition_count, hash_join_spill_headroom_bytes,
  hash_join_spill_max_recursion_depth; config docs + information_schema
  expectations regenerated.

Verified: full physical-plan suite (1268 passed; spill_compression zstd
feature quirk pre-exists on clean base), joins suite 926 passed, full
sqllogictest suite green modulo the pre-existing local
encrypted_parquet quirk, clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When enable_hash_join_spill is on and the build-side reservation first
fails, the build switches into scatter mode instead of erroring: buffered
batches and the rest of the build input are hash-partitioned into K disk
partitions (new spill.rs), the probe side is scattered with the same
seeds, and the join runs partition-pair by pair, each pair building an
ordinary in-memory hash table under its own reservation. The in-memory
fast path is unchanged.

- spill.rs: SideScatter / PartitionSpillWriter (coalesced IPC writes,
  file rotation, StringView compaction), chained spill readers,
  SpillJoinDriver state machine, auto partition-count sizing.
- exec.rs: engage-on-overflow inside the build fold with exact
  reservation shrinks; BuildPhaseOutput::Spilled; per-partition spill
  context in execute(); spill disabled when it would break the advertised
  probe-side output ordering.
- stream.rs: SpillJoin stream state; bounds-only dynamic-filter reporting
  for spilled partitions (prevents both the bounds-report deadlock and
  wrong pruning); output flows through the existing coalescer/fetch.
- shared_bounds.rs: PushdownStrategy::BoundsOnly (unlike Empty, a
  spilled partition's probe rows must not be pruned by the CASE filter).

M2 scope: PartitionMode::Partitioned + JoinType::Inner only (gate in
execute()); oversized partitions surface a clean resources-exhausted
error until recursion lands (M4).

Tests (8 new): scatter round-trip, seed independence from repartition
hash, file rotation + temp cleanup, forced-spill inner join equals
in-memory results with spill metrics recorded, fast path untouched with
ample memory, pool fully released after completion and after mid-stream
drop, disk-cap exhaustion and flag-off both keep today's clean errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the inner-join-only gate: disk partitions are key-disjoint, so
each pair's in-memory join has complete knowledge for its keys —
per-pair visited bitmaps (unmatched build emission) and probe-side null
padding work unchanged. The driver's pair-skip logic is now a per-type
matrix: pairs are skipped only when the join type provably emits
nothing for an empty side; pairs that emit unmatched rows from the
non-empty side still run (the inner stream handles the empty other
side natively).

The ordering guard from M2 (no spilling when the operator's advertised
probe-side order maintenance is live) now carries the weight for
Inner/Right/RightSemi/RightAnti/RightMark. Null-aware anti joins remain
excluded.

Tests: forced-spill matrix over all 10 join types vs in-memory
reference (multiset-fingerprint comparison), and an
Inner/Left/Full x NullEqualsNothing/NullEqualsNull matrix with NULL
join keys (all NULLs scatter to one partition, so NullEqualsNull
matching stays complete; NULL-heavy skew is deferred to the chunked
fallback in M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pair whose build partition exceeds the budget is now handled in three
tiers instead of erroring:

1. Recursive repartition (up to hash_join_spill_max_recursion_depth
   passes): already-loaded batches and the rest of the pair's build
   stream re-scatter into 8 children at the next seed level, the pair's
   probe partition follows with the same seed, and the children are
   queued front-first so their disk space is reclaimed promptly. A
   no-shrink shortcut (largest child > 3/4 of the parent) marks children
   as duplicate-key mass and sends them straight to the fallback.
2. Chunked build fallback for build-side-emission types (Inner, Left,
   LeftSemi, LeftAnti, LeftMark): build rows load in budget-sized chunks,
   each joined against a full re-read of the pair's probe partition —
   correct because each build row lives in exactly one chunk.
3. Probe-side-emission types (Right family, Full) in this corner surface
   a descriptive resources-exhausted error (their outputs depend on
   matches against the whole build side, which chunks cannot track) —
   never wrong results.

Two real bugs found by the new tests and fixed:

- Seed derivation: per-level scatter seeds differed only by a small
  additive constant in one ahash seed word, and ahash folds that word in
  additively — hashes at consecutive levels differed by a constant, so
  under % K a whole parent partition landed in ONE child and recursion
  could never split anything. Seeds are now derived through a SplitMix64
  mixer (regression test: recursive_scatter_levels_are_decorrelated).
- Memory accounting: loaders reserved only batch bytes, so a chunk/pair
  could fill the budget with data and then fail on the hash table's
  estimate (~2x the data for narrow rows). Spill-mode loaders (build
  fold, pair load, chunk load) now pre-reserve the incremental table
  estimate per batch — overflow surfaces where the fallback logic can
  engage — and build_left_data takes prereserved_table_bytes so the
  accounting stays exact (fast path passes 0: unchanged).

New metrics: join_spill_repartition_passes, join_spill_fallback_chunks.

Tests (15 total now): extreme duplicate-key skew via chunked fallback
(Inner + LeftAnti) vs in-memory reference, unsupported-type skew clean
error, moderate-skew recursion with zero fallback chunks, per-level
scatter decorrelation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CollectLeft joins (single shared build, N probe partitions) can now
spill: the build scatters once into K disk partitions, each output
partition scatters ITS OWN probe stream with the same seed and walks
k = 0..K, and every k must run in every partition (skipping would leave
the shared completion counter high and suppress unmatched emission).

Design decision recorded by the tests: per-k hash tables are NOT shared
across probe partitions. Sharing them (first attempt, via per-k
OnceAsync) meant a retained-table WINDOW spanning the progress gap
between the fastest and slowest partition — up to K tables resident,
observed directly as three concurrent multi-MB tables blowing a pool
sized for two. Instead each partition builds its own copy of k's table
from the shared files (bounded: at most one resident table per
partition, matching the auto-K sizing, which now also divides its
per-partition target by the consumer count). What IS shared per k is
exactly what correctness needs: the visited bitmap and the
probe-completion counter, injected into each copy's JoinLeftData —
JoinLeftData's bitmap/counter are Arc'd now — so matches from all
partitions mark one bitmap and the existing last-finisher logic emits
unmatched build rows exactly once. Row order across copies is
deterministic (same files, same order), so bitmap indices agree.

Oversized shared partitions surface a clean descriptive error (no
recursion for the shared build: a bitmap cannot be split across
repartition levels). Null-aware anti joins remain excluded.

Test: forced CollectLeft spill over Inner/Left/LeftAnti/Full with two
concurrently-polled output partitions — fingerprint-identical to the
in-memory join (exactly-once unmatched emission), spill engaged, pool
drained after the plan drops (the exec node's left_fut cache retains
the scatter headroom until then, as it retains the whole build today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… pass

Two more cancel/error-safety tests: dropping the output stream deep
inside the chunked skew fallback releases every reservation (chunk
data, table estimate, headroom, scratch), and a probe-side error
arriving mid-scatter propagates verbatim with the pool drained.

Regression status at this point: 18 spill tests, full physical-plan
suite 1286 passed (spill_compression zstd feature quirk pre-exists on
clean base), full sqllogictest suite green except the pre-existing
local encrypted_parquet environment quirk, clippy -D warnings and fmt
clean. Performance parity gates (flag-on vs flag-off at SF1/SF10) run
with the rustice integration, where a release build and the benchmark
harness exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operational visibility for long spilled joins on remote deployments:
one line when a build side switches to scatter mode (with buffered
bytes/rows), one per recursive repartition pass, one per chunked-
fallback chunk. Mirrors SortExec's spill logging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tually available

with_can_spill() was driven by the raw config flag, but under
FairSpillPool a spillable consumer is capped at pool/n_spillable — a
join excluded from the spill fallback (null-aware anti join,
order-preserving probe side, disabled DiskManager) got the tiny fair
share with no fallback to honor it, failing queries that passed before
the feature existed. Found by TPC-H Q16 at SF100: its NOT IN subquery
plans as a null-aware anti join, which hit a ~155 MB share of a 16 GB
pool and errored.

The reservation is now marked spillable exactly when the spill context
exists (the full gate), so non-degradable joins keep unspillable
semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation common sqllogictest physical-plan labels Jul 10, 2026
@Vedin
Vedin merged commit ed76ebc into embucket-sync-df53.0.0 Jul 10, 2026
59 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common documentation Improvements or additions to documentation physical-plan sqllogictest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant