Property testing the B-tree
Testing the B-tree against Rust's BTreeMap with random lists of operations.
A normal test checks one example: insert 1, 2, 3, then expect 1, 2, 3 back. B-tree bugs hide in rare situations, such as a merge right after a split, at the third level, while the page being merged has just been evicted from the buffer pool. Nobody writes that example by hand.
A property test states a rule that must hold for every input, then lets a library generate hundreds of random inputs. Fishbowl uses the proptest crate.
The rule: behave like BTreeMap
Rust's standard library has BTreeMap, a sorted map that lives in memory and
is known to be correct. Fishbowl's B+tree is also a sorted map, from rowid to
bytes. So the rule is: after any sequence of operations, the B+tree and a
BTreeMap given the same sequence must agree. The BTreeMap is called the
model.
Each test case is a random list of up to 1,000 operations: insert or replace a key with a value of random size (sometimes big enough to need overflow pages), delete a key, or look one up. Keys mostly come from a narrow range, so inserts often replace and deletes often find something. For each operation, both maps must give the same answer. At the end:
- A full scan must return exactly the model's contents, in order.
BTree::checkmust pass: sorted keys, correct signposts, every leaf at the same depth, an unbroken leaf chain.- Every page must be accounted for: in the tree, on the free list, or the header. None leaked, none used twice.
- Commits and rollbacks are mixed into the sequence. After a rollback, the tree must match the model as it was at the last commit.
- The pager commits, and a new pager reopens the files from their bytes. The contents must still match, which proves nothing committed lived only in memory.
This runs 256 times with a three-frame buffer pool, which forces constant
eviction between commits, and 256 times with the normal pool. Another test fills a tree and
deletes every key in random order, then checks that the tree is back to one
empty page. A fixed test builds a three-level tree, which random runs rarely
reach. A last property test runs random INSERT, UPDATE and DELETE
statements through SQL and compares the table with a plain list.
Shrinking
When a case fails, proptest does not just print 800 random operations. It shrinks them: it tries removing operations and making values smaller, keeping each change that still fails. The report is usually a handful of operations, small enough to trace by hand.
Testing the tests
A test suite that always passes might not be checking anything. So the tests were run against deliberately broken versions of the tree: one that forgets to relink leaves after a merge, one that puts the wrong signpost after borrowing, and a pager that drops changed pages instead of writing them back. Each break failed four of the five B-tree tests.
Code: crates/engine/tests/btree.rs and crates/engine/tests/storage.rs.