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.
- 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
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
| Statement | Each index of the table |
|---|---|
| INSERT | one new entry |
| DELETE | the row's entry removed, found exactly: its value and key are both in the row |
| UPDATE of the indexed column | the old entry removed, the new one inserted |
| UPDATE of other columns | nothing at all |
| TRUNCATE | emptied |
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
Write a row and its entries
One statement, one change: every index of the table follows the row.
- 1.Before writing anything, run every check. For each index, a new value longer than 255 bytes is 01604.
- 2.INSERT: write the row; then, for each index of the table, insert the entry (value, key).
- 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.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.If anything fails after the first write, the statement fails and section 34's rule takes back every page it changed, index pages included.
Find, then change
UPDATE and DELETE may now read an index while they write to it.
- 1.Run the scan of the UPDATE or DELETE to the end, and collect the table key of every row it keeps.
- 2.Only then change the rows, one key at a time, each looked up by its key.
- 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.
| Input | Output |
|---|---|
select id from t where name = 'bob'; after bob was renamed | id, (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
$droid --db shop.dbWelcome 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';id2(1 rows)[EXEC] rows_examined=1 index_lookups=1>index check idx[INDEX] Check idx: OK entries=2>.exitexiting.. 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 statusshows 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 itinsert-adds-entry
- UPDATE of the indexed column moves the row's entryupdate-moves-entry
- DELETE removes the row's entrydelete-removes-entry
- UPDATE and DELETE find their rows through the index, and change each oncedml-through-the-index
- ROLLBACK takes back what INSERT, UPDATE and DELETE did to the indexrollback-restores-index
- A statement that fails leaves the index as it wasfailed-statement-leaves-no-entry
- After a crash the index holds the committed rows and nothing elsecrash-keeps-index-consistent
- 2000 INSERTs split the index many times, and every value is still foundindex-splits
- With two indexes, one UPDATE that touches both columns keeps both truetwo-indexes-both-maintained
- An UPDATE of a column no index has writes no index pageunindexed-update-skips-index
- TRUNCATE empties the index, and it fills againtruncate-empties-index