Repository navigation
Add internal/nodepage: the on-disk byte layout of a B+Tree node - #9
Merged
Merged
Conversation
Every link in the Phase A tree is a Go pointer, which is a RAM address, which means the tree cannot survive the process that built it. This adds a byte-for-byte page format where a link is a page number instead: page 12 is at byte 12*4096, in this process and in the next one. The layout is a slotted page, the shape Postgres and SQLite use. A 16-byte page header, then an 8-byte node header holding the next-leaf or first-child link, the level, and two bytes of padding that keep the slot array 4-byte aligned. Slots grow up from byte 24 at 4 bytes each, cells grow down from byte 4095, and free space is whatever lies between. The point of the indirection is that the slot array is sorted while the cells are not. Inserting a key in the middle shifts a few 4-byte slots and never moves a key byte, so binary search stays O(log n) while writes stay cheap. Growing the two regions toward each other also avoids having to decide a split between them up front, which would be wrong for every key length but one. A leaf cell is key bytes followed by a 6-byte row id; an internal cell is key bytes followed by a 4-byte child. The key length is never stored because the suffix is fixed per kind. An internal node with n keys has n+1 children and the extra one has no slot to sit in, so it lives in the node header, sharing four bytes with the leaf's next pointer and disambiguated by the kind byte. Fanout is now computed rather than estimated. With 4096-byte pages and 8-byte keys an internal node holds 254 keys and 255 children, a leaf holds 226, and three levels reach 14,695,650 keys. This corrects the README, which said roughly 340 per internal node from 4080/(8+4). That ignored the node header and the 4-byte slot each entry needs; overhead is 25% of fanout. MaxKeys and Fanout compute it, and a test fills a real page for every key length from 1 to 64 to confirm the arithmetic. Validate checks twelve structural facts, the strongest being that the cells exactly tile the region from free end to the end of the page with no gaps and no overlaps, and that slots are in strictly ascending key order. Eleven deliberate corruptions are asserted to fail it. TestGoldenLeafLayout pins fourteen exact byte ranges so the format cannot drift silently. FuzzNodePageSurvivesArbitraryKeys ran 2,382,065 executions, and a round trip through a real page.File asserts the bytes read back are identical to the bytes written. The B+Tree does not use this yet. 116 tests, 10 fuzz targets, race clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nodes as pages
Why this is needed
The B+Tree from Phase A works and disappears the moment the process exits. Every
link inside it —
children []*node,next *node— is a Go pointer, which is aRAM address. Write one to disk, restart, and it points at whatever happens to
occupy that address now.
Page 12 is at byte
12 * 4096 = 49152— in this process, in the next one, and onanother machine. This change introduces
internal/nodepage, the byte layout thatmakes that swap possible. The tree does not use it yet; this is the format the
tree will be written in.
What is in the change
internal/nodepage/nodepage.gointernal/nodepage/describe.gointernal/nodepage/nodepage_test.gointernal/nodepage/fuzz_test.gocmd/scutedb-demo/nodepage.gomake demo-nodepagenodepageimportscoreandpageand nothing else. It is a second importer ofpage, alongsideslots.The layout
A slotted page — the same shape Postgres and SQLite use:
The page header is the existing 16 bytes from
0x02, untouched. The node headeradds 8: a 4-byte link, a 2-byte level, and 2 bytes of padding that exist so the
slot array starts at byte 24 and stays 4-byte aligned.
The link field holds two different things. In a leaf it is the next leaf;
in an internal node it is the leftmost child. They are never both needed, the
kindbyte says which one it is, and sharing saves 4 bytes on every node in thetree.
Next()andFirstChild()are two names for the same four bytes.How the code is organised
Making a node. Both constructors set
FreeStartto 24 rather than 16,because the node header sits between the page header and the slot array.
NewLeaf(id)NewInternal(id, level)Load(p page.Page)Nodeisstruct{ page.Page }— embedding, soID(),Kind(),FreeSpace()and the rest of the page methods are available on a node without redeclaring any
of them.
Reading a node.
KeyCount()Level()Next()/FirstChild()KeyRef(i)Key(i)Row(i)RowIDstored after key iChild(i)Child(0)comes from the headerSearch(key)ChildFor(key)The
Key/KeyRefsplit follows the same rulecodecandslotsalready use,and it matters more here: once a buffer pool recycles page buffers, a retained
view points at a different page's bytes.
Writing a node.
AppendLeaf(key, rid)key ‖ rowPage(4) ‖ rowSlot(2)AppendInternal(key, child)key ‖ childPage(4)Fits(keyLen)/EntrySize(keyLen)appendCellAppend is append, not insert: it writes the slot at the end of the array, so
the caller must supply keys in ascending order.
Validatecatches a caller thatdoes not. Ordered insertion into the middle belongs to the step that makes the
tree use this format, and building it now would be an untested guess at what that
step needs.
Checking a node.
Validate()verifies twelve facts and is called by nearlyevery test.
Reading it as a human.
Describe()prints the region map, a hexdump of theheaders, every field decoded with its offset, the slot array, the cells, and for
an internal node the full child list.
Hexdump(b, base)andFanoutTable(keyLen)are the pieces it is built from, usable on their own.
The flow of one lookup
This is the path
make demo-nodepagewalks, reading pages out of a real file:Every hop is a page number turned into a byte offset by
page.Offset(id), whichis one multiplication. Nothing in that path is a pointer.
Why the slot array exists
Two requirements pull against each other: keys must be in sorted order so search
can be binary, and key bytes must not move on every insert.
The slot array resolves it by sorting 4-byte references instead of the keys.
Inserting in the middle shifts a handful of slots; the key bytes stay wherever
they were first written. That is why the slot array is sorted while the cells are
in arrival order, and why
KeyRef(i)has to go through a slot rather thanindexing into the page directly.
Growing the two regions toward each other, from opposite ends, means neither
needs a size fixed in advance. They meet when the page is full, and
FreeSpace()— which is just
FreeEnd - FreeStartfrom the existing page header — is theentire bookkeeping. A fixed boundary between them would have to be guessed, and
would be wrong for every key length but one.
The key length is never stored, because the suffix is fixed per kind: a leaf key
is
cellLen - 6, an internal key iscellLen - 4.Fanout is computed, not chosen
This corrects a figure in the README.
0x06claimed roughly 340 keys perinternal node, from
4080 / (8 + 4). That ignored the 8-byte node header and the4-byte slot each entry needs. The real number is 254 — bookkeeping costs 25%
of the fanout. The estimate erred in the usual direction: it counted the data and
forgot the structure that makes the data findable.
MaxKeysandFanoutnowcompute it, and
TestMaxKeysMatchesWhatActuallyFitsfills a real page for everykey length from 1 to 64, for both kinds, and compares.
An internal node with n keys has n+1 children; the extra one has no slot to
sit in, which is what the header link is for on that side.
Reading it back without any of this code
Three pages written to a file, then read with
xxd:0000 000103btree-leaf0002002024 + 2 slots x 40fe40000 000200000ff2 000eSlot 0 names byte 4082 of page 1, which is file offset
4096 + 4082 = 8178:7F FF FF FF FF FF FF FFis the key -1, sign bit flipped by the ordered keyencoding from
0x03. Then0000 0064is row page 100, and0000is slot 0.Every layer built so far is visible in those fourteen bytes.
How the real engines do it
Postgres calls a page number a
BlockNumber, auint32index into the relationfile, and its B-Tree pages carry a
btpo_levelfield matching thelevelhere.SQLite uses 1-based 32-bit page numbers, page 1 always holding the schema. InnoDB
uses 32-bit page numbers inside a tablespace with 16 KB pages. None of them store
an address.
How it is verified
Validatechecks twelve structural facts. The strongest is that the cellsexactly tile the region from
free endto the end of the page — sorted byoffset, the first must start at
free end, each must end where the next begins,and the last must end at 4096. Gaps and overlaps are both rejected. It also
checks that slots are in strictly ascending key order, that
free startequals24 + 4 x count, that no cell is too short to hold its own suffix, that thepadding bytes are still zero, and that leaf-ness and level-0 agree.
the slot array, two slots aliasing one cell, a cell too short for a row id,
swapped slots, moved free pointers, a used padding byte, a leaf claiming a
level, a flipped kind byte.
TestGoldenLeafLayoutpins 14 exact byte ranges, so the format cannot driftwithout a test failing, and asserts the free space between the regions is still
all zeros.
TestRoundTripThroughARealFilewrites throughpage.File, reads back, andasserts the bytes are identical.
FuzzNodePageSurvivesArbitraryKeysran 2,382,065 executions building pagesfrom arbitrary keys at arbitrary widths, validating and reading back each one.
116 tests, 10 fuzz targets,
go test -raceclean,go vetclean.Not in this change
internal/btreestill uses pointers; wiring it to this format — splitting andmerging pages rather than structs — is the next step. No free list, no buffer
pool, and the reserved header bytes still hold no checksum.
Run the walkthrough with
make demo-nodepage.