Repository navigation
Add internal/codec: compact value encoding and order-preserving key encoding - #1
Merged
Merged
Conversation
Splits encoding into two jobs with different goals. Values (varint, zigzag, length-prefixed) optimise for size; keys (fixed-width big-endian, sign-flipped) optimise for byte-comparable ordering, because the B+Tree in 0x06 compares keys with bytes.Compare and nothing else. Length prefixing fixes the item-boundary problem left by 0x02. Three fuzz targets cover round-tripping, key order preservation, and decoder robustness against arbitrary input; the varint implementation is verified byte-identical to encoding/binary. Also corrects the README's page-size justification: 4096 matches the filesystem block size, not the VM page size, which is 16 KB on Apple Silicon.
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.
Summary
Adds
internal/codec, the layer that converts Go values to bytes and back.It is split into two encoders with different goals, because a single encoding
cannot satisfy both:
value.gokey.gobytes.Comparevarint.goBackground
internal/pagecan store bytes but nothing marks where one stored item ends:Both items are unrecoverable. Separately, the B+Tree will compare keys using
bytes.Compareand nothing else — it has no type information — so the keyencoding is what determines whether range queries are correct.
Encoding is a file-format decision. Unlike a cache or an index implementation,
it cannot be changed after data exists without rewriting every file, so it is
settled before the tree is built rather than after.
Design
Why two encoders
The two requirements are in direct conflict.
Compactness wants the low bits first, so a small number can terminate early.
Sort-correctness wants the high bits first, so the first byte that differs is
the most significant one. Varint encoding demonstrates the conflict:
Compact and order-preserving are mutually exclusive properties of a single
encoding, so the package provides both and the caller picks by context: index
keys use
key.go, everything stored inside a record usesvalue.go.Value encoding
Length prefixing (
AppendBytes,AppendString) — length first, thenpayload:
Chosen over delimiters because a delimiter requires escaping any occurrence in
the data, and because a length prefix allows skipping an item without reading
it — a prerequisite for the slot directory the heap file will need.
Varints (
AppendUvarint) — 7 payload bits per byte plus a continuationflag:
Most integers a storage engine writes are bookkeeping — lengths, counts, slot
indices — and are small. A fixed 8 bytes for values under 128 costs 7 wasted
bytes per occurrence; a row with three variable-length fields wastes 21 bytes,
which at scale is both disk and read amplification, since fewer rows fit in a
4 KB page.
Cost: the maximum
uint64takes 10 bytes rather than 8.Zigzag (
zigzag,unzigzag) — required because two's complement makes-1all ones, which a varint encodes in the maximum 10 bytes. Zigzag maps signed to
unsigned so that magnitude, not sign, determines size:
v << 1doubles the value and frees the low bit.v >> 63is an arithmeticshift on a signed integer, producing
0for non-negative input and all-ones fornegative — a branchless sign mask. XOR with all-ones inverts, giving
2vfornon-negative and
2|v| - 1for negative.-1drops from 10 bytes to 1.Chosen over a separate sign flag because the result is an ordinary
uint64andreuses the existing varint encoder unchanged, with no branch in the hot path.
Key encoding
Big-endian (
AppendKeyUint64) —bytes.Compareis left-to-right,first-difference-wins, so the most significant byte must come first. Sorting
[1 2 255 256 300]by encoded bytes:Sign flip (
AppendKeyInt64) — big-endian alone is insufficient for signedvalues. Positives begin
00–7F, negatives begin80–FF, so the entirepositive range sorts below the entire negative range:
Each half is internally ordered; only the halves are transposed. XOR-ing the
sign bit swaps them:
Without this, a range scan over
-2..2starts atFF FF ... FEand ends at00 00 ... 02— the start is above the end, so the scan terminates immediatelyand returns zero rows with no error. This is a silent-wrong-answer failure mode,
which is why it is fixed before any index exists rather than found later.
Floats (
AppendKeyFloat64) — same approach with an extra case: invert thesign bit if positive, invert all bits if negative, since negative floats are
ordered in reverse by their bit pattern.
This is the standard technique; CockroachDB, FoundationDB and TiKV encode keys
the same way.
Testing
Table tests cover specific values, boundaries and truncation. Three fuzz targets
cover the general claims:
FuzzValueRoundTrip— encode/decode is lossless for all types, and thedecoder consumes exactly the bytes the encoder produced
FuzzKeyOrderingInt64— for alla,b: numeric ordering equalsbytes.Compareordering. ~3.9M pairs in 8s, no counterexampleFuzzDecoderNeverPanics— arbitrary input produces an error, never a panic.Required because corrupt pages will reach the decoder in production and must
surface as errors rather than crashes
TestVarintMatchesStdlibasserts our varint output is byte-identical toencoding/binaryacross boundary values.go vet,gofmt -l, andgo test -race ./...are clean.Behaviour change, via
make demo-encode:Size
Not in scope
and null bitmaps belong to the tuple layer and are not addressed here.
value; that requires a null bitmap in the record header.
AppendKeyStringis a passthrough, which is correct forsingle-column keys but ambiguous for composites:
("a","bc")and("ab","c")both encode to
abc. An escaping scheme is needed before multi-column indexesland.
Unrelated fix included
The README justified the 4096-byte page size as matching "the machine's
virtual-memory page size". That holds on x86-64 but not on Apple Silicon, which
uses 16 KB VM pages. The relevant alignment is the filesystem block size (4096
on APFS and ext4). Corrected.