droid
Index Scan on the Primary Key
Query Execution · Stage 15 of 16

Index Scan on the Primary Key

Let a WHERE on id go down the tree to its first row, and count the rows every scan reads

Add an IndexScan node that answers WHERE id = k, id > k and id < k by searching the tree instead of reading every row, have the planner choose it for any WHERE on id, and print how many rows each SELECT examined.

Not run yet · 13 tests
Concepts in this stage
  • An index scan: use the order the tree already keeps to start where the answer starts and stop where it ends
  • The same rows, less work: a plan changes how rows are found, never which rows are found
  • Access paths: a SeqScan and an IndexScan are two ways to produce the same rows, and the planner picks one
  • Measuring work: rows_examined counts the rows a scan hands up, so the difference between two plans is visible
  • Ranges: after the first key, the leaf chain gives the rest in order
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParser
Validator
Answering a query
Planner
Executor
Keeping changes safe
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row format
Slotted page
B+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

The tree has been sorted by id since btree-search, and btree find has used that since, reading one path from the root to a leaf. SELECT never did. where id = 20 has been a Filter over a SeqScan: read every row, keep one. On 24 rows nobody notices. On a million rows it is the difference between reading three pages and reading all of them.

This stage gives the planner a second way to read the table, the IndexScan, and makes it the plan for every WHERE on id. The rows printed do not change, not one. What changes is how many rows are read to find them, and a new line makes that visible: [EXEC] rows_examined=<n>.

What changes: the scan starts where the answer starts

The tree is sorted by id, and its leaves are chained in that order. So the rows with id > 14 are all together: they start at one position in one leaf and run to the end of the chain. An IndexScan goes down the tree to that position, the way btree find does, then reads along the leaves:

select id from users where id > 14, on rows 1 to 24
internal · page 0 · read
[13]
internal · page 6
[5,9]
leaf · page 1
[1,2,3,4]
next → page 2
leaf · page 2
[5,6,7,8]
next → page 3
leaf · page 3
[9,10,11,12]
next → page 4
internal · page 7 · read
[17]
leaf · page 4 · read
[13,14,15,16]
next → page 5
leaf · page 5 · read
[17,18,19,20,21,22,23,24]
next → none
14 is not below 13, so the search goes right, to page 7; 14 is below 17, so it goes to page 4. The first key above 14 is 15. From there the scan hands up 15 and 16, follows page 4's link to page 5, and hands up 17 to 24: ten rows. Pages 1, 2, 3 and 6 are never read.

The three conditions differ only in where they start and when they stop:

ConditionStarts atStops
id = kthe position of k, found by searching the treeafter one row, or at once if k is not there
id > kthe first key greater than k, found by searching the treeat the end of the table
id < kthe first row of the tableat the first key that is not less than k

id < k starts at the beginning, like a SeqScan, but it stops as soon as the keys pass k instead of reading on to the end. Because the rows come in key order, nothing after that key can match.

The IndexScan hands up only rows that satisfy its condition, so there is no Filter above it. For name and email nothing changes: the tree is not sorted by them, and a Filter over a SeqScan is still the only way to answer.

The plan

explain shows the new node. For a key it names the key; for a range, the condition:

droid session
>explain select * from users where id = 20;
[PLAN] select * from users where id = 20
[PLAN] └── IndexScan (table=users, key=20)
>explain select id from users where id > 14;
[PLAN] select id from users where id > 14
[PLAN] └── IndexScan (table=users, range: id > 14)

The volcano model makes this a small change. An IndexScan has the same open, next and close as any other node. The executor, the Filter and both printers do not change at all. Only the planner learns to choose it.

Counting the work

After every SELECT's result, the program now prints how many rows the scan at the bottom of the plan read and handed up: [EXEC] rows_examined=<n>. In table mode it follows the (n rows) footer; in CSV mode, the last row, or the header line when no row matched.

  • A SeqScan hands up every row, so it examines the whole table. A Filter above it may keep one row, and 24 were still examined.
  • An IndexScan examines only the rows it hands up: 1 for a key that is there, 0 for one that is not, and the rows of the range for > and <.
  • To know that id < 3 is over, the scan must look at key 3. It does not hand that row up, so it does not count it: id < 3 over rows 1 to 24 examines 2.

The counter is what the tests use to tell the two plans apart. The rows printed are the same either way; only the count shows which plan ran.

The algorithm in words

1

Choose the scan

The planner now has two ways to read the table.

  1. 1.A WHERE on id, with =, < or >, gets an IndexScan holding the operator and the key. Nothing goes above it: every row it hands up already satisfies the condition.
  2. 2.A WHERE on name or email gets a Filter over a SeqScan, as before: the tree is not ordered by them.
  3. 3.A SELECT without WHERE gets a SeqScan.
  4. 4.Invariant: for any statement, the rows printed are exactly the ones the plan of the previous stage printed, in the same order.
2

Run an IndexScan

Every IndexScan finds a starting point in the tree, then reads forward along the leaves until its condition stops holding.

  1. 1.For id = k: search the tree for k, as btree find does. If it is there, hand up that row, once. Either way the scan is then over.
  2. 2.For id > k: search for the position of the first key greater than k. Hand up rows from there, following the leaf links, to the end of the table.
  3. 3.For id < k: start at the first row of the table. Hand up rows while the key is less than k, and stop at the first key that is not.
  4. 4.Count each row you hand up. The key that tells you a range is over is looked at, not handed up, and not counted.
  5. 5.Invariant: the scan reads one path down the tree, then only the leaves that hold its rows, plus at most one more key.
3

Count what was examined

Every scan counts the rows it hands up, and SELECT prints the count at the end.

  1. 1.A SeqScan hands up every row of the table, so its count is the table's size, whatever the Filter above it keeps.
  2. 2.An IndexScan's count is the number of rows it handed up.
  3. 3.After the SELECT's result, the footer in table mode and the last line in CSV mode, print [EXEC] rows_examined=<n>.
  4. 4.A statement that fails, an INSERT, and explain print no counter.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, sections 12 and 13.

InputOutput
explain select ... where id = <k>;[PLAN] <statement>, then [PLAN] └── IndexScan (table=users, key=<k>)
explain select ... where id > <k>; or id < <k>[PLAN] <statement>, then [PLAN] └── IndexScan (table=users, range: id > <k>) or range: id < <k>
explain select ... where name ... or email ...a Filter over a SeqScan, as before
any SELECT that runsits result, then [EXEC] rows_examined=<n>
an INSERT, explain, a statement that failsno [EXEC] line

What you should see when done

The file already holds rows 1 to 24, inserted in order in an earlier session.

shell
$droid --db users.db
Welcome to droid!
>explain select * from users where id = 20;
[PLAN] select * from users where id = 20
[PLAN] └── IndexScan (table=users, key=20)
>select * from users where id = 20;
id | name | email
20 | user20 | user20@example.com
(1 rows)
[EXEC] rows_examined=1
>explain select id from users where id > 14;
[PLAN] select id from users where id > 14
[PLAN] └── IndexScan (table=users, range: id > 14)
>select id from users where id > 14;
id
15
16
17
18
19
20
21
22
23
24
(10 rows)
[EXEC] rows_examined=10
>select id from users where name = 'user20';
id
20
(1 rows)
[EXEC] rows_examined=24
>select id from users where id < 3;
id
1
2
(2 rows)
[EXEC] rows_examined=2
>.exit
exiting.. good bye!

Row 20 found by its id examined 1 row. Found by its name, 24: same row, same output, and the counter is the only line that shows the difference.

Common bugs in this stage

  • Still filtering. The plan says IndexScan, but the node reads every row and checks each one. The rows are right and the counter says 24. An IndexScan starts at its first row and stops after its last.
  • Stopping one leaf early. A range reads to the end of the leaf where it started and stops, instead of following the link. id > 14 returns 15 and 16. Follow the leaf chain.
  • Counting the row that ends the range. id < 3 examines 3 instead of 2. Look at the key to decide, and count only the rows handed up.
  • Starting id > k at k. The search for k lands on k when it is there, and > must not include it. Start at the first key greater than k.
  • Equal keys going left. The descent must use the same rule as btree find: a key equal to a separator is on its right. Otherwise id = 13 searches the wrong leaf and finds nothing.
  • A Filter left above the IndexScan. It does no harm to the rows and fails explain, which shows one node.
  • An IndexScan for name. The tree is sorted by id only. A WHERE on name through the tree finds some rows and misses others.
  • The counter in the wrong place. Before the footer, missing in CSV mode, or printed after an error or after explain.

Language notes

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

  • All languages: the search that finds the position of a key, or of the first key greater than it, is the one btree find and INSERT already use. An IndexScan is that search plus the cursor you wrote for SELECT, started at the position the search returns.
  • C: the IndexScan node's state is a cursor, the operator, the key, and a flag for "done". Its next is a few lines, because the cursor already knows how to cross leaves.
  • C++: a second subclass of the node base class, beside SeqScan, with the same three virtual methods. The planner returns one or the other through the same std::unique_ptr to the base.
  • Rust: an IndexScan struct implementing the same trait as SeqScan, holding a cursor. If your cursor borrows the pager, the scan needs the same lifetime.
  • Zig: one more arm in the tagged union of nodes, or one more struct behind the same function table; the switch in next gains a case and nothing else changes.

Checklist

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

  • explain of a WHERE id = k shows one node, IndexScan (table=users, key=k)
    explain-index-scan-equals
  • explain of a WHERE id > k or id < k shows IndexScan (table=users, range: id > k)
    explain-index-scan-ranges
  • A WHERE on name or email is still a Filter over a SeqScan
    explain-other-columns-still-filter
  • A SeqScan examines every row, whatever the Filter keeps
    exec-counter-full-scan
  • WHERE id = k examines 1 row, or 0 when k is not there
    exec-counter-equals
  • A range examines only the rows in it, and not the key that ends it
    exec-counter-ranges
  • The IndexScan compares keys unsigned
    exec-counter-unsigned
  • On an empty table every scan examines 0
    exec-counter-empty-table
  • In a tree of depth 4, an IndexScan examines 10 rows where a full scan examines 150
    exec-counter-deep-tree
  • The counter follows the last row in CSV mode
    exec-counter-csv
  • No counter after an INSERT, an error or explain
    exec-counter-only-after-a-select
  • A WHERE on id plans an IndexScan and prints its row, examining 1
    index-scan-end-to-end
  • The IndexScan searches a tree read back from the file
    index-scan-after-restart