Repository navigation
Add internal/nullbits, and fix three key-encoding defects in codec - #2
Merged
Merged
Conversation
A field holding zero and a field never given a value encode identically today. Sentinel values cannot fix this, since every sentinel removes a legal value from the type's range; instead a bitmap in the record header carries one bit per field, ahead of the value area because a null field occupies no bytes and cannot be stepped over. Also adds SQL three-valued logic, where comparisons against NULL yield UNKNOWN and WHERE keeps only TRUE. Reviewing this change surfaced three defects in the previous PR's encoding, all silent-wrong-answer bugs: -0.0 and +0.0 encoded to maximally distant keys despite being numerically equal, NaN payloads were not canonicalised so two NaNs could be different keys, and Bytes returned a slice aliasing the caller's buffer, which would corrupt reads once the buffer pool recycles pages. Each is fixed with a regression test, plus a new FuzzKeyOrderingFloat64 target covering the class.
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/nullbits, which lets a record say "this field has no value" —and fixes three defects in
internal/codecfound while reviewing this change.Two pieces:
nullbits.gologic.goUNKNOWNPart 1 — Null bitmaps
The problem
A byte of zeros in a page is ambiguous:
"Balance is zero" and "balance was never recorded" are different facts, and a
record format has to distinguish them. The distinction lives in the on-disk
layout, so it has to be settled before rows exist.
Why not a sentinel value
The obvious approach is to reserve a magic value meaning "missing". It fails the
same way every time: the sentinel is drawn from the type's own range, so it
removes a value the column can legitimately hold.
-10MinInt64""MySQL's
0000-00-00date and the-1"no sensor reading" convention are thismistake in production. The information has to live outside the value's
encoding, not inside its range.
The design
One bit per field, in the record header, ahead of the value area.
Two properties, both load-bearing later:
A null field occupies zero bytes in the value area. There is nothing between
nameandscorein that record. The bitmap is not a description of the data;it is the only evidence the field exists at all.
Which forces the bitmap to precede the values. A reader cannot parse the
value area without first knowing which fields were skipped, because a skipped
field leaves no bytes to step over. This is why Postgres carries
t_bitsin thetuple header rather than beside the data.
One bit rather than one flag byte per field. A 32-column row spends 4 bytes
instead of 32, on every row.
Bit layout
Field
ioccupies biti % 8of bytei / 8, counting from the low bit:Pinned by
TestBitLayoutIsStable. This is a file format: if someone later"tidies" it to count from the high bit, every existing database becomes
unreadable, and the failure would look like data corruption rather than a code
change.
Padding bits
The bitmap is sized in whole bytes, so a 5-field record has three unused
trailing bits. The bitmap does not know the field count — the schema does.
Two consequences handled explicitly:
CountandAnytake the field count as an argument. Without it, a stray orcorrupt padding bit would be reported as a null field that does not exist.
Describe(fields)renders padding as-, so debug output distinguishes"present" from "not a field".
Out-of-range indices are ignored rather than panicking. A corrupt page will
reach this code eventually, and it must surface as an error upstream rather than
taking the process down.
Part 2 — Three-valued logic
SQL
NULLmeans unknown, not empty. A comparison against an unknown valueis not true and not false, so it needs a third answer.
The behaviour this produces surprises people, and is intended:
A predicate that holds for every number in existence still excludes the row,
because
WHEREkeepsTRUEonly and discardsUNKNOWN. The law of theexcluded middle does not hold in three-valued logic. This is also why
IS NULLexists as separate syntax —
= NULLcan never evaluate to true.Unknown does not blindly propagate.
FALSE AND UNKNOWNisFALSEandTRUE OR UNKNOWNisTRUE, because the result is already determined regardlessof the unknown operand. Both tables are asserted cell by cell rather than
derived, since a single wrong cell produces wrong query results rather than an
error.
Bool3normalises values outside{False, True, Unknown}toUNKNOWN. Beforethis, a garbage value fell through the
ANDguards and returnedTRUEwhileString()already reported it asUNKNOWN— an inconsistency that would benear-impossible to trace back from a wrong result.
Nullable integers are modelled as
*int64, withnilmeaning NULL. The callerhas to dereference, so they cannot silently treat a missing value as zero.
Part 3 — Defects fixed in
internal/codecFound by adversarial review of the previous PR's code. All three are the same
class: wrong answers with no error, which is the worst failure mode a
database has. Each now has a regression test.
-0.0and+0.0encoded to different keysIEEE-754 defines them as equal, but their bit patterns differ maximally. An
index would file
-0.0below every negative number and+0.0above everypositive one, so
WHERE balance = 0.0would miss rows stored as-0.0— avalue that arises naturally from
-1 * 0or from rounding a small negative.Zero is now normalised before encoding.
Worth noting why the existing tests missed it:
TestKeyOrderingFloat64includedboth zeros but asserted
Compare(a, b) <= 0, which is satisfied by any orderingof two equal values. The assertion was too weak to catch it.
NaN payloads were not canonicalised
NaN is not one bit pattern but roughly 2^53 of them. Two NaNs could become two
different index keys, so a lookup could miss. Now canonicalised, and they sort
above every real number, matching Postgres.
Bytesreturned a slice aliasing the caller's bufferHarmless today. Lethal once the buffer pool recycles page buffers, at which
point a decoded value would silently change, or point into an unrelated page,
after eviction — corruption that is very hard to reproduce.
Bytesnow copies.BytesRefis the explicit zero-copy variant for hot paths,where the name carries the warning.
Stringwas already safe, since Go'sstring()conversion copies.New fuzz target
FuzzKeyOrderingFloat64covers the class both key bugs were in — floats werethe only key type without a fuzzer. It asserts the full contract: NaN equals
NaN, NaN sorts above everything, and for every other pair numeric order equals
byte order, both zeros included.
Testing
39 tests passing.
go vet,gofmt -landgo test -race ./...clean.Four fuzz targets, all run clean after the encoding changes:
Notable tests:
TestBitLayoutIsStable— pins the on-disk bit positionsTestPaddingBitsDoNotCountAsFields— a set padding bit must not become a nullTestAndTable/TestOrTable— all nine cells of each truth tableTestExcludedMiddleDoesNotHold—(x = 30) OR (x != 30)isUNKNOWNforNULL
x, and aWHEREclause drops the rowTestNegativeZeroEncodesLikePositiveZero,TestAllNaNsEncodeIdentically,TestBytesDoesNotAliasSource— regressions for the three defects aboveSize
Not in scope
real encoder needs schema, field ordering and type dispatch, which belong to
the tuple layer.
t_bitsentirely in that case, saving a byte per row. Worth doing once the record
header has a flags field to signal it.
int64.EqualInt64andLessInt64demonstratethe semantics; a general comparison needs the type system.
but belongs with the slot directory in the heap file.
NOT NULLconstraints. A schema concern.