Skip to content

fix(writer,reader): stop zone-map pruning from silently disabling itself - #381

Merged
dfa1 merged 3 commits into
mainfrom
fix/zone-map-pruning-378-379-380
Sep 12, 2026
Merged

fix(writer,reader): stop zone-map pruning from silently disabling itself#381
dfa1 merged 3 commits into
mainfrom
fix/zone-map-pruning-378-379-380

Conversation

@dfa1

@dfa1 dfa1 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

Three related bugs, one shared write-path root cause plus an independent read-path cost bug:

Fixed in response to review

A /code-review high pass on the initial commit surfaced two real correctness gaps, both now fixed (second commit):

  1. Zone-table lookup was mis-aligned for foreign files. The RowFilter zone-map pruning fetches each candidate chunk's full segment over HTTP just to check its stats #380 fast path indexed the decoded zone table by chunk ordinal unconditionally — correct for this writer's own vortex.stats (one zone per chunk, by construction), but Rust's vortex.zoned format uses a genuinely independent, uniform zone length with no 1:1 relationship to chunk boundaries. ScanIterator now 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) for vortex.zoned.
  2. MaskedEncodingEncoder computed 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 (a long[]/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/maxOf helpers remove duplicated stats-unpacking across the FOR/Dict encoders, and a test's // When result variable renamed to match convention.

Test plan

  • ./mvnw test -pl reader,writer — full unit suites green
  • FrameOfReferenceEncodingEncoderTest/DictEncodingEncoderTestencode()/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 behavior
  • WriterZoneMapHttpPruningTest — mocks HttpClient; 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 the windowStart/sliceOffsetFor derivation (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 the 0 placeholder)
  • JavaWritesRustReadsIntegrationTestjavaWriter_jniReader_zoneMapped_allNullChunkStillRoundTrips (new), javaWriter_rustReader_masked_nullableI64, javaWriter_rustReader_masked_nullableUtf8 all 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

dfa1 and others added 3 commits September 12, 2026 10:16
…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
@dfa1
dfa1 merged commit c83d60f into main Sep 12, 2026
6 checks passed
@dfa1
dfa1 deleted the fix/zone-map-pruning-378-379-380 branch September 12, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant