- Status: Proposed
- Date: 2026-06-13
- Deciders: project maintainer
- Related: TODO.md — domain primitives / Valhalla item, ADR 0005 — Vector API adoption
Throughout the public API, raw long and int values are used to represent
structurally distinct domain concepts. A caller cannot tell from a signature
alone whether a long parameter is a row count, a byte offset, a byte length,
or a U32 wire value widened to avoid sign issues. Misuse is a silent bug —
the compiler accepts any long in place of any other.
The same applies to unsigned integer types from the wire format. Java has no
native unsigned primitives. U8, U16, U32, and U64 are currently represented
as their widened signed counterparts (int, int, long, long) with
no enforcement of the unsigned range and no indication at the call site that
the value must be treated as unsigned.
The TODO.md item reads:
Use domain primitives (
UInt32,UInt64, etc.) as value classes via Project Valhalla instead of rawlong/int.
A companion article
argues that Valhalla's value class eliminates the historical heap-allocation
cost that forced domain primitives to stop at system boundaries. Until JEP 401
finalizes, identity-class wrappers (records) impose per-instance heap overhead
that makes them unsuitable in hot decode loops — but acceptable at API
boundaries where the type is created and consumed at most once per scan.
| Concept | Raw type today | Invariant | Appears in |
|---|---|---|---|
| Row count | long |
>= 0 |
Chunk.rowCount(), Layout.rowCount, ScanOptions.limit, ArrayStats.nullCount/trueCount, WriteOptions.chunkSize |
| Byte offset | long |
>= 0 |
SegmentSpec.offset(), VarBinArray.readOffset() |
| Byte length | long/int |
>= 0 |
SegmentSpec.length(), Trailer.postscriptLen |
| Element index | long |
>= 0, < array.length |
All array accessors (getLong(long i), getInt(long i), …) |
| U32 wire value | long (widened) |
0 <= v <= 0xFFFFFFFFL |
Array decode outputs, stats min/max for U32 columns |
| U64 wire value | long (reinterpreted) |
any 64-bit pattern; treat with Long.compareUnsigned |
Array decode outputs, stats min/max for U64 columns |
| Segment index | int |
>= 0 |
writeSegment() return, ChunkRef.segIdx |
| Cascade depth | int |
>= 0 |
WriteOptions.allowedCascading |
| Chunk size | int |
> 0 |
WriteOptions.chunkSize |
| Format version | int |
>= 0 |
Trailer.version, VortexHandle.version() |
JEP 401 (Value Classes and Objects) is a preview feature in Java 25. Enabling
it requires --enable-preview across all compile and runtime invocations,
which flows into every downstream consumer — identical deployment tax to the
--add-modules problem described in ADR 0005. value class is expected to
finalize in Java 26 or 27.
Wrap concepts that appear at public API boundaries, are created at most once
per scan (not per row), and where confusion with another long/int is a
realistic bug risk:
| Wrapper | Replaces | Invariant enforced |
|---|---|---|
record RowCount(long value) |
long row count |
value >= 0 |
record ByteOffset(long value) |
long offset |
value >= 0 |
record ByteLength(long value) |
long/int length |
value >= 0 |
RowCount is the highest-priority: it appears on Chunk.rowCount(),
Layout.rowCount, ScanOptions.limit, and ArrayStats, and is the most
likely to be silently swapped with a byte count or a limit.
U8 and U16 are widened to int — no ambiguity in practice.
U32 is widened to long. The range invariant (0 <= v <= 0xFFFFFFFFL) is
documented on the relevant accessor Javadoc. No wrapper yet; the value is
used in arithmetic and comparison contexts where a record would require
unwrapping on every operation.
U64 is reinterpreted as long (all 64 bits). Callers must use
Long.compareUnsigned, Long.toUnsignedString, Long.divideUnsigned, etc.
This convention is documented at the array accessor level. A UInt64 wrapper
encoding this requirement explicitly is the end state but is deferred (see
below).
Element indices (long i), segment indices (int), cascade depth (int),
chunk size (int), and format version (int) are not wrapped:
- Element indices appear in hot decode loops. A record wrapper would force
an unbox on every accessor call — unacceptable until
value classis available. - Segment index, cascade depth, chunk size, format version are internal or config values passed at most once per write/open. The confusion risk is low; wrapping adds friction with no proportionate safety gain.
When JEP 401 leaves preview (expected Java 26/27) and requires no
--enable-preview flag:
- Promote Tier 1 records to
value class— zero-cost in arrays and fields. - Add
value class UInt32andvalue class UInt64for unsigned wire types. - Re-evaluate element indices — if
value class ElementIndexhas the same footprint aslong, wrapping becomes viable for the accessor API.
This ADR will be superseded by a new ADR at that point.
RowCount,ByteOffset,ByteLengthcatch swap bugs at compile time.- Unsigned integer conventions are written down once, not rediscovered per reviewer.
- Valhalla migration path is explicit: records today → value classes when available.
- Tier 1 wrappers add
.value()unwrapping at call sites that feed arithmetic operations. Acceptable for boundary types; would not be acceptable for hot inner loops. - U64 as reinterpreted
longremains a footgun until Tier 4. Mitigation: Javadoc on every U64 accessor states the unsigned convention explicitly.
- If a caller passes a
RowCountto a method expecting aByteLength, they get a compile error (good). If they unwrap both and do arithmetic on rawlongs, the protection is lost. The wrappers defend the boundary, not internal computation. value classfinalization could slip beyond Java 27. Tier 1 records remain correct indefinitely; the cost is minor boxing at non-hot sites.
Wrap everything including element indices. Rejected: per-element record allocation in a 3M-row scan is measurable overhead and defeats the purpose of the FFM/zero-copy design.
Keep raw primitives, use parameter names only. Rejected: parameter names are invisible at call sites without IDE support and do not enforce invariants.
Use @NonNegative annotations (e.g., Checker Framework). Considered as a
complement, not a replacement. Annotations are not enforced by javac without
the Checker Framework on the compile path — adding that as a mandatory
dependency is too heavy. Wrappers provide the same guarantee with zero
additional tooling.
- TODO.md — domain primitives / Valhalla item
- Rethink Domain Primitives with Valhalla
- JEP 401: Value Classes and Objects — https://openjdk.org/jeps/401
- ADR 0005 — Vector API adoption — analogous deferral pattern for a preview API