droid
Secondary Indexes Under Versions
Sessions and Concurrency · Stage 9 of 11

Secondary Indexes Under Versions

Entries carry no version: mark the old one, add the new one, and check every hit against the row

Keep index entries free of versions. A changed indexed value marks its old entry and adds a new one; a DELETE marks its entries; purge takes marked entries out. Every row found through an index is rebuilt for the reader and kept only if its visible version still has the entry's value.

Not run yet · 7 tests
Concepts in this stage
  • An index entry has no version: it is a value and a key
  • Changing an indexed value leaves two entries for one row: the old one marked
  • A hit through the index is a candidate; the row's visible version decides
  • An index-only scan trusts the index only when nothing about the row is recent
  • A unique value is free again once its delete has committed
Where this stage sits in the engine
Talking to the user
REPLSessionsPrinter
Server
Wire protocol
Understanding SQL
LexerParserValidator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead logUndo logLock 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

Read views rebuild old rows from undo, but they reach rows through the table's key. A secondary index is a second tree, keyed by value. Move a person from city a to city b, and the index on city loses (a, 1) and gains (b, 1). A reader whose view predates the move asks for city a: the entry is gone, so the index says nobody lives there, while the same reader's full scan finds person 1 in a. Two plans, two answers.

Entries have no version

One fix is to put versions in the index: each entry carrying the transactions that created and deleted it. Then every index write must also maintain those versions, every index grows with old values, and every update writes all of them. InnoDB does not do this, and neither does this engine.

Instead, an entry stays just a value and a key, and the index keeps old entries around, marked, the way the table keeps deleted rows:

StatementEach index of the table
INSERTadds the entry, or unmarks a marked one with the same value and key
UPDATE that changes the valuemarks the old entry, adds the new one
DELETEmarks the entry
undotakes the change back
purgeremoves the marked entries of the changes it drops

After the move, the index holds (a, 1) marked and (b, 1). Both point at row 1.

Every hit is checked

Because entries carry no version, an entry is only a candidate. For each entry in range, marked or not, the reader rebuilds the version of the row it may see, and keeps the row only if that version's value is the entry's value:

index on city after the move: (a,1) marked, (b,1)
header
cells = 2
cell 0
key = 1
cell 1
key = 1
new
free space
old view, city = 'a': (a,1) leads to row 1, its visible version has a: kept
header
cells = 1
cell 0
key = 1
free space
old view, city = 'b': (b,1) leads to row 1, its visible version has a: rejected
header
cells = 1
cell 0
key = 1
free space
Each cell is 10 bytes. A cell that moves one position moves by 10 bytes.
Cells labelled by the row's key. The entry finds the row; the row's visible version decides.

So the old view finds person 1 in a and nobody in b, and a new view the opposite, and each row is handed up at most once, since its visible version has one value.

An index-only scan answers from the index without reading the table. That is only safe when the entry cannot be newer than the reader: it is not marked and the row has no kept undo record, so every view sees the same thing. Otherwise the scan reads the row like any index scan.

A unique index checks values the same way: a value is taken by an entry that is not marked, or by a marked one whose marking transaction is another transaction that has not committed. A transaction's own deletion frees the value for its own INSERT. Delete a row and insert its unique value again, with an old view still open, and the insert is accepted: the old entry is marked by a committed delete.

The algorithm in words

1

Writing an index

Mark, never remove, while a view might look.

  1. 1.INSERT: add the row's entry, or, if an entry with the same value and key is marked, unmark it.
  2. 2.UPDATE that changes the indexed value: mark the old entry, then add the new one as an INSERT would.
  3. 3.DELETE: mark the row's entries.
  4. 4.Undo takes each of these back: unmark, or remove the entry it added. Purge removes the marked entries of the changes it drops.
2

Reading through an index

Every entry in range is a candidate.

  1. 1.Walk the entries in the scan's bounds, marked or not.
  2. 2.For each, rebuild the version of its row the reader sees (section 87).
  3. 3.Hand the row up only if that version exists, is not a deletion, and has the entry's value.
  4. 4.An index-only scan may skip the table only for an entry that is not marked and whose row has no kept undo record.

Output contract for this stage

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

InputOutput
index check <name> with marked entries[INDEX] Check <name>: OK entries=<n>, marked entries counted in n. It is OK when each row that is not marked has exactly one unmarked entry, with its newest value and its key, every unmarked entry has such a row, and the entries are in order

What you should see when done

shell
$droid --db m.db
Welcome to droid!
>create table people (id int primary key, city varchar(20));
Create table statement executed successfully
>insert into people (id, city) values (1, 'a');
Insert statement executed successfully (1 rows)
>create index by_city on people (city);
Create index statement executed successfully
>@1 begin;
@1| Begin statement executed successfully
>@1 select * from people where city = 'a';
@1| id | city
@1| 1 | a
@1| (1 rows)
@1| [EXEC] rows_examined=1 index_lookups=0
>@2 update people set city = 'b' where id = 1;
@2| Update statement executed successfully (1 rows)
@2| [EXEC] rows_examined=1
>@1 select * from people where city = 'b';
@1| id | city
@1| (0 rows)
@1| [EXEC] rows_examined=0 index_lookups=0
>index check by_city
[INDEX] Check by_city: OK entries=2
>.exit
exiting.. good bye!

Common bugs in this stage

  • Removing the old entry on UPDATE. An old view can no longer find the row by its old value.
  • Trusting a hit. Rebuild the row and compare its value with the entry's.
  • Handing a row up twice when both its old and new entries are in range. The value check keeps one.
  • Counting a marked entry as taken in a unique index after its delete committed.
  • Index-only answers from a marked entry. Read the table.

Language notes

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

  • C: reuse the slot-length bit helper from read views for index leaves; the entry's value bytes are compared with memcmp against the rebuilt row's column.
  • C++: a bool marked() accessor on the index cell type keeps the bit twiddling in one place.
  • Rust: give the index cursor an iterator adapter that yields candidates and a second that filters them by the rebuilt version.
  • Zig: the same filter as an inline function over the cursor's next(), returning null for rejected entries.

Checklist

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

  • An old view finds a row by its old value and not its new one; a new view the opposite
    old-and-new-readers
  • Under an old view, the index and a full scan agree
    index-and-scan-agree
  • Deleting and re-inserting a row with the same value finds it once
    delete-reinsert-same-value
  • Rolling back a change of an indexed value restores the entries
    rollback-restores-entries
  • A unique value can be used again once its delete has committed
    unique-under-versions
  • An index-only scan reads the table when an entry may be newer than the view
    index-only-recheck
  • index check counts marked entries until purge takes them out
    marked-entries-counted