Give the Sort operator a memory limit, spill sorted runs to a temporary file when its input passes the limit, merge the runs stably into one order, count the runs, and leave no file behind, even after a crash.
The Sort operator of the previous stage collects every row it is given and sorts them in memory. That works until the table is larger than memory, and then it does not work at all. Databases sort tables far larger than their RAM every day, for ORDER BY, for building indexes and for joins, with an idea older than most of computing: external merge sort.
The idea has two halves. First, read as many rows as fit, sort them, and write them out as a sorted run; repeat until the input is used up. Second, merge the runs: every run is sorted, so the smallest row overall is the smallest of the runs' first rows. Take it, advance that run, repeat. The merge needs only one row from each run in memory at a time.
This engine counts its memory in rows, set with --sort-memory. The default is 10000 rows, so every table in the earlier stages sorted in memory and will keep doing so.
With 100 rows and a limit of 10, the Sort writes ten runs of ten rows. With a limit of 7 it writes fifteen, the last one with two rows. The number of runs is ceil(rows / limit), and the [EXEC] line now reports it for every plan that has a Sort:
$droid --db big.db --sort-memory 10>select id, v from s order by v;...(100 rows)[EXEC] rows_examined=100 sort_runs=10
Rows that fit make one run, sorted in memory and never written. A Sort that no row reaches makes none: sort_runs=0. A plan without a Sort, such as a query with no ORDER BY or one ordered by the key, prints rows_examined alone, as it always did.
The previous stage promised that rows with equal keys come out in the order they arrived, which is key order. Spilling must keep that promise. Inside a run it is kept by the stable sort. Across runs it is kept by the merge's tie rule: when two runs offer equal rows, take the one from the earlier run. Every row of an earlier run arrived before every row of a later one, so the earlier run's row is the one that came first.
That is the whole reason the output does not depend on the limit. The tests sort the same 100 rows with limits of 1, 7, 10 and 1000 and compare every line: same rows, same order, ties included.
Runs go to a temporary file beside the database, named by adding .tmp to its path. It is scratch space for one statement, and it lives outside everything the earlier parts built to protect the database:
Because nothing in it can ever be needed again, cleaning up is simple: delete it when the statement ends, whether the statement succeeded or failed, and delete any leftover at open, which is what a crash in the middle of a sort leaves. The sort_spill fail-point stops the program right after the first run is written, so the tests can see the file exist and then see the next open remove it.
The Sort operator of the previous stage held every row. Now it holds at most the limit, and writes out what it cannot hold.
Each run is sorted; the output is the merge of all of them. Only the first unread row of each run has to be in memory.
The temporary file belongs to one statement, and must not outlive it.
These are the exact lines the tests check. The full definition is on the output contract page, section 45.
| Input | Output |
|---|---|
--sort-memory 10, then a SELECT whose Sort gets 100 rows | [EXEC] rows_examined=100 sort_runs=10 |
the same with --sort-memory 1000, or no option | [EXEC] rows_examined=100 sort_runs=1 |
| a Sort that no row reaches | sort_runs=0 |
| a SELECT whose plan has no Sort | [EXEC] rows_examined=<n>, with no sort_runs |
--sort-memory <rows>, before or after --db and -c, as --cache-pages goes | accepted for 1 to 1000000 |
--sort-memory 0, 1000001 or abc | [ERROR:00107] <message>, exit status 1 |
--sort-memory with nothing after it | [ERROR:00102] <message>, exit status 1 |
DROID_FAILPOINT=sort_spill and a Sort over its limit | the program stops after the first run, exit status 3 |
| the temporary file cannot be created, written or read | [ERROR:01504] <message>; the statement fails and its file is deleted (no test sets this up) |
The temporary file is <db path>.tmp. It exists only while a statement that spills is running, and the next open deletes one that a crash left. explain does not change: the plan cannot know how many rows will reach the Sort.
$droid --db big.db --sort-memory 3Welcome to droid!>create table s (id int, v int);Create table statement executed successfully>insert into s (id, v) values (1, 2);Insert statement executed successfully (1 rows)>insert into s (id, v) values (2, 1);Insert statement executed successfully (1 rows)>insert into s (id, v) values (3, 2);Insert statement executed successfully (1 rows)>insert into s (id, v) values (4, 1);Insert statement executed successfully (1 rows)>insert into s (id, v) values (5, 2);Insert statement executed successfully (1 rows)>select id, v from s order by v;id | v2 | 14 | 11 | 23 | 25 | 2(5 rows)[EXEC] rows_examined=5 sort_runs=2>select id from s;id12345(5 rows)[EXEC] rows_examined=5>.exitexiting.. good bye!
The first run held ids 1, 2 and 3, the second ids 4 and 5. The ties on v = 1 (ids 2 and 4) and on v = 2 (1, 3 and 5) span both runs and still come out in key order. And there is no big.db.tmp left in the directory.
sort_runs and the fail-point say a spill happened that should not have.The page above is the same for every language. These are the only language-specific hints.
tmpfile() would be simpler, but the contract names the file, so fopen the path, write runs with fwrite, and remove it at the end. A small binary heap of run numbers makes the merge's smallest-row choice fast; compare by keys, then by run number.std::fs::File with BufWriter and BufReader; std::collections::BinaryHeap is a max-heap, so wrap entries in std::cmp::Reverse and put the run number in the ordering for ties.std.fs.cwd().createFile and deleteFile; std.PriorityQueue with a compare function that breaks ties by run number.