droid
When the Build Side Does Not Fit
Joins and Relations · Stage 6 of 13

When the Build Side Does Not Fit

Partition both inputs by hash to disk, then join one partition at a time

Bound a hash join's memory with --join-memory: when the build side has more rows than the limit, split both inputs into ceil(b / limit) partitions by a hash of their keys in the temporary file, join partition by partition, and report partitions=P.

Not run yet · 8 tests
Concepts in this stage
  • Rows with equal keys hash to the same partition, so partitions can be joined independently
  • Partitioning both sides with the same hash and the same count is what makes it correct
  • Skew: no hash can split one key's rows
  • A spilled join gives the same rows in no promised order
  • The join-side twin of the external sort, using the same temporary file
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParserValidator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
Pager
Database file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

A hash join holds its whole build side in memory. Ten thousand orders fit; a billion do not. The external-sort stage solved the same problem for ORDER BY by writing sorted runs to disk. This stage solves it for hash joins, with the same temporary file and the same kind of limit: --join-memory <rows>, 10000 by default.

Partitions

The idea that makes it work: rows with equal keys hash to the same value. So if both inputs are split by the same hash into the same number of partitions, every pair that can join lies inside one partition. Each partition can be joined on its own, and a partition is small enough to fit.

Build partition 0 (key mod 4 = 0)
header
cells = 4
cell 0
key = 4
cell 1
key = 8
cell 2
key = 8
cell 3
key = 12
free space
Build partition 1
header
cells = 3
cell 0
key = 1
cell 1
key = 5
cell 2
key = 9
free space
Probe partition 0
header
cells = 2
cell 0
key = 8
new
cell 1
key = 16
new
free space
Each cell is 12 bytes. A cell that moves one position moves by 12 bytes.
Keys split by key mod 4, for illustration. The probe row with key 8 is written to partition 0, the only place its partners can be; partition 0 is joined on its own.

The number of partitions is fixed by the build side's size: P = ceil(b / limit). With 200 build rows and --join-memory 10, that is 20. A build side of exactly limit rows fits, and is one partition.

So the join reads the build side, writing it to the temporary file once it no longer fits; splits it into P partitions; reads the probe side into the same P partitions; then builds and probes one partition at a time. Each input is read once and written once more.

Skew

One key with more rows than the limit cannot be split by any hash: all its rows go to one partition. Real engines split again with a second hash, which does not help either, and then fall back to something slower. Here the rule is simple: the limit decides how many partitions there are, and an oversized partition is joined in memory all the same. The join never fails because of the limit.

What spilling changes

The same rows come out, but not in probe order: partition by partition. A spilled join promises no order, so a query that needs one must say ORDER BY. The database file and the log are not touched, and the temporary file is gone when the statement ends.

partitions=<P> follows hash_probe_rows on the [EXEC] line, 1 when nothing spilled:

[EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100 partitions=20

The algorithm in words

1

Grace hash join

Partition, then join each partition in memory.

  1. 1.Read the build side. While it fits in --join-memory rows, hold it. When one more row arrives, write all held rows, and every later build row, to the temporary file.
  2. 2.When the build side ends with b rows, set P = ceil(b / limit), and split the build rows into P partitions by a hash of their keys. Drop rows with a NULL key.
  3. 3.Read the probe side and write each row to the partition its keys hash to, with the same hash and the same P. A probe row with a NULL key pairs with nothing; under LEFT JOIN hand it up padded.
  4. 4.For each partition: build a hash table from its build rows and probe it with its probe rows, padding under LEFT JOIN.
  5. 5.Delete the temporary file when the statement ends.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, section 71.

InputOutput
--join hash --join-memory 10, users join orders[EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100 partitions=20
the same with --join-memory 200... partitions=1
--join-memory 0, --join-memory abc or --join-memory 1000001[ERROR:00109] ..., exit status 1
--join-memory with nothing after it[ERROR:00102] ..., exit status 1

What you should see when done

shell
$droid --db spill.db --join hash --join-memory 1
Welcome to droid!
>create table a (id int primary key, k int);
Create table statement executed successfully
>create table b (id int primary key, k int);
Create table statement executed successfully
>insert into a (id, k) values (1, 1);
Insert statement executed successfully (1 rows)
>insert into b (id, k) values (1, 1);
Insert statement executed successfully (1 rows)
>insert into b (id, k) values (2, 2);
Insert statement executed successfully (1 rows)
>insert into b (id, k) values (3, 1);
Insert statement executed successfully (1 rows)
>select a.id, b.id from a join b on a.k = b.k order by b.id;
id | id
1 | 1
1 | 3
(2 rows)
[EXEC] rows_examined=4 hash_build_rows=3 hash_probe_rows=1 partitions=3 sort_runs=1
>.exit
exiting.. good bye!

Common bugs in this stage

  • Different hashes or partition counts on the two sides. A pair split across partitions is lost.
  • Promising probe order. Rows come partition by partition.
  • Refusing a partition over the limit. Skewed keys must still join.
  • Leaving the temporary file behind, or writing it into the database file.
  • Counting partitions=0 for a join that fits: it is one partition.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C: reuse the run writer of the external sort; a partition is a run that is not sorted.
  • C++: one output buffer per partition, flushed to its own page chain in the temporary file.
  • Rust: a Vec of partition writers indexed by hash % P; BufWriter per partition is fine for P in the hundreds.
  • Zig: an array of P small buffers, each with the page number of its chain's tail.

Checklist

One line per thing the tests check. The tags name the test cases.

  • A build side larger than --join-memory is joined in ceil(b / memory) partitions
    partitions-when-needed
  • A build side that fits is one partition, also when it has exactly --join-memory rows
    one-partition-when-it-fits
  • Spilled or not, the join gives the same rows
    same-result-either-way
  • One key with more rows than the memory limit is still joined, all of it
    skewed-key
  • A LEFT JOIN that spills still pads every left row with no partner
    left-join-spills
  • A spilling SELECT leaves no temporary file and does not change the database
    temp-file-gone
  • A spilling join works with a pool of 16 frames
    spill-under-small-pool
  • --join-memory takes 1 to 1000000
    join-memory-option