Summary
With WriteOptions.cascading(depth) for any depth > 0, every numeric column I tested loses its zone-map min/max statistics entirely — only sum survives. This silently breaks RowFilter-driven zone-map pruning (ScanIterator#canPruneChunk) for any column written through the cascade, regardless of type (i64, f64 both affected) and regardless of whether the data has any special structure (a purely arithmetic series and uniformly random doubles both lose stats the same way). At WriteOptions.cascading(0) (equivalent to WriteOptions.defaults()), the same columns get correct min/max.
This is a broader, more consequential version of #378 (which was specifically about globalDict + Utf8): that one only affected dict-encoded string columns; this one appears to affect any column that goes through a cascade competition with allowedCascading > 0 at all.
Reproduction
Two-column file (timestamp:i64:series(1700000000000,1000), price:f64:range(50,150)), 200,000 rows:
cascading depth |
timestamp zone 0 stats |
price zone 0 stats |
0 |
min=1700000000000, max=1700065535000, sum=111413347450880000 |
min=50.0006..., max=149.9985..., sum=6553292.68 |
3 |
min=null, max=null, sum=111413347450880000 |
min=null, max=null, sum=6546631.49 |
(sum values differ slightly between rows because the two runs used different row counts, not because of the cascading depth — the point is min/max disappear entirely at depth 3 while sum is always present.)
Consequence measured on a 2,000,000-row file: a RowFilter.gte("timestamp", ...).and(RowFilter.lte("timestamp", ...)) range covering rows [1,000,000, 1,050,000) (2.5% of the file) — a query that should prune all but a couple of chunks — instead yields all 31 of 31 chunks, matching all 2,000,000 rows.
Where I looked
CascadingCompressor#spliceResult (writer module) does propagate step.statsMin()/step.statsMax() from the winning CascadeStep into the final EncodeResult — the propagation plumbing itself looks correct. EncodingEncoder's default encodeCascade() (used by encoders that don't override it) wraps a plain encode() result, including its stats, so those encoders should be fine either way.
My working hypothesis, not fully confirmed: encoders that do override encodeCascade() to expose intermediate/child representations for cascading (ALP, FrameOfReference, BitPacked, Delta, etc.) return a CascadeStep whose statsMin()/statsMax() are left null, even though their own plain terminal encode() path computes proper min/max. Since depth 0 only permits terminal encodings (per CascadingCompressor's own doc comment), it always picks a stats-preserving path; depth > 0 lets a cascade-capable encoder win, and that path apparently only bothers wiring up sum (presumably for the ADR 0013 SUM-pushdown reduction), not min/max.
I have not traced this all the way to a single line/encoder — it may be one shared gap in a common helper, or a per-encoder omission repeated across several encodeCascade() overrides. Worth an actual audit of each cascade-capable encoder's encodeCascade() before fixing, or centralizing min/max computation in CascadingCompressor itself so it doesn't depend on each encoder remembering to do it.
Impact
Silent, not a crash — the scan still returns correct results, just without any pruning speedup, on top of the cost of decoding much more data than necessary. Given cascading() is a common choice for real compression benefit (e.g. JdbcImportOptions.defaults() already uses WriteOptions.cascading(3)), this likely affects a broad swath of realistic writes, not an edge case.
How this was found
Found while building a demo (vortex-server/vortex-demo/vortex-fakedata-generator, see #377) showing VortexHttpReader's partial-fetch story over HTTP. After working around #378 (globalDict), a numeric time-range filter on a plain i64 series column still fetched the entire file — this issue is why. The demo currently works around it by generating with --cascading 0.
🤖 Filed via Claude Code
https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
Summary
With
WriteOptions.cascading(depth)for anydepth > 0, every numeric column I tested loses its zone-mapmin/maxstatistics entirely — onlysumsurvives. This silently breaksRowFilter-driven zone-map pruning (ScanIterator#canPruneChunk) for any column written through the cascade, regardless of type (i64,f64both affected) and regardless of whether the data has any special structure (a purely arithmetic series and uniformly random doubles both lose stats the same way). AtWriteOptions.cascading(0)(equivalent toWriteOptions.defaults()), the same columns get correctmin/max.This is a broader, more consequential version of #378 (which was specifically about
globalDict+Utf8): that one only affected dict-encoded string columns; this one appears to affect any column that goes through a cascade competition withallowedCascading > 0at all.Reproduction
Two-column file (
timestamp:i64:series(1700000000000,1000),price:f64:range(50,150)), 200,000 rows:cascadingdepthtimestampzone 0 statspricezone 0 stats0min=1700000000000, max=1700065535000, sum=111413347450880000min=50.0006..., max=149.9985..., sum=6553292.683min=null, max=null, sum=111413347450880000min=null, max=null, sum=6546631.49(
sumvalues differ slightly between rows because the two runs used different row counts, not because of the cascading depth — the point ismin/maxdisappear entirely at depth 3 whilesumis always present.)Consequence measured on a 2,000,000-row file: a
RowFilter.gte("timestamp", ...).and(RowFilter.lte("timestamp", ...))range covering rows [1,000,000, 1,050,000) (2.5% of the file) — a query that should prune all but a couple of chunks — instead yields all 31 of 31 chunks, matching all 2,000,000 rows.Where I looked
CascadingCompressor#spliceResult(writer module) does propagatestep.statsMin()/step.statsMax()from the winningCascadeStepinto the finalEncodeResult— the propagation plumbing itself looks correct.EncodingEncoder's defaultencodeCascade()(used by encoders that don't override it) wraps a plainencode()result, including its stats, so those encoders should be fine either way.My working hypothesis, not fully confirmed: encoders that do override
encodeCascade()to expose intermediate/child representations for cascading (ALP, FrameOfReference, BitPacked, Delta, etc.) return aCascadeStepwhosestatsMin()/statsMax()are left null, even though their own plain terminalencode()path computes proper min/max. Since depth 0 only permits terminal encodings (perCascadingCompressor's own doc comment), it always picks a stats-preserving path; depth > 0 lets a cascade-capable encoder win, and that path apparently only bothers wiring upsum(presumably for the ADR 0013 SUM-pushdown reduction), notmin/max.I have not traced this all the way to a single line/encoder — it may be one shared gap in a common helper, or a per-encoder omission repeated across several
encodeCascade()overrides. Worth an actual audit of each cascade-capable encoder'sencodeCascade()before fixing, or centralizing min/max computation inCascadingCompressoritself so it doesn't depend on each encoder remembering to do it.Impact
Silent, not a crash — the scan still returns correct results, just without any pruning speedup, on top of the cost of decoding much more data than necessary. Given
cascading()is a common choice for real compression benefit (e.g.JdbcImportOptions.defaults()already usesWriteOptions.cascading(3)), this likely affects a broad swath of realistic writes, not an edge case.How this was found
Found while building a demo (
vortex-server/vortex-demo/vortex-fakedata-generator, see #377) showingVortexHttpReader's partial-fetch story over HTTP. After working around #378 (globalDict), a numeric time-range filter on a plaini64series column still fetched the entire file — this issue is why. The demo currently works around it by generating with--cascading 0.🤖 Filed via Claude Code
https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi