droid
Keeping Indexes True: DML, Rollback, Recovery
Indexes and the Optimizer · Stage 3 of 11

Keeping Indexes True: DML, Rollback, Recovery

Every write to a table is a write to each of its indexes, in the same statement

Make INSERT, UPDATE and DELETE add, move and remove index entries in the same statement as the row, so that every index agrees with its table after every statement: committed, rolled back, failed or cut short by a crash.

Not run yet · 11 tests
Concepts in this stage
  • An index that disagrees with its table gives wrong answers silently, which is worse than a crash
  • INSERT adds an entry, DELETE removes one, UPDATE of an indexed column moves one; other UPDATEs leave the index alone
  • Index pages are pages: the transaction and recovery code you already have protects them for free
  • A statement that fails must leave no entry behind, exactly as it leaves no row
  • Find every row first, then change them: a scan must never meet the entries its own statement wrote
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
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted 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 previous stage built an index once, from the rows a table had at that moment. The next INSERT makes it wrong.

A wrong index is the worst kind of bug a database can have. It does not crash. It answers where name = 'bob' with no rows while bob sits in the table, or with a row that is no longer bob, and nothing tells you. A full scan would have been right. So the rule of this stage is absolute: after every statement, every index holds exactly one entry per row of its table, with that row's value and key. index check tests exactly that, and the tests run it after commits, rollbacks, failures and crashes.

What each statement does to an index

Index on name, before: (ada,1) (bob,2) (cy,3) (dan,4)
header
cells = 4
cell 0
key = 1
cell 1
key = 2
cell 2
key = 3
cell 3
key = 4
free space
update t set name = 'zed' where id = 2: (bob,2) leaves, (zed,2) arrives at the end
header
cells = 4
cell 0
key = 1
cell 1
key = 3
moved
cell 2
key = 4
moved
cell 3
key = 2
new
free space
Each cell is 10 bytes. A cell that moves one position moves by 10 bytes.
Cells labelled by the table key of their entry. The row with key 2 did not move in the table; its entry moved in the index, because the index is ordered by name.
StatementEach index of the table
INSERTone new entry
DELETEthe row's entry removed, found exactly: its value and key are both in the row
UPDATE of the indexed columnthe old entry removed, the new one inserted
UPDATE of other columnsnothing at all
TRUNCATEemptied

The last-but-one line is worth the care. An UPDATE that does not change an indexed column must not touch that index: not rewrite the entry in place, not even read its pages for writing. tx status counts the pages a transaction changed, and the tests use it to check.

Transactions and crashes come for free

There is no new recovery code in this stage. An index's nodes are pages, and the transaction machinery from Transactions and Recovery works on pages:

  • ROLLBACK restores every page the transaction changed, index pages among them.
  • A statement that fails halfway, for instance an INSERT whose index entry went in before the table said the key already exists, takes its pages back by the same rule.
  • A crash loses the uncommitted pages and the log brings back the committed ones, index pages among them.

What the stage asks of you is to make sure every index write happens inside the statement, through the same pool and the same transaction as the table's write. An index written outside it, or after the commit, is the one recovery cannot fix.

Find every row, then change them

UPDATE and DELETE now find their rows through an index when their WHERE allows it. That opens an old trap. Take an index on name and:

update t set name = 'b' where name = 'a';

The scan walks the entries with value 'a'. Each UPDATE removes one and inserts an entry with value 'b'. If the scan and the writes are interleaved, the scan's position in the leaf can shift under it, or, with a range, the scan can meet the very entries the statement just wrote and update the same row again. IBM's System R team found this in 1976 with a query that gave everyone under a salary a raise and kept going until no one was under it; it is still called the Halloween problem.

The cure is to separate the two phases. Run the scan to the end and collect the keys of the rows it keeps. Then change each row by its key. The rows changed are those that matched when the statement began, each once. The contract makes this the rule for every UPDATE and DELETE, whatever its plan.

The algorithm in words

1

Write a row and its entries

One statement, one change: every index of the table follows the row.

  1. 1.Before writing anything, run every check. For each index, a new value longer than 255 bytes is 01604.
  2. 2.INSERT: write the row; then, for each index of the table, insert the entry (value, key).
  3. 3.DELETE: for each row, delete its entry from each index (you know the exact entry: the row's value and key), then delete the row.
  4. 4.UPDATE: for each index whose column the statement assigns and whose value really changes, delete the old entry and insert the new one; then write the row. An index whose column is not assigned is not touched.
  5. 5.If anything fails after the first write, the statement fails and section 34's rule takes back every page it changed, index pages included.
2

Find, then change

UPDATE and DELETE may now read an index while they write to it.

  1. 1.Run the scan of the UPDATE or DELETE to the end, and collect the table key of every row it keeps.
  2. 2.Only then change the rows, one key at a time, each looked up by its key.
  3. 3.The rows changed are exactly those that matched before the statement began, and each is changed once.

Output contract for this stage

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

InputOutput
select id from t where name = 'bob'; after bob was renamedid, (0 rows), [EXEC] rows_examined=0 index_lookups=0
update t set name = 'b' where name = 'a';Update statement executed successfully (2 rows), [EXEC] rows_examined=2 index_lookups=2
index check idx after any statement[INDEX] Check idx: OK entries=<rows of the table>
tx status after an UPDATE of an unindexed column of one row[TX] Status: IN_TRANSACTION pages=1
an INSERT or UPDATE of a value of more than 255 bytes into an indexed column[ERROR:01604] <message>, and nothing changes

There is no new syntax and no new code.

What you should see when done

shell
$droid --db shop.db
Welcome to droid!
>create table t (id int, name varchar(20), n int);
Create table statement executed successfully
>create index idx on t (name);
Create index statement executed successfully
>insert into t (id, name, n) values (1, 'ada', 3);
Insert statement executed successfully (1 rows)
>insert into t (id, name, n) values (2, 'bob', 5);
Insert statement executed successfully (1 rows)
>update t set name = 'zed' where id = 2;
Update statement executed successfully (1 rows)
[EXEC] rows_examined=1
>select id from t where name = 'bob';
id
(0 rows)
[EXEC] rows_examined=0 index_lookups=0
>begin;
Begin statement executed successfully
>delete from t where name = 'zed';
Delete statement executed successfully (1 rows)
[EXEC] rows_examined=1 index_lookups=1
>rollback;
Rollback statement executed successfully
>select id from t where name = 'zed';
id
2
(1 rows)
[EXEC] rows_examined=1 index_lookups=1
>index check idx
[INDEX] Check idx: OK entries=2
>.exit
exiting.. good bye!

Common bugs in this stage

  • Removing the old entry by value alone. With duplicates, that removes someone else's entry. Remove the exact entry: value and key.
  • Reading the old value after writing the new row. UPDATE needs the old value to find the old entry. Read the row before you change it.
  • Writing the index for every UPDATE. It works, and tx status shows the extra pages. Touch only the indexes whose column the statement assigns and whose value really changes.
  • Adding the entry before the checks. An INSERT that then fails on a duplicate key leaves a ghost entry unless the statement's pages are taken back. Check first, or make sure the failure path restores every page.
  • Scanning and writing at the same time. Collect the keys, then change the rows.
  • An index updated outside the transaction. ROLLBACK and recovery restore what went through the pool and the log, nothing else.

Language notes

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

  • C: collecting keys needs a growable array of 32-bit keys; a realloc-doubling buffer is enough.
  • C++: collect into a std::vector<uint32_t> before the write loop.
  • Rust: collecting the keys first also satisfies the borrow checker, which would refuse a mutable write to the tree while an iterator borrows it.
  • Zig: an std.ArrayList(u32) with the statement's allocator, freed when the statement ends.

Checklist

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

  • Rows inserted after CREATE INDEX are found through it
    insert-adds-entry
  • UPDATE of the indexed column moves the row's entry
    update-moves-entry
  • DELETE removes the row's entry
    delete-removes-entry
  • UPDATE and DELETE find their rows through the index, and change each once
    dml-through-the-index
  • ROLLBACK takes back what INSERT, UPDATE and DELETE did to the index
    rollback-restores-index
  • A statement that fails leaves the index as it was
    failed-statement-leaves-no-entry
  • After a crash the index holds the committed rows and nothing else
    crash-keeps-index-consistent
  • 2000 INSERTs split the index many times, and every value is still found
    index-splits
  • With two indexes, one UPDATE that touches both columns keeps both true
    two-indexes-both-maintained
  • An UPDATE of a column no index has writes no index page
    unindexed-update-skips-index
  • TRUNCATE empties the index, and it fills again
    truncate-empties-index