Skip to content

Commit b65e479

Browse files
dfa1claude
andcommitted
feat: implement vortex.zstd encode (Primitive + Utf8/Binary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5ea5817 commit b65e479

3 files changed

Lines changed: 222 additions & 9 deletions

File tree

TODO.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,21 @@
102102
| `vortex.list` | — | ❌ | ❌ | hard | needs list array model; unblocks `list.vortex` |
103103
| `vortex.listview` | — | ❌ | ❌ | hard | unblocks `listview.vortex` |
104104
| `vortex.fixed_size_list` | — | ❌ | ❌ | hard | unblocks `fixed_size_list.vortex` |
105-
| `vortex.zstd` | `ZstdEncoding` | ✅ | ❌ stub | — | Primitive, Utf8, Binary (no dict, no nullable); uses airlift/aircompressor |
105+
| `vortex.zstd` | `ZstdEncoding` | ✅ | ✅ | — | Primitive, Utf8, Binary (no dict, no nullable); uses airlift/aircompressor |
106+
107+
### `vortex.zstd` known limitations
108+
109+
- [ ] **Nullable arrays (decode)** — `ZstdEncoding.Decoder` throws when `node.children().length > 0` (validity child present).
110+
Fix: if child[0] exists, decode it as a validity bitmap (Bool array). Zstd stores only the _valid_ values compactly — after decompressing, scatter them back into a full-length array using the validity positions. For strings, reconstruct null slots as zero-length entries or handle at the `VarBinArray` level.
111+
112+
- [ ] **Dictionary support (decode)** — `ZstdEncoding.Decoder` throws when `metadata.dictionary_size != 0`.
113+
Fix: when `dictionary_size > 0`, buffer[0] is the dictionary bytes and frames start at buffer[1]. Pass dict bytes to `new ZstdDecompressor()` — check if aircompressor supports dictionary-mode decompression; if not, switch to `com.github.luben:zstd-jni` for this path only (native, dictionary-trained zstd is hard to replicate in pure Java).
114+
115+
- [ ] **Multi-frame encode** — `ZstdEncoding.Encoder` always produces a single frame for the whole array.
116+
Fix: accept a `valuesPerFrame` parameter (default: all values in one frame). Split the raw byte buffer at frame boundaries (`valuesPerFrame * byteWidth`), compress each slice independently, emit one `ZstdFrameMetadata` per frame. Enables partial decompression during slice scans.
117+
118+
- [ ] **Nullable arrays (encode)** — `ZstdEncoding.Encoder` has no null handling.
119+
Fix: accept nullable input (e.g. `Integer[]` or a validity mask alongside the data array). Strip null positions before compression. Encode the validity bitmap as a Bool child (child[0]) in the `EncodeNode`. Mirrors what Rust does: only valid values go into the compressed payload.
106120
107121
### S3 Fixture Status (`v0.72.0/arrays/`)
108122

core/src/main/java/io/github/dfa1/vortex/encoding/ZstdEncoding.java

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.dfa1.vortex.encoding;
22

33
import com.google.protobuf.InvalidProtocolBufferException;
4+
import io.airlift.compress.zstd.ZstdCompressor;
45
import io.airlift.compress.zstd.ZstdDecompressor;
56
import io.github.dfa1.vortex.core.ArrayStats;
67
import io.github.dfa1.vortex.core.DType;
@@ -17,11 +18,15 @@
1718
import io.github.dfa1.vortex.core.array.VarBinArray;
1819
import io.github.dfa1.vortex.proto.EncodingProtos;
1920

21+
import java.lang.foreign.Arena;
2022
import java.lang.foreign.MemorySegment;
2123
import java.lang.foreign.ValueLayout;
2224
import java.nio.ByteBuffer;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.Arrays;
27+
import java.util.List;
2328

24-
/// Decoder for {@code vortex.zstd} — Zstandard-compressed columnar array.
29+
/// Encoder/decoder for {@code vortex.zstd} — Zstandard-compressed columnar array.
2530
///
2631
/// <p>Wire format:
2732
/// <ul>
@@ -30,27 +35,152 @@
3035
/// <li>Child 0: validity bitmap (optional; this decoder rejects nullable arrays)</li>
3136
/// </ul>
3237
///
33-
/// <p>Primitive dtype: decompressed bytes are raw LE values → returned as typed primitive array.
34-
/// <p>Utf8/Binary dtype: decompressed bytes interleave {@code [u32-LE length][data]} per valid string.
38+
/// <p>Primitive dtype: raw LE values compressed into a single frame.
39+
/// <p>Utf8/Binary dtype: {@code [u32-LE length][data]} per string, compressed into a single frame.
3540
///
36-
/// <p>Scope: decode only, no dictionary, non-nullable.
41+
/// <p>No dictionary, no nullable (validity child not supported).
3742
public final class ZstdEncoding implements Encoding {
3843

3944
@Override
4045
public EncodingId encodingId() {
4146
return EncodingId.VORTEX_ZSTD;
4247
}
4348

49+
@Override
50+
public boolean accepts(DType dtype) {
51+
return dtype instanceof DType.Primitive || dtype instanceof DType.Utf8 || dtype instanceof DType.Binary;
52+
}
53+
4454
@Override
4555
public EncodeResult encode(DType dtype, Object data) {
46-
throw new UnsupportedOperationException("encode not supported by " + encodingId());
56+
return Encoder.encode(dtype, data);
4757
}
4858

4959
@Override
5060
public Array decode(DecodeContext ctx) {
5161
return Decoder.decode(ctx);
5262
}
5363

64+
private static final class Encoder {
65+
66+
private static EncodeResult encode(DType dtype, Object data) {
67+
if (dtype instanceof DType.Primitive dt) {
68+
return encodePrimitive(dt, data);
69+
}
70+
if (dtype instanceof DType.Utf8 || dtype instanceof DType.Binary) {
71+
return encodeVarBin(dtype, (String[]) data);
72+
}
73+
throw new VortexException(EncodingId.VORTEX_ZSTD, "unsupported dtype: " + dtype);
74+
}
75+
76+
private static EncodeResult encodePrimitive(DType.Primitive dt, Object data) {
77+
MemorySegment raw = primitiveToLeBytes(dt.ptype(), data);
78+
long n = primitiveLength(dt.ptype(), data);
79+
byte[] rawBytes = raw.toArray(ValueLayout.JAVA_BYTE);
80+
return buildResult(rawBytes, n);
81+
}
82+
83+
private static EncodeResult encodeVarBin(DType dtype, String[] strings) {
84+
byte[] raw = buildLengthPrefixed(strings);
85+
return buildResult(raw, strings.length);
86+
}
87+
88+
private static EncodeResult buildResult(byte[] raw, long n) {
89+
byte[] compressed = compress(raw);
90+
byte[] meta = EncodingProtos.ZstdMetadata.newBuilder()
91+
.setDictionarySize(0)
92+
.addFrames(EncodingProtos.ZstdFrameMetadata.newBuilder()
93+
.setUncompressedSize(raw.length)
94+
.setNValues(n))
95+
.build().toByteArray();
96+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ZSTD, ByteBuffer.wrap(meta),
97+
new EncodeNode[0], new int[]{0});
98+
return new EncodeResult(root, List.of(MemorySegment.ofArray(compressed)), null, null);
99+
}
100+
101+
private static byte[] compress(byte[] input) {
102+
ZstdCompressor compressor = new ZstdCompressor();
103+
byte[] out = new byte[compressor.maxCompressedLength(input.length)];
104+
int len = compressor.compress(input, 0, input.length, out, 0, out.length);
105+
return Arrays.copyOf(out, len);
106+
}
107+
108+
private static MemorySegment primitiveToLeBytes(PType ptype, Object data) {
109+
return switch (ptype) {
110+
case I8, U8 -> MemorySegment.ofArray((byte[]) data);
111+
case I16, U16, F16 -> {
112+
short[] arr = (short[]) data;
113+
MemorySegment seg = Arena.ofAuto().allocate((long) arr.length * 2, 2);
114+
for (int i = 0; i < arr.length; i++) {
115+
seg.setAtIndex(PTypeIO.LE_SHORT, i, arr[i]);
116+
}
117+
yield seg;
118+
}
119+
case I32, U32 -> {
120+
int[] arr = (int[]) data;
121+
MemorySegment seg = Arena.ofAuto().allocate((long) arr.length * 4, 4);
122+
for (int i = 0; i < arr.length; i++) {
123+
seg.setAtIndex(PTypeIO.LE_INT, i, arr[i]);
124+
}
125+
yield seg;
126+
}
127+
case I64, U64 -> {
128+
long[] arr = (long[]) data;
129+
MemorySegment seg = Arena.ofAuto().allocate((long) arr.length * 8, 8);
130+
for (int i = 0; i < arr.length; i++) {
131+
seg.setAtIndex(PTypeIO.LE_LONG, i, arr[i]);
132+
}
133+
yield seg;
134+
}
135+
case F32 -> {
136+
float[] arr = (float[]) data;
137+
MemorySegment seg = Arena.ofAuto().allocate((long) arr.length * 4, 4);
138+
for (int i = 0; i < arr.length; i++) {
139+
seg.setAtIndex(PTypeIO.LE_FLOAT, i, arr[i]);
140+
}
141+
yield seg;
142+
}
143+
case F64 -> {
144+
double[] arr = (double[]) data;
145+
MemorySegment seg = Arena.ofAuto().allocate((long) arr.length * 8, 8);
146+
for (int i = 0; i < arr.length; i++) {
147+
seg.setAtIndex(PTypeIO.LE_DOUBLE, i, arr[i]);
148+
}
149+
yield seg;
150+
}
151+
};
152+
}
153+
154+
private static long primitiveLength(PType ptype, Object data) {
155+
return switch (ptype) {
156+
case I8, U8 -> ((byte[]) data).length;
157+
case I16, U16, F16 -> ((short[]) data).length;
158+
case I32, U32 -> ((int[]) data).length;
159+
case F32 -> ((float[]) data).length;
160+
case I64, U64 -> ((long[]) data).length;
161+
case F64 -> ((double[]) data).length;
162+
};
163+
}
164+
165+
private static byte[] buildLengthPrefixed(String[] strings) {
166+
int total = 0;
167+
byte[][] encoded = new byte[strings.length][];
168+
for (int i = 0; i < strings.length; i++) {
169+
encoded[i] = strings[i].getBytes(StandardCharsets.UTF_8);
170+
total += 4 + encoded[i].length;
171+
}
172+
MemorySegment seg = Arena.ofAuto().allocate(total > 0 ? total : 1);
173+
long pos = 0;
174+
for (byte[] bytes : encoded) {
175+
seg.set(PTypeIO.LE_INT, pos, bytes.length);
176+
pos += 4;
177+
MemorySegment.copy(MemorySegment.ofArray(bytes), 0, seg, pos, bytes.length);
178+
pos += bytes.length;
179+
}
180+
return seg.asSlice(0, total).toArray(ValueLayout.JAVA_BYTE);
181+
}
182+
}
183+
54184
private static final class Decoder {
55185

56186
private static Array decode(DecodeContext ctx) {

core/src/test/java/io/github/dfa1/vortex/encoding/ZstdEncodingTest.java

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,82 @@ class ZstdEncodingTest {
3131
class Encode {
3232

3333
@Test
34-
void encode_throwsUnsupportedOperationException() {
34+
void encode_i32_roundTrips() {
35+
// Given
36+
var sut = new ZstdEncoding();
37+
int[] data = {10, 20, 30, 40};
38+
39+
// When
40+
EncodeResult result = sut.encode(I32, data);
41+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(result, data.length, I32, EncodingRegistry.empty());
42+
IntArray decoded = (IntArray) sut.decode(ctx);
43+
44+
// Then
45+
assertThat(decoded.length()).isEqualTo(data.length);
46+
for (int i = 0; i < data.length; i++) {
47+
assertThat(decoded.getInt(i)).as("index %d", i).isEqualTo(data[i]);
48+
}
49+
}
50+
51+
@Test
52+
void encode_i64_roundTrips() {
53+
// Given
54+
var sut = new ZstdEncoding();
55+
long[] data = {100L, 200L, 300L};
56+
57+
// When
58+
EncodeResult result = sut.encode(I64, data);
59+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(result, data.length, I64, EncodingRegistry.empty());
60+
LongArray decoded = (LongArray) sut.decode(ctx);
61+
62+
// Then
63+
assertThat(decoded.length()).isEqualTo(data.length);
64+
for (int i = 0; i < data.length; i++) {
65+
assertThat(decoded.getLong(i)).as("index %d", i).isEqualTo(data[i]);
66+
}
67+
}
68+
69+
@Test
70+
void encode_utf8_roundTrips() {
71+
// Given
72+
var sut = new ZstdEncoding();
73+
String[] data = {"hello", "world", "zstd"};
74+
75+
// When
76+
EncodeResult result = sut.encode(UTF8, data);
77+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(result, data.length, UTF8, EncodingRegistry.empty());
78+
VarBinArray decoded = (VarBinArray) sut.decode(ctx);
79+
80+
// Then
81+
assertThat(decoded.length()).isEqualTo(data.length);
82+
for (int i = 0; i < data.length; i++) {
83+
assertThat(decoded.getString(i)).as("index %d", i).isEqualTo(data[i]);
84+
}
85+
}
86+
87+
@Test
88+
void encode_emptyArray_roundTrips() {
89+
// Given
90+
var sut = new ZstdEncoding();
91+
int[] data = {};
92+
93+
// When
94+
EncodeResult result = sut.encode(I32, data);
95+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(result, data.length, I32, EncodingRegistry.empty());
96+
IntArray decoded = (IntArray) sut.decode(ctx);
97+
98+
// Then
99+
assertThat(decoded.length()).isZero();
100+
}
101+
102+
@Test
103+
void encode_unsupportedDtype_throwsVortexException() {
35104
// Given
36105
var sut = new ZstdEncoding();
37106

38107
// When / Then
39-
assertThatThrownBy(() -> sut.encode(I32, new int[]{1, 2, 3}))
40-
.isInstanceOf(UnsupportedOperationException.class);
108+
assertThatThrownBy(() -> sut.encode(new DType.Null(false), null))
109+
.isInstanceOf(VortexException.class);
41110
}
42111
}
43112

0 commit comments

Comments
 (0)