Skip to content

Commit 5907302

Browse files
dfa1claude
andcommitted
feat(fbs): in-house FlatBuffers toolchain + MemorySegment-native wire format (ADR 0017)
See ADR 0017. Replaces flatc + com.google.flatbuffers with an in-house, MemorySegment-native FlatBuffers toolchain (fbs-gen + fbsrt runtime), and migrates every wire-format metadata path (Layout, decode/encode nodes, DType.Extension, zone-map stats, postscript plumbing) off java.nio.ByteBuffer. Unblocks JPMS (no upstream automatic module). Full unit suite + 275 Rust-interop integration tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7bd764d commit 5907302

261 files changed

Lines changed: 7627 additions & 5200 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,19 @@ Trunk-based. PRs fine but always squash or rebase — no merge commits. Keep com
6060
./bench JavaVsJniReadBenchmark.javaReadVolume # benchmark — always ClassName.methodName filter
6161
```
6262

63-
Regenerate after editing `.fbs`/`.proto`:
63+
Regenerate after editing `.fbs`/`.proto` (both generators are in-house, no external tools):
6464

6565
```bash
66-
brew install flatbuffers # only for .fbs edits (any flatc version; guard auto-stripped)
67-
./mvnw compile -pl proto-gen # only on .proto edits
66+
./mvnw compile -pl fbs-gen,proto-gen # build the generators
6867
./mvnw generate-sources -pl core -P regenerate-sources # then commit
6968
```
7069

71-
`flatc` runs whenever the profile is active; if you only changed `.proto`, revert spurious
72-
`fbs/` diffs: `git checkout -- core/src/main/java/io/github/dfa1/vortex/fbs/`. Proto-to-Java
73-
is in-process via `proto-gen` (no `protoc`/`protobuf-java`): one record per message with static
74-
`decode(MemorySegment, long, long)` + `encode()` operating directly on a segment.
70+
Both schema languages are compiled in-process to MemorySegment-native Java, with no
71+
`flatc`/`protoc` and no `com.google.flatbuffers`/`protobuf-java` runtime (ADR 0017):
72+
- **`.fbs``fbs-gen`** (`io.github.dfa1.vortex.fbsgen`): generates readers extending
73+
`FbsTable`/`FbsStruct` and builders over `FbsBuilder` (all in `io.github.dfa1.vortex.fbsrt`).
74+
- **`.proto``proto-gen`**: one record per message with static `decode(MemorySegment, long,
75+
long)` + `encode()` operating directly on a segment.
7576

7677
### Mutation testing
7778

cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,9 @@ private void indexStatsChildren(InspectorTree.Node node) {
191191
}
192192

193193
private static boolean hasLeadingStats(Layout layout) {
194-
java.nio.ByteBuffer meta = layout.metadata();
195-
return meta != null && meta.hasRemaining() && meta.get(meta.position()) == 1;
194+
MemorySegment meta = layout.metadata();
195+
return meta != null && meta.byteSize() > 0
196+
&& meta.get(java.lang.foreign.ValueLayout.JAVA_BYTE, 0) == 1;
196197
}
197198

198199
private void prefetchTopColumns() {
@@ -671,8 +672,7 @@ private void runStatsLoad(InspectorTree.Node anchor) {
671672
statsCache.put(anchor, new DataState.Failed("no column dtype"));
672673
return;
673674
}
674-
DType.Struct statsDtype = ZonedStatsSchema.statsTableDtype(
675-
columnDtype, anchorLayout.metadata());
675+
DType.Struct statsDtype = ZonedStatsSchema.statsTableDtype(columnDtype, anchorLayout.metadata());
676676
if (statsDtype.fieldNames().isEmpty()) {
677677
statsCache.put(anchor, new DataState.Failed("no stats present in metadata"));
678678
return;

core/pom.xml

Lines changed: 21 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@
1515
</description>
1616

1717
<dependencies>
18-
<!-- production -->
19-
<dependency>
20-
<groupId>com.google.flatbuffers</groupId>
21-
<artifactId>flatbuffers-java</artifactId>
22-
</dependency>
2318
<!-- testing -->
2419
<dependency>
2520
<groupId>org.junit.jupiter</groupId>
@@ -59,53 +54,51 @@
5954

6055
<!--
6156
Generated sources (src/main/java/…/fbs and …/proto) are committed to the repo.
62-
Normal builds need no external tools.
57+
Normal builds need no external tools — both generators are pure in-house Java.
6358
6459
To regenerate after editing .fbs or .proto schemas:
65-
brew install flatbuffers
60+
./mvnw compile -pl fbs-gen,proto-gen
6661
./mvnw generate-sources -pl core -P regenerate-sources
6762
Then commit the updated files.
68-
Any flatc version works — the profile strips the version guard automatically.
6963
-->
7064
<profiles>
7165
<profile>
7266
<id>regenerate-sources</id>
7367
<build>
7468
<plugins>
75-
<!-- 1. Generate Java from FlatBuffer schemas -->
69+
<!--
70+
Generate MemorySegment-native Java from the .fbs (FlatBuffers) and .proto
71+
(Protobuf) schemas via the in-house vortex-fbs-gen / vortex-proto-gen tools.
72+
73+
Pre-step: run `./mvnw compile -pl fbs-gen,proto-gen` once so these execs find
74+
the classes. We use direct execs (rather than declaring the generators as Maven
75+
deps) to avoid artificial provided-scope deps leaking into the published core POM.
76+
-->
7677
<plugin>
7778
<groupId>org.codehaus.mojo</groupId>
7879
<artifactId>exec-maven-plugin</artifactId>
7980
<executions>
8081
<execution>
81-
<id>flatc-generate</id>
82+
<id>fbsgen-generate</id>
8283
<phase>generate-sources</phase>
8384
<goals>
8485
<goal>exec</goal>
8586
</goals>
8687
<configuration>
87-
<executable>flatc</executable>
88-
<workingDirectory>${project.basedir}/src/main/flatbuffers</workingDirectory>
88+
<executable>java</executable>
8989
<arguments>
90-
<argument>--java</argument>
91-
<argument>-I</argument>
92-
<argument>.</argument>
93-
<argument>-o</argument>
94-
<argument>${project.basedir}/src/main/java</argument>
95-
<argument>vortex-file/footer.fbs</argument>
96-
<argument>vortex-layout/layout.fbs</argument>
97-
<argument>vortex-array/array.fbs</argument>
98-
<argument>vortex-dtype/dtype.fbs</argument>
90+
<argument>-cp</argument>
91+
<argument>${project.basedir}/../fbs-gen/target/classes</argument>
92+
<argument>io.github.dfa1.vortex.fbsgen.Main</argument>
93+
<argument>--out</argument>
94+
<argument>${project.basedir}/src/main/java/io/github/dfa1/vortex/fbs</argument>
95+
<argument>${project.basedir}/src/main/flatbuffers/vortex-array/array.fbs</argument>
96+
<argument>${project.basedir}/src/main/flatbuffers/vortex-dtype/dtype.fbs</argument>
97+
<argument>${project.basedir}/src/main/flatbuffers/vortex-file/footer.fbs</argument>
98+
<argument>${project.basedir}/src/main/flatbuffers/vortex-layout/layout.fbs</argument>
9999
</arguments>
100100
</configuration>
101101
</execution>
102-
<!--
103-
2. Generate MemorySegment-native Java from Protobuf schemas via vortex-proto-gen.
104-
105-
Pre-step: run `./mvnw compile -pl proto-gen` once so this exec finds the classes.
106-
We use a direct exec (rather than declaring vortex-proto-gen as a Maven dep) to avoid
107-
an artificial provided-scope dep that would leak into the published core POM.
108-
-->
109102
<execution>
110103
<id>protogen-generate</id>
111104
<phase>generate-sources</phase>
@@ -128,51 +121,6 @@
128121
</execution>
129122
</executions>
130123
</plugin>
131-
132-
<!--
133-
Why we strip ValidateVersion():
134-
flatc injects a `public static void ValidateVersion()` into every generated
135-
class that calls `Constants.FLATBUFFERS_25_X_Y()` — a method that only exists
136-
in the flatbuffers-java jar built from the *same* flatc release. This is a
137-
developer-experience guard: it prevents accidentally pairing a stale generated
138-
file with a newer runtime jar.
139-
140-
The problem: flatbuffers-java on Maven Central lags the flatc CLI by months.
141-
As of 2026-06, Maven Central tops out at 25.2.10 while `brew install flatbuffers`
142-
installs 25.12.19. Any attempt to compile freshly generated code against the
143-
Maven Central jar fails with "cannot find symbol: Constants.FLATBUFFERS_25_12_19".
144-
145-
The strip is safe because:
146-
- The FlatBuffers binary wire format is stable across releases; 25.2.10 can
147-
read bytes written by code generated with any 25.x flatc.
148-
- We commit the stripped files, so the check is irrelevant at compile time for
149-
normal builds (no flatc involved).
150-
- The strip only runs inside the `regenerate-sources` profile, not during
151-
normal `./mvnw verify` builds.
152-
153-
Re-evaluate if flatbuffers-java is ever published to Maven Central in lock-step
154-
with flatc releases — at that point the antrun step can be removed.
155-
-->
156-
<plugin>
157-
<groupId>org.apache.maven.plugins</groupId>
158-
<artifactId>maven-antrun-plugin</artifactId>
159-
<executions>
160-
<execution>
161-
<id>flatc-strip-version-guard</id>
162-
<phase>generate-sources</phase>
163-
<goals>
164-
<goal>run</goal>
165-
</goals>
166-
<configuration>
167-
<target>
168-
<replaceregexp match=" public static void ValidateVersion\(\) \{ Constants\.[^}]+\}&#xA;" replace="" flags="g">
169-
<fileset dir="${project.basedir}/src/main/java/io/github/dfa1/vortex/fbs" includes="*.java" />
170-
</replaceregexp>
171-
</target>
172-
</configuration>
173-
</execution>
174-
</executions>
175-
</plugin>
176124
</plugins>
177125
</build>
178126
</profile>

core/src/main/java/io/github/dfa1/vortex/core/DType.java

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package io.github.dfa1.vortex.core;
22

3-
import java.nio.ByteBuffer;
3+
import java.lang.foreign.MemorySegment;
44
import java.util.ArrayList;
55
import java.util.LinkedHashMap;
66

@@ -267,7 +267,7 @@ record FixedSizeList(DType elementType, int fixedSize, boolean nullable) impleme
267267
record Extension(
268268
String extensionId,
269269
DType storageDType,
270-
ByteBuffer metadata,
270+
MemorySegment metadata,
271271
boolean nullable
272272
) implements DType {
273273

@@ -280,11 +280,52 @@ record Extension(
280280
/// @throws VortexException if `metadata` carries more than
281281
/// [#MAX_METADATA_SIZE] readable bytes
282282
public Extension {
283-
if (metadata != null && metadata.remaining() > MAX_METADATA_SIZE) {
283+
if (metadata != null && metadata.byteSize() > MAX_METADATA_SIZE) {
284284
throw new VortexException("extension metadata too large: "
285-
+ metadata.remaining() + " > " + MAX_METADATA_SIZE);
285+
+ metadata.byteSize() + " > " + MAX_METADATA_SIZE);
286286
}
287287
}
288+
289+
/// Value equality with content-based metadata comparison.
290+
///
291+
/// [MemorySegment] uses identity equality, so the default record `equals` would
292+
/// treat a heap-built dtype and the same dtype read back from a mapped file as
293+
/// unequal. Extension is a value type (compared in schema round-trips and as a
294+
/// component of [Struct]/[List] dtypes), so metadata is compared by bytes —
295+
/// matching the content semantics the previous `ByteBuffer` component had.
296+
///
297+
/// @param o other object
298+
/// @return whether `o` is an Extension with equal id, storage, nullability and metadata bytes
299+
@Override
300+
public boolean equals(Object o) {
301+
if (this == o) {
302+
return true;
303+
}
304+
if (!(o instanceof Extension other)) {
305+
return false;
306+
}
307+
return nullable == other.nullable
308+
&& extensionId.equals(other.extensionId)
309+
&& storageDType.equals(other.storageDType)
310+
&& metadataEquals(metadata, other.metadata);
311+
}
312+
313+
/// @return a hash consistent with [#equals(Object)] (metadata hashed by content)
314+
@Override
315+
public int hashCode() {
316+
int h = java.util.Objects.hash(extensionId, storageDType, nullable);
317+
if (metadata != null) {
318+
h = 31 * h + java.util.Arrays.hashCode(metadata.toArray(java.lang.foreign.ValueLayout.JAVA_BYTE));
319+
}
320+
return h;
321+
}
322+
323+
private static boolean metadataEquals(MemorySegment a, MemorySegment b) {
324+
if (a == null || b == null) {
325+
return a == b;
326+
}
327+
return a.byteSize() == b.byteSize() && a.mismatch(b) == -1;
328+
}
288329
}
289330

290331
/// Variant logical type for semi-structured data (analogous to Parquet variant / JSON).

core/src/main/java/io/github/dfa1/vortex/extension/TimeDtype.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import io.github.dfa1.vortex.core.VortexException;
66
import io.github.dfa1.vortex.encoding.TimeUnit;
77

8-
import java.nio.ByteBuffer;
8+
import java.lang.foreign.MemorySegment;
9+
import java.lang.foreign.ValueLayout;
910

1011
/// Static factories and metadata accessors for `vortex.time` extension dtypes.
1112
///
@@ -36,8 +37,7 @@ public static DType.Extension of(TimeUnit unit, boolean nullable) {
3637
case Microseconds, Nanoseconds -> PType.I64;
3738
case Days -> throw new IllegalArgumentException("Days unit not valid for vortex.time");
3839
};
39-
ByteBuffer meta = ByteBuffer.allocate(1);
40-
meta.put(0, (byte) unit.ordinal());
40+
MemorySegment meta = MemorySegment.ofArray(new byte[]{(byte) unit.ordinal()});
4141
return new DType.Extension(
4242
ExtensionId.VORTEX_TIME.id(),
4343
new DType.Primitive(storage, nullable),
@@ -51,10 +51,10 @@ public static DType.Extension of(TimeUnit unit, boolean nullable) {
5151
/// @return the recorded time unit
5252
/// @throws VortexException if metadata is missing or empty
5353
public static TimeUnit readUnit(DType.Extension ext) {
54-
ByteBuffer meta = ext.metadata();
55-
if (meta == null || !meta.hasRemaining()) {
54+
MemorySegment meta = ext.metadata();
55+
if (meta == null || meta.byteSize() == 0) {
5656
throw new VortexException("missing TimeUnit metadata byte for " + ext.extensionId());
5757
}
58-
return TimeUnit.fromTag(meta.get(meta.position()));
58+
return TimeUnit.fromTag(meta.get(ValueLayout.JAVA_BYTE, 0));
5959
}
6060
}

core/src/main/java/io/github/dfa1/vortex/extension/TimestampDtype.java

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import io.github.dfa1.vortex.core.VortexException;
66
import io.github.dfa1.vortex.encoding.TimeUnit;
77

8-
import java.nio.ByteBuffer;
9-
import java.nio.ByteOrder;
8+
import static io.github.dfa1.vortex.encoding.PTypeIO.LE_SHORT;
9+
10+
import java.lang.foreign.MemorySegment;
11+
import java.lang.foreign.ValueLayout;
1012
import java.nio.charset.StandardCharsets;
1113
import java.time.ZoneId;
1214
import java.util.Optional;
@@ -37,12 +39,10 @@ public static DType.Extension of(boolean nullable) {
3739
/// @return matching extension dtype
3840
public static DType.Extension of(TimeUnit unit, ZoneId zone, boolean nullable) {
3941
byte[] tzBytes = zone == null ? new byte[0] : zone.getId().getBytes(StandardCharsets.UTF_8);
40-
ByteBuffer meta = ByteBuffer.allocate(3 + tzBytes.length).order(ByteOrder.LITTLE_ENDIAN);
41-
meta.put(0, (byte) unit.ordinal());
42-
meta.putShort(1, (short) tzBytes.length);
43-
for (int k = 0; k < tzBytes.length; k++) {
44-
meta.put(3 + k, tzBytes[k]);
45-
}
42+
MemorySegment meta = MemorySegment.ofArray(new byte[3 + tzBytes.length]);
43+
meta.set(ValueLayout.JAVA_BYTE, 0, (byte) unit.ordinal());
44+
meta.set(LE_SHORT, 1, (short) tzBytes.length);
45+
MemorySegment.copy(tzBytes, 0, meta, ValueLayout.JAVA_BYTE, 3, tzBytes.length);
4646
return new DType.Extension(
4747
ExtensionId.VORTEX_TIMESTAMP.id(),
4848
new DType.Primitive(PType.I64, nullable),
@@ -56,11 +56,11 @@ public static DType.Extension of(TimeUnit unit, ZoneId zone, boolean nullable) {
5656
/// @return the recorded time unit
5757
/// @throws VortexException if metadata is missing or empty
5858
public static TimeUnit readUnit(DType.Extension ext) {
59-
ByteBuffer meta = ext.metadata();
60-
if (meta == null || !meta.hasRemaining()) {
59+
MemorySegment meta = ext.metadata();
60+
if (meta == null || meta.byteSize() == 0) {
6161
throw new VortexException("missing TimeUnit metadata byte for " + ext.extensionId());
6262
}
63-
return TimeUnit.fromTag(meta.get(meta.position()));
63+
return TimeUnit.fromTag(meta.get(ValueLayout.JAVA_BYTE, 0));
6464
}
6565

6666
/// Reads the optional IANA timezone from the metadata's UTF-8 suffix.
@@ -69,25 +69,20 @@ public static TimeUnit readUnit(DType.Extension ext) {
6969
/// @return parsed zone id, or empty when no timezone is recorded
7070
/// @throws VortexException if the metadata is truncated mid-string
7171
public static Optional<ZoneId> timezone(DType.Extension ext) {
72-
ByteBuffer meta = ext.metadata();
73-
if (meta == null || meta.remaining() < 3) {
72+
MemorySegment meta = ext.metadata();
73+
if (meta == null || meta.byteSize() < 3) {
7474
return Optional.empty();
7575
}
76-
ByteBuffer le = meta.duplicate().order(ByteOrder.LITTLE_ENDIAN);
77-
int basePos = le.position();
78-
int tzLen = Short.toUnsignedInt(le.getShort(basePos + 1));
76+
int tzLen = Short.toUnsignedInt(meta.get(LE_SHORT, 1));
7977
if (tzLen == 0) {
8078
return Optional.empty();
8179
}
82-
if (le.remaining() < 3 + tzLen) {
80+
if (meta.byteSize() < 3 + tzLen) {
8381
throw new VortexException(
8482
"timestamp metadata truncated: declared tz_len="
85-
+ tzLen + " but only " + (le.remaining() - 3) + " bytes available");
86-
}
87-
byte[] tzBytes = new byte[tzLen];
88-
for (int k = 0; k < tzLen; k++) {
89-
tzBytes[k] = le.get(basePos + 3 + k);
83+
+ tzLen + " but only " + (meta.byteSize() - 3) + " bytes available");
9084
}
85+
byte[] tzBytes = meta.asSlice(3, tzLen).toArray(ValueLayout.JAVA_BYTE);
9186
return Optional.of(ZoneId.of(new String(tzBytes, StandardCharsets.UTF_8)));
9287
}
9388
}

0 commit comments

Comments
 (0)