Skip to content

Commit 246122a

Browse files
dfa1claude
andcommitted
fix: zero-extend U8/U16/U32 children in DateTimePartsArrays.readLong (#252)
U16 seconds-within-day values >= 32768 (tweets after 09:06 UTC) were widened as signed shorts (-22093 for seconds=43443), multiplied by unitsPerSecond=1_000_000, and produced a 65536-second error in the reconstructed timestamp. Added U-ptype detection via array.dtype() and use Byte/Short/Integer.toUnsignedLong for unsigned widening. bi-euro2016 flips back to ok. covid-world-vaccination-progress stays gap:253 (separate U32 vs I32 DType mismatch, different root cause). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c0ab5ee commit 246122a

4 files changed

Lines changed: 79 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- `ScanIterator.sliceArray` now handles `NullArray`; previously threw on Rust-written files where a null-typed column used a coarser chunk grid than the other columns. ([#247](https://github.com/dfa1/vortex-java/issues/247))
1515
- `AlpRdEncodingDecoder` now reads `left_parts_ptype` from metadata and rejects any value other than U16 with a `VortexException`; previously the field was silently ignored and U16 was hardcoded, risking a 2-byte stride over a 1-byte buffer for spec-conformant non-Rust writers. ([#249](https://github.com/dfa1/vortex-java/issues/249))
1616
- `SparseEncodingDecoder` now asserts exactly two children (`patch_indices`, `patch_values`) and throws `VortexException` on any other count; previously a third child was silently accepted. ([#250](https://github.com/dfa1/vortex-java/issues/250))
17+
- `DateTimePartsArrays.readLong` now zero-extends unsigned children (`U8`/`U16`/`U32`) instead of sign-extending them; previously a `U16` seconds-within-day value ≥ 32 768 was widened as a negative short, producing a 65 536-second error in the reconstructed timestamp (#252, bi-euro2016 corpus).
1718
- `RaincloudConformanceIntegrationTest` now writes both the vortex-java and Parquet oracle CSVs to temp files and streams them line-by-line for comparison; previously it materialized both into heap `String` objects, causing OOM for large corpus files (≥157 MB) that were previously hidden by throwing before reaching the corrupted line. ([#254](https://github.com/dfa1/vortex-java/issues/254))
1819

1920
## [0.12.1] — 2026-07-08

integration/src/test/resources/raincloud/expected-status.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ bi-cmsprovider,untriaged
2929
bi-commongovernment,untriaged
3030
bi-corporations,ok
3131
bi-eixo,untriaged
32-
bi-euro2016,gap:252
32+
bi-euro2016,ok
3333
bi-food,ok
3434
bi-generico,untriaged
3535
bi-hashtags,ok
Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,49 @@
11
package io.github.dfa1.vortex.reader.array;
22

33
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.PType;
46

57
/// Package-private helper for the [LazyDateTimePartsLongArray] record.
68
///
79
/// `days`, `seconds` and `subseconds` children can each be one of
8-
/// the four signed-integer typed array interfaces; the writer picks the narrowest
9-
/// ptype that fits. [#readLong(Array, long)] centralizes the per-row read so
10-
/// the record itself stays compact.
10+
/// the four integer-typed array interfaces (signed or unsigned); the writer picks
11+
/// the narrowest ptype that fits the value range. [#readLong(Array, long)]
12+
/// centralizes the per-row read so the record itself stays compact.
1113
final class DateTimePartsArrays {
1214

1315
private DateTimePartsArrays() {
1416
}
1517

16-
/// Reads `arr[i]` as a signed long. Recurses through [MaskedArray] to its
17-
/// raw payload without inspecting validity: the decoder intersects component
18-
/// validities into the reassembled array's own mask (#235), so a null row's
19-
/// filler value here is harmless — callers gate on that outer mask.
18+
/// Reads `arr[i]` as a widened long, respecting the child's signedness.
19+
///
20+
/// Unsigned children (`U8`, `U16`, `U32`) require zero-extension, not sign-extension.
21+
/// The Rust writer encodes seconds-within-day (0–86 399) as `U16` when values fit;
22+
/// without zero-extension, values ≥ 32 768 are misread as negative shorts and produce
23+
/// a `2^16`-second offset in the reassembled timestamp (#252).
24+
///
25+
/// Recurses through [MaskedArray] to its raw payload without inspecting validity:
26+
/// the decoder intersects component validities into the reassembled array's own mask
27+
/// (#235), so a null row's filler value here is harmless — callers gate on that outer mask.
2028
///
2129
/// @param arr source typed Array
2230
/// @param i row index
2331
/// @return cell value as long
2432
/// @throws VortexException for unsupported array types
2533
static long readLong(Array arr, long i) {
2634
return switch (arr) {
27-
case ByteArray a -> a.getByte(i);
28-
case ShortArray a -> a.getShort(i);
29-
case IntArray a -> a.getInt(i);
35+
case ByteArray a -> isUnsigned(a) ? Byte.toUnsignedLong(a.getByte(i)) : a.getByte(i);
36+
case ShortArray a -> isUnsigned(a) ? Short.toUnsignedLong(a.getShort(i)) : a.getShort(i);
37+
case IntArray a -> isUnsigned(a) ? Integer.toUnsignedLong(a.getInt(i)) : a.getInt(i);
3038
case LongArray a -> a.getLong(i);
3139
case MaskedArray a -> readLong(a.inner(), i);
3240
default -> throw new VortexException(
3341
"DateTimeParts: unsupported child array type: " + arr.getClass().getSimpleName());
3442
};
3543
}
44+
45+
private static boolean isUnsigned(Array a) {
46+
return a.dtype() instanceof DType.Primitive p
47+
&& (p.ptype() == PType.U8 || p.ptype() == PType.U16 || p.ptype() == PType.U32);
48+
}
3649
}

reader/src/test/java/io/github/dfa1/vortex/reader/array/DateTimePartsArraysTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package io.github.dfa1.vortex.reader.array;
22

33
import io.github.dfa1.vortex.core.error.VortexException;
4+
import io.github.dfa1.vortex.encoding.DTypes;
45
import org.junit.jupiter.api.Test;
56

7+
import java.lang.foreign.Arena;
8+
import java.lang.foreign.MemorySegment;
9+
import java.lang.foreign.ValueLayout;
10+
611
import static io.github.dfa1.vortex.reader.array.TestArrays.bools;
712
import static io.github.dfa1.vortex.reader.array.TestArrays.bytes;
813
import static io.github.dfa1.vortex.reader.array.TestArrays.doubles;
@@ -23,6 +28,55 @@ void readLong_dispatchesEveryIntegerArrayType() {
2328
assertThat(DateTimePartsArrays.readLong(longs(5_000_000_000L), 0)).isEqualTo(5_000_000_000L);
2429
}
2530

31+
/// Seconds-within-day are encoded as U16 when the range fits (0–65 535, which covers
32+
/// all seconds in a day up to 18:12:15 UTC). Without zero-extension, values ≥ 32 768
33+
/// are sign-extended to negative shorts and produce a 2^16-second error in the
34+
/// reconstructed timestamp (#252). Reproduces with seconds = 43 443 (≈ 12:04 UTC,
35+
/// the tweet time from bi-euro2016 line 262 146 that first surfaced the bug).
36+
@Test
37+
void readLong_u16ShortArray_zeroExtends() {
38+
// Given — U16 ShortArray with a value that overflows signed I16 (43443 > 32767)
39+
short value = (short) 43_443; // 0xA9B3 — fits U16, overflows I16 → -22093 if signed
40+
MemorySegment seg = Arena.ofAuto().allocate(2L, 2);
41+
seg.set(ValueLayout.JAVA_SHORT, 0, value);
42+
ShortArray u16 = new MaterializedShortArray(DTypes.U16, 1, seg.asReadOnly());
43+
44+
// When
45+
long result = DateTimePartsArrays.readLong(u16, 0);
46+
47+
// Then — zero-extended to 43443, NOT sign-extended to -22093
48+
assertThat(result).isEqualTo(43_443L);
49+
}
50+
51+
@Test
52+
void readLong_u8ByteArray_zeroExtends() {
53+
// Given — U8 ByteArray with value 200 (> 127, sign-extends to -56 if treated as I8)
54+
MemorySegment seg = Arena.ofAuto().allocate(1L, 1);
55+
seg.set(ValueLayout.JAVA_BYTE, 0, (byte) 200);
56+
ByteArray u8 = new MaterializedByteArray(DTypes.U8, 1, seg.asReadOnly());
57+
58+
// When
59+
long result = DateTimePartsArrays.readLong(u8, 0);
60+
61+
// Then
62+
assertThat(result).isEqualTo(200L);
63+
}
64+
65+
@Test
66+
void readLong_u32IntArray_zeroExtends() {
67+
// Given — U32 IntArray with value 2^31 + 1 (overflows signed I32)
68+
int value = Integer.MIN_VALUE + 1; // same bits as 2147483649 unsigned
69+
MemorySegment seg = Arena.ofAuto().allocate(4L, 4);
70+
seg.set(ValueLayout.JAVA_INT, 0, value);
71+
IntArray u32 = new MaterializedIntArray(DTypes.U32, 1, seg.asReadOnly());
72+
73+
// When
74+
long result = DateTimePartsArrays.readLong(u32, 0);
75+
76+
// Then — 2^31 + 1, not -2^31 + 1
77+
assertThat(result).isEqualTo(2_147_483_649L);
78+
}
79+
2680
@Test
2781
void readLong_recursesThroughValidMaskedCell() {
2882
// Given — a masked array whose cell is valid

0 commit comments

Comments
 (0)