fix(writer,reader): stop zone-map pruning from silently disabling itself - #381
Merged
Conversation
…elf (#378, #379, #380) Zone-map MIN/MAX was all-or-nothing (one stats-less chunk, e.g. an all-null chunk, blanked pruning for the whole column) and FrameOfReferenceEncodingEncoder/DictEncodingEncoder's primitive path never surfaced MIN/MAX at all, so cascading compression silently lost pruning for any chunk it won. Fixed both to match Rust, which always stores zone-map stats per-zone nullable. Separately, ScanIterator's pruning check fetched each chunk's full data segment just to read its embedded stats, so checking a chunk over HTTP cost as much as reading it. It now reads the compact zone-map table instead (decoded once per column per scan). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mh2Mh6vemzhJ6knfo2eoME
…s found in review Code review of the #378/#379/#380 fix surfaced a real correctness gap: the zone-map pruning fast path indexed the decoded zone table by chunk ordinal for every layout, which only holds for this writer's own vortex.stats format (one zone per chunk, by construction); Rust's vortex.zoned format uses a genuinely independent, uniform zone length, so ordinal indexing could silently attribute a chunk to the wrong zone. ScanIterator now dispatches by layout id: ordinal lookup for our own vortex.stats, and a zone-length containment check (bailing unless a chunk's whole row range fits inside one zone) for vortex.zoned. Separately, comparing vortex-java's zone-map output against the real Rust reader (vortex-jni) on identical data surfaced a second, more serious bug: MaskedEncodingEncoder computed MIN/MAX from whichever inner encoder ran over the dense, placeholder-filled values array (a long[]/int[]/... has no way to represent "no value"), so a nullable column with any nulls could report a wrong non-null stat (e.g. 0) instead of excluding those rows — corrupting pruning and any consumer trusting the zone-map's actual values, not just losing pruning power. Now computed directly from (values, validity), excluding invalid rows. Also applies the review's minor findings: PrimitiveEncodingEncoder gains minOf/maxOf helpers to remove the stats-unpacking duplication across FrameOfReferenceEncodingEncoder/DictEncodingEncoder, and fixes a test's `// When` result naming to match convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mh2Mh6vemzhJ6knfo2eoME
…EncodingEncoder The compact-by-validity switch added to MaskedEncodingEncoder for the #378 masked-stats fix duplicated the same per-ptype array shape already scattered across PrimitiveEncodingEncoder.minMaxStats and ZoneMapStatCodec.statColumn. Moved it to core.compute.PrimitiveArrays (already the shared home for typed-array <-> long[] conversions used by both reader and writer) as a general-purpose operation, covering every primitive ptype including floats (toLongs/fromLongs are integer-only). MaskedEncodingEncoder now delegates instead of keeping its own copy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mh2Mh6vemzhJ6knfo2eoME
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three related bugs, one shared write-path root cause plus an independent read-path cost bug:
VortexWriter.flushZoneMapsrequired every chunk in a column to carry min/max stats before emitting the MIN/MAX zone-map fields at all. One stats-less chunk (e.g. an all-null chunk, common for optional string columns) silently dropped MIN/MAX for the entire column, not just that chunk. Confirmed against the real Rust reference (vortex-jni) that zone-map stats are always per-zone nullable there (vortex-layout/src/layouts/zoned/schema.rswraps every stat.as_nullable()) — this was a genuine spec deviation, not just an internal inconsistency. Fixed to mark only the offending zone invalid, matching Rust.FrameOfReferenceEncodingEncoderandDictEncodingEncoder's primitive path hardcodednullmin/max stats. Combined with the RowFilter zone-map pruning silently no-ops for Utf8 columns under the default globalDict=true #378 gate, any chunk the cascading ("better compression" /WriteOptions.cascading) path routed through FOR or Dict silently lost zone-map pruning for the whole column. Both now compute real stats via the existing sharedPrimitiveEncodingEncoder.minMaxStats.ScanIterator.canPruneChunkread each chunk's pruning stats viareadFlatStats, which fetches that chunk's entire data segment over HTTP just to read its embedded stats footer — the same bytes a real (non-pruned) read would fetch. So checking whether a chunk could be pruned cost as much as reading it. It now decodes the column's compactvortex.statszone-map table instead (once per column per scan, cached).Fixed in response to review
A
/code-review highpass on the initial commit surfaced two real correctness gaps, both now fixed (second commit):vortex.stats(one zone per chunk, by construction), but Rust'svortex.zonedformat uses a genuinely independent, uniform zone length with no 1:1 relationship to chunk boundaries.ScanIteratornow dispatches by layout id: ordinal lookup for our own format, and a zone-length containment check (bail unless a chunk's whole row range fits inside one zone) forvortex.zoned.MaskedEncodingEncodercomputed wrong (not just missing) stats for nullable columns. While verifying the RowFilter zone-map pruning silently no-ops for Utf8 columns under the default globalDict=true #378 fix by comparing vortex-java's zone-map output against the real Rust reader on identical data, found that a nullable column's MIN/MAX was computed over the dense, placeholder-filled values array (along[]/int[]/... has no way to represent "no value"), so a column with any nulls could report a wrong non-null stat (e.g.0) instead of excluding those rows — corrupting pruning and any consumer trusting the zone-map's actual values, not just losing pruning power. Now computed directly from(values, validity), excluding invalid rows.Also applied the review's minor findings:
PrimitiveEncodingEncoder.minOf/maxOfhelpers remove duplicated stats-unpacking across the FOR/Dict encoders, and a test's// Whenresult variable renamed to match convention.Test plan
./mvnw test -pl reader,writer— full unit suites greenFrameOfReferenceEncodingEncoderTest/DictEncodingEncoderTest—encode()/encodeCascade()now surface correct min/max (unordered input, so a broken read would surface a wrong value)WriterZoneMapTest.chunkWithoutStats_marksOnlyThatZoneInvalid— rewritten to assert the fixed per-zone-nullable behaviorWriterZoneMapHttpPruningTest— mocksHttpClient; filters so every chunk is pruned; asserts exactly 1 additional HTTP request (the shared zone-table fetch) instead of 4 (one per chunk, pre-fix)ScanIteratorChunkGridTest— pins both the chunk-ordinal invariant (our own format) and thewindowStart/sliceOffsetForderivation (foreign-format containment check)MaskedEncodingEncoderTest— a nullable column with a leading invalid slot and a placeholder-polluted value range reports the true min/max, excluding invalid rows; an all-invalid column reports no stats at all (not the0placeholder)JavaWritesRustReadsIntegrationTest—javaWriter_jniReader_zoneMapped_allNullChunkStillRoundTrips(new),javaWriter_rustReader_masked_nullableI64,javaWriter_rustReader_masked_nullableUtf8all pass against the real Rust reader (./mvnw verify -pl integration -am -Dit.test=...)Closes #378, closes #379, closes #380.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Mh2Mh6vemzhJ6knfo2eoME