Skip to content

Add internal/nullbits, and fix three key-encoding defects in codec - #2

Merged
suhailopensource merged 1 commit into
mainfrom
step/0x04-nulls
Aug 30, 2026
Merged

suhailopensource merged 1 commit into
mainfrom
step/0x04-nulls

Conversation

@suhailopensource

Copy link
Copy Markdown
Member

Summary

Adds internal/nullbits, which lets a record say "this field has no value" —
and fixes three defects in internal/codec found while reviewing this change.

Two pieces:

file what it provides
nullbits.go a null bitmap: one bit per field, stored in the record header
logic.go SQL three-valued logic, where a comparison can answer UNKNOWN

Part 1 — Null bitmaps

The problem

A byte of zeros in a page is ambiguous:

a slot holding the number 0:   00
an untouched slot:             00

"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.

sentinel no longer storable
-1 any legitimately negative value
0 a real zero: a balance, a count, a temperature
MinInt64 the smallest valid integer
"" an intentionally blank string

MySQL's 0000-00-00 date and the -1 "no sensor reading" convention are this
mistake 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.

field    value      stored?
id       42         yes
name     suhail     yes
age      NULL       NO - 0 bytes
email    NULL       NO - 0 bytes
score    9.1        yes

bitmap:   ..NN.---
record:   0C 54 06 73 75 68 61 69 6C 40 22 33 33 33 33 33 33
          ^^ header, then only the three present values

Two properties, both load-bearing later:

A null field occupies zero bytes in the value area. There is nothing between
name and score in 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_bits in the
tuple 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 i occupies bit i % 8 of byte i / 8, counting from the low bit:

field 0 -> 0x01
field 7 -> 0x80
field 8 -> second byte, 0x01

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:

  • Count and Any take the field count as an argument. Without it, a stray or
    corrupt 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 NULL means unknown, not empty. A comparison against an unknown value
is not true and not false, so it needs a third answer.

The behaviour this produces surprises people, and is intended:

age is NULL

age = 30                  -> UNKNOWN
age != 30                 -> UNKNOWN
age = 30 OR age != 30     -> UNKNOWN
WHERE keeps the row?      -> false

A predicate that holds for every number in existence still excludes the row,
because WHERE keeps TRUE only and discards UNKNOWN. The law of the
excluded middle does not hold in three-valued logic. This is also why IS NULL
exists as separate syntax — = NULL can never evaluate to true.

Unknown does not blindly propagate. FALSE AND UNKNOWN is FALSE and
TRUE OR UNKNOWN is TRUE, because the result is already determined regardless
of 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.

Bool3 normalises values outside {False, True, Unknown} to UNKNOWN. Before
this, a garbage value fell through the AND guards and returned TRUE while
String() already reported it as UNKNOWN — an inconsistency that would be
near-impossible to trace back from a wrong result.

Nullable integers are modelled as *int64, with nil meaning NULL. The caller
has to dereference, so they cannot silently treat a missing value as zero.


Part 3 — Defects fixed in internal/codec

Found 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.0 and +0.0 encoded to different keys

+0.0 encodes to  80 00 00 00 00 00 00 00
-0.0 encodes to  7F FF FF FF FF FF FF FF
numerically 0.0 == -0.0 ?  true

IEEE-754 defines them as equal, but their bit patterns differ maximally. An
index would file -0.0 below every negative number and +0.0 above every
positive one, so WHERE balance = 0.0 would miss rows stored as -0.0 — a
value that arises naturally from -1 * 0 or from rounding a small negative.

Zero is now normalised before encoding.

Worth noting why the existing tests missed it: TestKeyOrderingFloat64 included
both zeros but asserted Compare(a, b) <= 0, which is satisfied by any ordering
of 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.

Bytes returned a slice aliasing the caller's buffer

decoded:                            [1 2 3]
after mutating the source buffer:   [99 2 3]

Harmless 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.

Bytes now copies. BytesRef is the explicit zero-copy variant for hot paths,
where the name carries the warning. String was already safe, since Go's
string() conversion copies.

New fuzz target

FuzzKeyOrderingFloat64 covers the class both key bugs were in — floats were
the 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 -l and go test -race ./... clean.

Four fuzz targets, all run clean after the encoding changes:

FuzzValueRoundTrip       15s
FuzzKeyOrderingInt64     15s
FuzzDecoderNeverPanics   15s
FuzzKeyOrderingFloat64   20s   9,326,289 execs

Notable tests:

  • TestBitLayoutIsStable — pins the on-disk bit positions
  • TestPaddingBitsDoNotCountAsFields — a set padding bit must not become a null
  • TestAndTable / TestOrTable — all nine cells of each truth table
  • TestExcludedMiddleDoesNotHold — (x = 30) OR (x != 30) is UNKNOWN for
    NULL x, and a WHERE clause drops the row
  • TestNegativeZeroEncodesLikePositiveZero, TestAllNaNsEncodeIdentically,
    TestBytesDoesNotAliasSource — regressions for the three defects above

Size

nullbits.go        89
logic.go           85
nullbits_test.go  108
logic_test.go      72
regression_test.go (nullbits)  38
regression_test.go (codec)     72

Not in scope

  • Record assembly. The demo builds a record by hand to show the layout. A
    real encoder needs schema, field ordering and type dispatch, which belong to
    the tuple layer.
  • Omitting the bitmap when a row has no nulls. Postgres drops t_bits
    entirely in that case, saving a byte per row. Worth doing once the record
    header has a flags field to signal it.
  • Nullable types beyond int64. EqualInt64 and LessInt64 demonstrate
    the semantics; a general comparison needs the type system.
  • Tombstones. Marking a whole record deleted is the same idea one level up,
    but belongs with the slot directory in the heap file.
  • NOT NULL constraints. A schema concern.

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.
@suhailopensource
suhailopensource merged commit f8b1a23 into main Aug 30, 2026
1 check passed
@suhailopensource
suhailopensource deleted the step/0x04-nulls branch August 30, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant