Skip to content

Commit 2749b6c

Browse files
dfa1claude
andcommitted
feat(reader): IS NULL / IS NOT NULL chunk pruning via null_count
Writer now records per-segment null_count in the ArrayNode ArrayStats (the canonical fbs field Rust already writes), force-defaulting the build so null_count == 0 persists (flatbuffers omits default-0 otherwise; Rust writes 0 too). Reader parses it in ArrayStats.fromFbs. Adds RowFilter.isNull / isNotNull and the two canPruneChunk arms: IS NULL skips chunks with zero nulls, IS NOT NULL skips all-null chunks. Same chunk-level zone-map pruning machinery as min/max (not a row filter). NullCountPruningTest proves the right chunks are pruned; full writer/reader/cli/inspector suites + Rust JNI interop green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ab233b8 commit 2749b6c

5 files changed

Lines changed: 138 additions & 13 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public static ArrayStats empty() {
3434
}
3535

3636
/// Parses stats from a FlatBuffers [io.github.dfa1.vortex.fbs.ArrayStats] table.
37-
/// Returns an empty instance when `fbs` is `null` or carries no min/max.
37+
/// Returns an empty instance when `fbs` is `null` or carries no min/max and no null count.
3838
///
3939
/// @param fbs the FlatBuffers stats table, or `null`
4040
/// @return parsed stats, or an empty instance if no usable data is present
@@ -44,10 +44,11 @@ public static ArrayStats fromFbs(io.github.dfa1.vortex.fbs.ArrayStats fbs) {
4444
}
4545
Object min = decodeScalar(fbs.minAsByteBuffer());
4646
Object max = decodeScalar(fbs.maxAsByteBuffer());
47-
if (min == null && max == null) {
47+
Long nullCount = fbs.hasNullCount() ? fbs.nullCount() : null;
48+
if (min == null && max == null && nullCount == null) {
4849
return EMPTY;
4950
}
50-
return new ArrayStats(min, max, null, null, null, null);
51+
return new ArrayStats(min, max, null, nullCount, null, null);
5152
}
5253

5354
private static Object decodeScalar(ByteBuffer bytes) {

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
/// chunks where no row can satisfy the filter are skipped entirely.
77
public sealed interface RowFilter
88
permits RowFilter.And, RowFilter.Eq, RowFilter.Neq,
9-
RowFilter.Gt, RowFilter.Gte, RowFilter.Lt, RowFilter.Lte {
9+
RowFilter.Gt, RowFilter.Gte, RowFilter.Lt, RowFilter.Lte,
10+
RowFilter.IsNull, RowFilter.IsNotNull {
1011

1112
static RowFilter and(RowFilter... filters) {
1213
return new And(List.of(filters));
@@ -36,6 +37,14 @@ static RowFilter lte(String col, Comparable<?> val) {
3637
return new Lte(col, val);
3738
}
3839

40+
static RowFilter isNull(String col) {
41+
return new IsNull(col);
42+
}
43+
44+
static RowFilter isNotNull(String col) {
45+
return new IsNotNull(col);
46+
}
47+
3948
default RowFilter and(RowFilter other) {
4049
return new And(List.of(this, other));
4150
}
@@ -60,4 +69,10 @@ record Lt(String column, Comparable<?> value) implements RowFilter {
6069

6170
record Lte(String column, Comparable<?> value) implements RowFilter {
6271
}
72+
73+
record IsNull(String column) implements RowFilter {
74+
}
75+
76+
record IsNotNull(String column) implements RowFilter {
77+
}
6378
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,24 @@ private boolean canPruneChunk(ChunkSpec chunk, RowFilter filter) {
775775
yield false;
776776
}
777777
}
778+
case RowFilter.IsNull(var col) -> {
779+
Layout flat = chunk.layoutFor(col);
780+
if (flat == null) {
781+
yield false;
782+
}
783+
// Zero nulls in the chunk → no row is null → nothing can match IS NULL.
784+
Long nullCount = readFlatStats(flat).nullCount();
785+
yield nullCount != null && nullCount == 0;
786+
}
787+
case RowFilter.IsNotNull(var col) -> {
788+
Layout flat = chunk.layoutFor(col);
789+
if (flat == null) {
790+
yield false;
791+
}
792+
// Every row is null → no row is non-null → nothing can match IS NOT NULL.
793+
Long nullCount = readFlatStats(flat).nullCount();
794+
yield nullCount != null && nullCount == flat.rowCount();
795+
}
778796
};
779797
}
780798

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,8 @@ private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverr
555555
int segIdx = segs.size();
556556
long offset = bytesWritten;
557557

558-
ByteBuffer fbBuf = buildArrayFlatBuffer(result);
558+
long segNullCount = data instanceof NullableData nd ? countNulls(nd.validity()) : 0L;
559+
ByteBuffer fbBuf = buildArrayFlatBuffer(result, segNullCount);
559560

560561
// Segment format: [buffer data...] [FlatBuffer Array bytes] [4-byte LE u32 = fbLen]
561562
int fbLen = fbBuf.remaining();
@@ -571,7 +572,7 @@ private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverr
571572
segs.add(new SegRef(offset, bytesWritten - offset));
572573
lastStatsMin = result.statsMin();
573574
lastStatsMax = result.statsMax();
574-
lastNullCount = data instanceof NullableData nd ? countNulls(nd.validity()) : 0L;
575+
lastNullCount = segNullCount;
575576
return segIdx;
576577
}
577578
}
@@ -616,19 +617,28 @@ private void writePadding(int n) throws IOException {
616617
bytesWritten += n;
617618
}
618619

619-
private ByteBuffer buildArrayFlatBuffer(EncodeResult result) {
620+
private ByteBuffer buildArrayFlatBuffer(EncodeResult result, long nullCount) {
620621
var fbb = new FlatBufferBuilder(256);
621622

622-
// Stats for root node only (build before root ArrayNode)
623-
int statsOff = 0;
623+
// Stats for the root node only (build vectors before the ArrayStats table). null_count is
624+
// always recorded; min/max only when the encoder produced them.
625+
int minVec = result.hasStats()
626+
? io.github.dfa1.vortex.fbs.ArrayStats.createMinVector(fbb, result.statsMin()) : 0;
627+
int maxVec = result.hasStats()
628+
? io.github.dfa1.vortex.fbs.ArrayStats.createMaxVector(fbb, result.statsMax()) : 0;
629+
// forceDefaults only while building ArrayStats, so null_count = 0 is serialised (flatbuffers
630+
// omits a scalar equal to its default otherwise) — matching the Rust writer and letting the
631+
// reader prune IS NULL on zero-null chunks. Reset immediately so the Array/ArrayNode tables
632+
// keep their normal (offset-default-omitting) layout.
633+
fbb.forceDefaults(true);
634+
io.github.dfa1.vortex.fbs.ArrayStats.startArrayStats(fbb);
624635
if (result.hasStats()) {
625-
int minVec = io.github.dfa1.vortex.fbs.ArrayStats.createMinVector(fbb, result.statsMin());
626-
int maxVec = io.github.dfa1.vortex.fbs.ArrayStats.createMaxVector(fbb, result.statsMax());
627-
io.github.dfa1.vortex.fbs.ArrayStats.startArrayStats(fbb);
628636
io.github.dfa1.vortex.fbs.ArrayStats.addMin(fbb, minVec);
629637
io.github.dfa1.vortex.fbs.ArrayStats.addMax(fbb, maxVec);
630-
statsOff = io.github.dfa1.vortex.fbs.ArrayStats.endArrayStats(fbb);
631638
}
639+
io.github.dfa1.vortex.fbs.ArrayStats.addNullCount(fbb, nullCount);
640+
int statsOff = io.github.dfa1.vortex.fbs.ArrayStats.endArrayStats(fbb);
641+
fbb.forceDefaults(false);
632642

633643
int rootNodeOff = buildArrayNodeFlatBuffer(fbb, result.rootNode(), statsOff);
634644

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.github.dfa1.vortex.writer;
2+
3+
import io.github.dfa1.vortex.core.DType;
4+
import io.github.dfa1.vortex.core.PType;
5+
import io.github.dfa1.vortex.reader.RowFilter;
6+
import io.github.dfa1.vortex.reader.ScanOptions;
7+
import io.github.dfa1.vortex.reader.VortexReader;
8+
import io.github.dfa1.vortex.writer.encode.NullableData;
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.io.TempDir;
11+
12+
import java.io.IOException;
13+
import java.nio.channels.FileChannel;
14+
import java.nio.file.Path;
15+
import java.nio.file.StandardOpenOption;
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
22+
/// Verifies IS NULL / IS NOT NULL chunk pruning via per-chunk null_count in the ArrayNode stats.
23+
class NullCountPruningTest {
24+
25+
@TempDir
26+
Path tmp;
27+
28+
private static final DType.Struct SCHEMA = new DType.Struct(
29+
List.of("v"), List.of(new DType.Primitive(PType.I64, true)), false);
30+
31+
// chunkSize large so each writeChunk is exactly one chunk (one zone). Three chunks of distinct
32+
// sizes and null patterns: 3 rows / 0 nulls, 2 rows / 1 null, 4 rows / all null.
33+
private Path write() throws IOException {
34+
Path file = tmp.resolve("nulls.vtx");
35+
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false);
36+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
37+
var sut = VortexWriter.create(ch, SCHEMA, opts)) {
38+
sut.writeChunk(Map.of("v", new NullableData(
39+
new long[]{1, 2, 3}, new boolean[]{true, true, true})));
40+
sut.writeChunk(Map.of("v", new NullableData(
41+
new long[]{4, 0}, new boolean[]{true, false})));
42+
sut.writeChunk(Map.of("v", new NullableData(
43+
new long[]{0, 0, 0, 0}, new boolean[]{false, false, false, false})));
44+
}
45+
return file;
46+
}
47+
48+
private List<Long> scanRowCounts(Path file, RowFilter filter) throws IOException {
49+
var opts = new ScanOptions(List.of(), filter, ScanOptions.NO_LIMIT);
50+
var counts = new ArrayList<Long>();
51+
try (VortexReader reader = VortexReader.open(file);
52+
var iter = reader.scan(opts)) {
53+
iter.forEachRemaining(c -> counts.add(c.rowCount()));
54+
}
55+
return counts;
56+
}
57+
58+
@Test
59+
void isNull_prunesZeroNullChunks() throws IOException {
60+
// Given the 3-row chunk has zero nulls → pruned; the 1-null and all-null chunks survive
61+
Path file = write();
62+
63+
// When / Then surviving chunk sizes are 2 (1-null) and 4 (all-null)
64+
assertThat(scanRowCounts(file, RowFilter.isNull("v"))).containsExactly(2L, 4L);
65+
}
66+
67+
@Test
68+
void isNotNull_prunesAllNullChunks() throws IOException {
69+
// Given the 4-row chunk is all null → pruned; the 0-null and 1-null chunks survive
70+
Path file = write();
71+
72+
// When / Then surviving chunk sizes are 3 (0-null) and 2 (1-null)
73+
assertThat(scanRowCounts(file, RowFilter.isNotNull("v"))).containsExactly(3L, 2L);
74+
}
75+
76+
@Test
77+
void noFilter_keepsAllChunks() throws IOException {
78+
// Given / When / Then all three chunks survive
79+
assertThat(scanRowCounts(write(), null)).containsExactly(3L, 2L, 4L);
80+
}
81+
}

0 commit comments

Comments
 (0)