Skip to content

Commit fc488d0

Browse files
dfa1claude
andcommitted
feat(reader): LayoutDecoder SPI — layout decode is pluggable
LayoutDecoder + LayoutRegistry in reader.layout mirror the ReadRegistry idiom (builder final-freeze, string-keyed dispatch, duplicate registration throws, no service file — programmatic registration like ExtensionDecoder). The four built-ins move out of ScanIterator verbatim: Flat, Chunked, Zoned (claims both the canonical vortex.zoned and legacy vortex.stats ids via the layoutIds() set), Dict. ScanIterator.decodeLayout is now one registry call; zone-map pruning and chunk planning keep inspecting built-ins only — the SPI covers full-column subtree decode. Wired end-to-end per the no-decorative-flags rule: VortexHandle gains layoutRegistry(), both readers take open(..., LayoutRegistry) overloads, and a scan through a custom registry is proven by test. Unknown layouts still fail loudly (Rust default). Reverses the "Layout is a fixed set, no SPI" design decision — the reference implementation treats layouts as runtime-pluggable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b0f0681 commit fc488d0

13 files changed

Lines changed: 849 additions & 253 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,18 @@ Benchmark classes follow this: `JavaVsJni{Read,Write,Filter}Benchmark`,
2323

2424
```
2525
core — everything lives under `io.github.dfa1.vortex.core.*`:
26-
core.model DType, PType, TimeUnit, EncodingId, ExtensionId, TimeDtype, TimestampDtype
26+
core.model DType, PType, TimeUnit, EncodingId, LayoutId, ExtensionId, TimeDtype, TimestampDtype
2727
core.io IoBounds, PTypeIO, VortexFormat
2828
core.error VortexException
2929
core.compute FastLanes, PrimitiveArrays
3030
core.fbs / core.proto — generated wire codecs + their runtimes
3131
reader — VortexReader, VortexHttpReader, VortexHandle, ReadRegistry, Chunk, ArrayStats,
32-
ScanOptions, RowFilter; file internals (Footer, Layout, Trailer,
33-
PostscriptParser, …)
32+
ScanOptions, RowFilter; file internals (Footer, Trailer, PostscriptParser, …)
3433
reader.array — Array + all subtypes (decode outputs)
3534
reader.decode — EncodingDecoder, DecodeContext, ArrayNode + *EncodingDecoder impls
3635
reader.extension — ExtensionDecoder + Date/Time/Timestamp/Uuid impls
36+
reader.layout — Layout, LayoutDecoder, LayoutDecodeContext, LayoutRegistry
37+
+ built-in *LayoutDecoder impls, ZonedStatsSchema
3738
writer — VortexWriter, WriteRegistry, WriteOptions, ExtensionEncoder
3839
writer.encode — EncodingEncoder, EncodeContext, NullableData + *EncodingEncoder impls,
3940
extension encoders
@@ -194,9 +195,13 @@ in the Rust source for the exact schema, then implement from spec.
194195
not add variants. Use `new DType.Extension("ip.address", new DType.Primitive(PType.I32, false),
195196
null, false)` and register decoders/encoders on the registries (or `ServiceLoader<ExtensionEncoder>`).
196197
Mirrors Rust (`vortex.date`, `vortex.uuid`, …). No SPI for DType variants planned.
197-
- **Layout is a fixed set, no SPI.** `ScanIterator.decodeLayout()` dispatches the known IDs
198-
(flat/chunked/zoned/struct/dict) and throws otherwise. Keep the fixed set; revisit only for a
199-
concrete downstream case unaddressable by a different flat-segment encoding.
198+
- **Layout decode is pluggable via `LayoutDecoder` + `LayoutRegistry`** (`reader.layout`) — the
199+
Rust reference registers layouts at runtime, so ours are open too. Builder-registered only
200+
(`LayoutRegistry.builder().registerDefaults().register(custom).build()`, pass to
201+
`VortexReader.open(path, readRegistry, layoutRegistry)`) — **no service file**. Unknown layouts
202+
fail loudly (`VortexException`, Rust default; no allowUnknown for layouts). Scope: the SPI covers
203+
full-column subtree decode; zone-map pruning, filtered scans, and chunk planning recognize the
204+
built-in layouts only.
200205
- **Small public APIs.** Don't expose internals — when in doubt, leave it out or make it private.
201206
- **POM deps** grouped with comments: `<!-- production -->` then `<!-- testing -->`, each with
202207
project-internal (`io.github.dfa1.vortex:*`) deps first, then external. Omit empty sections.

reader/src/main/java/io/github/dfa1/vortex/reader/ScanIterator.java

Lines changed: 26 additions & 241 deletions
Large diffs are not rendered by default.

reader/src/main/java/io/github/dfa1/vortex/reader/VortexHandle.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.dfa1.vortex.core.model.DType;
44
import io.github.dfa1.vortex.reader.array.Array;
55
import io.github.dfa1.vortex.reader.layout.Layout;
6+
import io.github.dfa1.vortex.reader.layout.LayoutRegistry;
67

78
import java.io.Closeable;
89
import java.lang.foreign.MemorySegment;
@@ -57,6 +58,13 @@ public interface VortexHandle extends Closeable {
5758
/// @return the registry used to resolve encoding ids during scan
5859
ReadRegistry registry();
5960

61+
/// Returns the [LayoutRegistry] this handle was opened with, dispatching full-column layout
62+
/// subtree decode. Defaults to [LayoutRegistry#defaults()] unless a custom registry was
63+
/// supplied at open time.
64+
///
65+
/// @return the registry used to decode layout nodes during scan
66+
LayoutRegistry layoutRegistry();
67+
6068
@Override
6169
void close();
6270
}

reader/src/main/java/io/github/dfa1/vortex/reader/VortexHttpReader.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.dfa1.vortex.core.io.VortexFormat;
77
import io.github.dfa1.vortex.core.fbs.FbsPostscript;
88
import io.github.dfa1.vortex.reader.layout.Layout;
9+
import io.github.dfa1.vortex.reader.layout.LayoutRegistry;
910

1011
import java.io.IOException;
1112
import java.lang.foreign.Arena;
@@ -47,11 +48,12 @@ public final class VortexHttpReader implements VortexHandle {
4748
private final DType dtype;
4849
private final Layout layout;
4950
private final ReadRegistry registry;
51+
private final LayoutRegistry layoutRegistry;
5052

5153
private VortexHttpReader(
5254
URI uri, HttpClient client, long fileSize,
5355
int version, Footer footer, DType dtype, Layout layout,
54-
ReadRegistry registry
56+
ReadRegistry registry, LayoutRegistry layoutRegistry
5557
) {
5658
this.uri = uri;
5759
this.client = client;
@@ -62,6 +64,7 @@ private VortexHttpReader(
6264
this.dtype = dtype;
6365
this.layout = layout;
6466
this.registry = registry;
67+
this.layoutRegistry = layoutRegistry;
6568
}
6669

6770
public static VortexHttpReader open(URI uri) throws IOException {
@@ -83,6 +86,20 @@ public static VortexHttpReader open(URI uri, ReadRegistry registry) throws IOExc
8386
/// @return an open handle to the remote file
8487
/// @throws IOException if the file cannot be opened or parsed
8588
public static VortexHttpReader open(URI uri, ReadRegistry registry, HttpClient client) throws IOException {
89+
return open(uri, registry, LayoutRegistry.defaults(), client);
90+
}
91+
92+
/// Opens a remote Vortex file with explicit encoding and layout registries and a
93+
/// caller-supplied [HttpClient].
94+
///
95+
/// @param uri HTTP(S) URL of the Vortex file
96+
/// @param registry the encoding decode registry
97+
/// @param layoutRegistry the layout decode registry (custom layouts register here)
98+
/// @param client HTTP client to use for all Range requests
99+
/// @return an open handle to the remote file
100+
/// @throws IOException if the file cannot be opened or parsed
101+
public static VortexHttpReader open(URI uri, ReadRegistry registry, LayoutRegistry layoutRegistry,
102+
HttpClient client) throws IOException {
86103
// Single suffix Range request — Content-Range response header gives us fileSize.
87104
// Avoids a separate HEAD round trip.
88105
TailFetch tf = fetchTail(uri, client);
@@ -133,7 +150,7 @@ public static VortexHttpReader open(URI uri, ReadRegistry registry, HttpClient c
133150
return new VortexHttpReader(
134151
uri, client, fileSize, trailer.version(),
135152
parsed.footer(), parsed.dtype(), parsed.layout(),
136-
registry
153+
registry, layoutRegistry
137154
);
138155
}
139156

@@ -312,6 +329,11 @@ public ReadRegistry registry() {
312329
return registry;
313330
}
314331

332+
@Override
333+
public LayoutRegistry layoutRegistry() {
334+
return layoutRegistry;
335+
}
336+
315337
@Override
316338
public void close() {
317339
arena.close();

reader/src/main/java/io/github/dfa1/vortex/reader/VortexReader.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.dfa1.vortex.core.error.VortexException;
77
import io.github.dfa1.vortex.core.io.VortexFormat;
88
import io.github.dfa1.vortex.reader.layout.Layout;
9+
import io.github.dfa1.vortex.reader.layout.LayoutRegistry;
910

1011
import java.io.IOException;
1112
import java.lang.foreign.Arena;
@@ -34,11 +35,12 @@ public final class VortexReader implements VortexHandle {
3435
private final DType dtype;
3536
private final Layout layout;
3637
private final ReadRegistry registry;
38+
private final LayoutRegistry layoutRegistry;
3739

3840
private VortexReader(
3941
Arena arena, MemorySegment fileSegment, long fileSize,
4042
int version, Footer footer, DType dtype, Layout layout,
41-
ReadRegistry registry
43+
ReadRegistry registry, LayoutRegistry layoutRegistry
4244
) {
4345
this.arena = arena;
4446
this.fileSegment = fileSegment;
@@ -48,6 +50,7 @@ private VortexReader(
4850
this.dtype = dtype;
4951
this.layout = layout;
5052
this.registry = registry;
53+
this.layoutRegistry = layoutRegistry;
5154
}
5255

5356
/// Open a Vortex file. Memory-maps the entire file; all subsequent reads
@@ -57,6 +60,19 @@ public static VortexReader open(Path path) throws IOException {
5760
}
5861

5962
public static VortexReader open(Path path, ReadRegistry registry) throws IOException {
63+
return open(path, registry, LayoutRegistry.defaults());
64+
}
65+
66+
/// Opens a Vortex file with explicit encoding and layout registries. Memory-maps the entire
67+
/// file; all subsequent reads are zero-copy slices. Call [#close()] when done.
68+
///
69+
/// @param path the file to open
70+
/// @param registry the encoding decode registry
71+
/// @param layoutRegistry the layout decode registry (custom layouts register here)
72+
/// @return an open handle to the file
73+
/// @throws IOException if the file cannot be opened or parsed
74+
public static VortexReader open(Path path, ReadRegistry registry, LayoutRegistry layoutRegistry)
75+
throws IOException {
6076
Arena arena = Arena.ofConfined();
6177
try (var channel = FileChannel.open(path, StandardOpenOption.READ)) {
6278
long size = channel.size();
@@ -67,15 +83,15 @@ public static VortexReader open(Path path, ReadRegistry registry) throws IOExcep
6783
// lifetime. try-with-resources closes the file descriptor while all Array
6884
// buffers remain valid zero-copy slices until arena.close() is called.
6985
var segment = channel.map(FileChannel.MapMode.READ_ONLY, 0, size, arena);
70-
return parse(segment, size, arena, registry);
86+
return parse(segment, size, arena, registry, layoutRegistry);
7187
} catch (Exception e) {
7288
arena.close();
7389
throw e;
7490
}
7591
}
7692

7793
private static VortexReader parse(
78-
MemorySegment seg, long size, Arena arena, ReadRegistry registry
94+
MemorySegment seg, long size, Arena arena, ReadRegistry registry, LayoutRegistry layoutRegistry
7995
) {
8096
long bodyBytes = size - VortexFormat.TRAILER_SIZE;
8197
var trailerSeg = IoBounds.slice(seg, bodyBytes, VortexFormat.TRAILER_SIZE);
@@ -96,7 +112,7 @@ private static VortexReader parse(
96112
return new VortexReader(
97113
arena, seg, size, trailer.version(),
98114
parsed.footer(), parsed.dtype(), parsed.layout(),
99-
registry
115+
registry, layoutRegistry
100116
);
101117
}
102118

@@ -160,6 +176,11 @@ public ReadRegistry registry() {
160176
return registry;
161177
}
162178

179+
@Override
180+
public LayoutRegistry layoutRegistry() {
181+
return layoutRegistry;
182+
}
183+
163184
/// Returns the number of chunks in this file.
164185
///
165186
/// Equal to the length of [ScanIterator#chunkRowCounts()] from a full scan, and the number
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package io.github.dfa1.vortex.reader.layout;
2+
3+
import io.github.dfa1.vortex.core.error.VortexException;
4+
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.core.model.EncodingId;
6+
import io.github.dfa1.vortex.core.model.LayoutId;
7+
import io.github.dfa1.vortex.core.model.PType;
8+
import io.github.dfa1.vortex.reader.array.Array;
9+
import io.github.dfa1.vortex.reader.array.ChunkedBoolArray;
10+
import io.github.dfa1.vortex.reader.array.ChunkedByteArray;
11+
import io.github.dfa1.vortex.reader.array.ChunkedDoubleArray;
12+
import io.github.dfa1.vortex.reader.array.ChunkedFloatArray;
13+
import io.github.dfa1.vortex.reader.array.ChunkedIntArray;
14+
import io.github.dfa1.vortex.reader.array.ChunkedLongArray;
15+
import io.github.dfa1.vortex.reader.array.ChunkedShortArray;
16+
import io.github.dfa1.vortex.reader.array.VarBinArray;
17+
18+
import java.lang.foreign.ValueLayout;
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
/// Built-in decoder for the `vortex.chunked` layout — a sequence of flat leaves decoded into the
23+
/// zero-copy `ChunkedXxxArray` shape (ADR 0012). Extracted verbatim from `ScanIterator`'s
24+
/// `collectFlats` + `decodeChunkedLayout`.
25+
final class ChunkedLayoutDecoder implements LayoutDecoder {
26+
27+
@Override
28+
public LayoutId layoutId() {
29+
return LayoutId.CHUNKED;
30+
}
31+
32+
@Override
33+
public Array decode(LayoutDecodeContext ctx, Layout layout, DType dtype) {
34+
var flats = new ArrayList<Layout>();
35+
collectFlats(layout, flats);
36+
return decodeChunkedLayout(ctx, flats, dtype, layout.rowCount());
37+
}
38+
39+
/// Flattens a layout subtree into its ordered flat (and dict) leaves. A private copy of
40+
/// `ScanIterator.collectFlats`: the scan keeps its own for chunk-shape planning, while the
41+
/// chunked decoder needs the same flattening to build the leaf array list.
42+
///
43+
/// @param layout the layout subtree to flatten
44+
/// @param out accumulator for the flat leaves in scan order
45+
private static void collectFlats(Layout layout, List<Layout> out) {
46+
if (layout.isFlat()) {
47+
out.add(layout);
48+
} else if (layout.isDict()) {
49+
// Dict layout is a leaf chunk — decoded as a unit (values + codes).
50+
out.add(layout);
51+
} else if (layout.isZoned()) {
52+
// vortex.stats wraps one child (the data layout) — pass through for data
53+
if (!layout.children().isEmpty()) {
54+
collectFlats(layout.children().getFirst(), out);
55+
}
56+
} else if (layout.isChunked()) {
57+
// metadata[0] == 1 means children[0] is the per-chunk stats layout; skip it
58+
int start = (layout.metadata() != null
59+
&& layout.metadata().byteSize() > 0
60+
&& layout.metadata().get(ValueLayout.JAVA_BYTE, 0) == 1) ? 1 : 0;
61+
for (int i = start; i < layout.children().size(); i++) {
62+
collectFlats(layout.children().get(i), out);
63+
}
64+
}
65+
}
66+
67+
private static Array decodeChunkedLayout(LayoutDecodeContext ctx, List<Layout> flats, DType dtype,
68+
long totalRows) {
69+
if (flats.isEmpty()) {
70+
throw new VortexException(EncodingId.VORTEX_CHUNKED, "no flat children");
71+
}
72+
if (flats.size() == 1) {
73+
return FlatLayoutDecoder.decodeFlat(ctx, flats.getFirst(), dtype);
74+
}
75+
// ADR 0012: every primitive ptype gets the zero-copy ChunkedXxxArray shape.
76+
// The concat path is gone.
77+
var chunkArrays = new ArrayList<Array>(flats.size());
78+
for (Layout flat : flats) {
79+
chunkArrays.add(FlatLayoutDecoder.decodeFlat(ctx, flat, dtype));
80+
}
81+
if (dtype instanceof DType.Bool) {
82+
return ChunkedBoolArray.of(dtype, totalRows, chunkArrays);
83+
}
84+
if (dtype instanceof DType.Utf8 || dtype instanceof DType.Binary) {
85+
return VarBinArray.ChunkedMode.of(dtype, totalRows, chunkArrays);
86+
}
87+
PType ptype = ((DType.Primitive) dtype).ptype();
88+
return switch (ptype) {
89+
case I64, U64 -> ChunkedLongArray.of(dtype, totalRows, chunkArrays);
90+
case I32, U32 -> ChunkedIntArray.of(dtype, totalRows, chunkArrays);
91+
case F64 -> ChunkedDoubleArray.of(dtype, totalRows, chunkArrays);
92+
case F32 -> ChunkedFloatArray.of(dtype, totalRows, chunkArrays);
93+
case I16, U16 -> ChunkedShortArray.of(dtype, totalRows, chunkArrays);
94+
case I8, U8 -> ChunkedByteArray.of(dtype, totalRows, chunkArrays);
95+
default -> throw new VortexException("unsupported ptype for chunked layout: " + ptype);
96+
};
97+
}
98+
}

0 commit comments

Comments
 (0)