Skip to content

Commit 24b64b3

Browse files
dfa1claude
andcommitted
feat(calcite): push whole-table SUM down to zone-map stats
Wire SUM into VortexAggregatePushDownRule. A whole-table `SUM(col)` now rewrites to a single-row Values folded from the per-zone SUM rows via the new VortexTable.zoneSum (delegating to reader.compute.ZoneReducer) — no data segment decoded — joining the existing MIN/MAX/COUNT push-down. The rule abandons to the normal scan when a zone carries no usable sum (no zone map, or an overflowed zone) or when a WHERE filters the aggregate (whole-zone stats can't answer a filtered aggregate — that is the residual tier, still to come). Until now the zone SUM fold was reachable only through the test-only VortexAggregates helper; this makes the planner a real consumer of ZoneReducer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cfeb551 commit 24b64b3

5 files changed

Lines changed: 107 additions & 17 deletions

File tree

TODO.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,14 @@ Per-encoding gotchas:
100100
min/max/sum/null count, decoding sum from the `vortex.stats` zone-map table (matches files from
101101
Rust, whose flat writer omits per-flat sum). Calcite `VortexAggregates.SUM`/`AVG` now fold those
102102
per-zone sums (metadata-only), falling back to a full scan only when a column has no zone map.
103-
The fold is now a reusable `reader.compute.ZoneReducer.sum(col)` (the seam a future
104-
`vortex-compute` extracts); Calcite consumes it.
105-
Next: the residual tier needs a consumer first — wire Calcite aggregate+`WHERE` push-down, then
106-
add `ZoneReducer` predicate support (whole-zone fold + boundary-zone streaming) and the
107-
`Mask`/`Predicate`/kernel vocab on top.
103+
The fold is a reusable `reader.compute.ZoneReducer.sum(col)` (the seam a future `vortex-compute`
104+
extracts), now consumed by the planner: `VortexAggregatePushDownRule` rewrites a whole-table
105+
`SUM(col)` to a single-row `Values` via `VortexTable.zoneSum`, abandoning to the scan only when a
106+
zone carries no usable sum. A `SUM` with a `WHERE` still abandons (whole-zone stats can't answer a
107+
filtered aggregate) — that is the residual tier below.
108+
Next: the residual tier — give `ZoneReducer` predicate support (whole-zone fold for fully-selected
109+
zones + boundary-zone streaming for partially-selected ones), then let the rule push `SUM` with a
110+
`WHERE`. `Mask`/`Predicate`/kernel vocab on top.
108111

109112
## Encodings
110113

calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregatePushDownRule.java

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@
2525
import java.util.ArrayList;
2626
import java.util.List;
2727

28-
/// Rewrites a whole-table `MIN`/`MAX`/`COUNT` aggregate over a [VortexTable] into a single-row
29-
/// [LogicalValues] computed from the footer zone-map statistics — answering the query without
30-
/// decoding a single data segment (ADR 0013 §6, ADR 0018 Phase 2).
28+
/// Rewrites a whole-table `MIN`/`MAX`/`COUNT`/`SUM` aggregate over a [VortexTable] into a
29+
/// single-row [LogicalValues] computed from the footer zone-map statistics — answering the query
30+
/// without decoding a single data segment (ADR 0013 §6, ADR 0018 Phase 2).
3131
///
32-
/// Fires only when it can answer *every* aggregate from statistics: no `GROUP BY`, and each
33-
/// call is `COUNT(*)`, `COUNT(col)`, `MIN(col)`, or `MAX(col)` over a numeric column. Anything
34-
/// else (e.g. `SUM`, a grouped aggregate, `MIN` on a non-numeric column) leaves the plan
35-
/// untouched for the normal scan path. `SUM`/`AVG` join this tier once the writer emits a
36-
/// per-zone `SUM` statistic.
32+
/// Fires only when it can answer *every* aggregate from statistics: no `GROUP BY`, and each call
33+
/// is `COUNT(*)`, `COUNT(col)`, `MIN(col)`, `MAX(col)`, or `SUM(col)` over a numeric column. `SUM`
34+
/// folds the per-zone `SUM` rows via [VortexTable#zoneSum(String)]; it abandons (falling back to
35+
/// the scan) for a column whose zone-map table cannot answer it — no zone map, or an overflowed
36+
/// zone. Anything else (a grouped aggregate, `MIN` on a non-numeric column, `AVG` that was not
37+
/// reduced to `SUM`/`COUNT`) leaves the plan untouched for the normal scan path.
3738
// Calcite 1.40 removed RelRule.Config.EMPTY; the modern RelRule.Config path requires the
3839
// Immutables annotation processor. The classic operand() constructor is deprecated but fully
3940
// supported and far lighter for a single adapter rule — suppression is localized and justified.
@@ -154,6 +155,24 @@ private static RexLiteral evaluate(AggregateCall agg, RelDataType outType, Vorte
154155
}
155156
yield numericLiteral(rexBuilder, value, outType);
156157
}
158+
case SUM -> {
159+
if (agg.getArgList().size() != 1) {
160+
yield null;
161+
}
162+
String col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
163+
if (col == null) {
164+
yield null;
165+
}
166+
// Fold the per-zone SUM rows (metadata-only). A null fold means a zone carries no
167+
// usable sum (no zone map, or an overflowed zone) — abandon so the scan computes it.
168+
// It also covers SQL's empty/all-null SUM = NULL: zero zones fold to null and the
169+
// scan path produces the NULL literal, so the rule need not special-case it.
170+
Number sum = table.zoneSum(col);
171+
if (sum == null) {
172+
yield null;
173+
}
174+
yield numericLiteral(rexBuilder, sum, outType);
175+
}
157176
default -> null;
158177
};
159178
}

calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.dfa1.vortex.reader.ScanIterator;
77
import io.github.dfa1.vortex.reader.ScanOptions;
88
import io.github.dfa1.vortex.reader.VortexReader;
9+
import io.github.dfa1.vortex.reader.compute.ZoneReducer;
910
import io.github.dfa1.vortex.reader.array.BoolArray;
1011
import io.github.dfa1.vortex.reader.array.ByteArray;
1112
import io.github.dfa1.vortex.reader.array.DoubleArray;
@@ -86,6 +87,23 @@ public io.github.dfa1.vortex.reader.ArrayStats statsOf(String column) {
8687
}
8788
}
8889

90+
/// Folds the per-zone `SUM` statistics for `column` without decoding any data segment, or
91+
/// returns `null` when the zone-map table cannot answer the reduction — a column with no zone
92+
/// map, or a zone whose sum was not retained (e.g. an overflowed zone) — so the caller scans.
93+
///
94+
/// Integer columns fold into a [Long] (exact); floating columns into a [Double]. Used by the
95+
/// aggregate push-down rule to answer `SUM` (ADR 0013 §6).
96+
///
97+
/// @param column the numeric column name
98+
/// @return the column sum as a [Long] or [Double], or `null` if no zone carries a usable sum
99+
public Number zoneSum(String column) {
100+
try (VortexReader reader = VortexReader.open(file)) {
101+
return new ZoneReducer(reader).sum(column);
102+
} catch (IOException e) {
103+
throw new UncheckedIOException("cannot read zone sum of " + file, e);
104+
}
105+
}
106+
89107
/// Total row count across all chunks, read from chunk metadata without decoding data.
90108
///
91109
/// @return the number of rows in the file

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregatePushDownTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,48 @@ void minMaxCountRewriteToValuesFromStats() throws Exception {
9595
}
9696
}
9797

98+
@Test
99+
void sumRewritesToValuesFromZoneStats() throws Exception {
100+
// Given a whole-table SUM over an integer column (volume, I64 → exact Long) and a floating
101+
// column (low, F64 → Double) — both answerable by folding the per-zone SUM rows
102+
SchemaPlus root = Frameworks.createRootSchema(true);
103+
SchemaPlus vtx = root.add("vtx", new VortexSchema(Map.of("ohlc", file)));
104+
FrameworkConfig config = Frameworks.newConfigBuilder()
105+
.defaultSchema(vtx)
106+
.parserConfig(org.apache.calcite.sql.parser.SqlParser.config()
107+
.withUnquotedCasing(org.apache.calcite.avatica.util.Casing.UNCHANGED))
108+
.build();
109+
Planner planner = Frameworks.getPlanner(config);
110+
SqlNode parsed = planner.parse("select sum(volume), sum(low) from ohlc");
111+
RelNode logical = planner.rel(planner.validate(parsed)).rel;
112+
113+
// When the aggregate push-down rule runs
114+
HepProgram program = new HepProgramBuilder()
115+
.addRuleCollection(VortexAggregatePushDownRule.RULES)
116+
.build();
117+
HepPlanner hep = new HepPlanner(program);
118+
hep.setRoot(logical);
119+
RelNode optimized = hep.findBestExp();
120+
121+
// Then the plan is a single-row Values — no scan, no aggregate, no data segment decoded
122+
String plan = RelOptUtil.toString(optimized);
123+
assertThat(plan).contains("LogicalValues").doesNotContain("TableScan").doesNotContain("Aggregate");
124+
125+
Values values = findValues(optimized);
126+
assertThat(values).isNotNull();
127+
List<RexLiteral> sumRow = values.getTuples().getFirst();
128+
129+
// And the folded sums equal what ZoneReducer computes — exact Long for volume (not widened
130+
// to Double), Double for low
131+
try (VortexReader reader = VortexReader.open(file)) {
132+
Number volumeSum = VortexAggregates.of(reader, "volume").sum();
133+
Number lowSum = VortexAggregates.of(reader, "low").sum();
134+
assertThat(volumeSum).isInstanceOf(Long.class);
135+
assertThat(sumRow.get(0).getValueAs(Long.class)).isEqualTo(volumeSum.longValue());
136+
assertThat(sumRow.get(1).getValueAs(Double.class)).isEqualTo(lowSum.doubleValue());
137+
}
138+
}
139+
98140
@Test
99141
@SuppressWarnings("try") // the Hook.Closeable is used only for its scope (deregister on close)
100142
void pushDownRunsEndToEndThroughJdbcPlanner() throws Exception {

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateRuleBranchTest.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,19 @@ void countColumn_withProjectPath_rewritesToValues() {
5656
}
5757

5858
@Test
59-
void sum_hasNoZoneStat_abandonsRewrite() {
60-
// Given SUM(volume) — no SUM zone statistic exists, so evaluate() yields null and the whole
61-
// rewrite is abandoned
59+
void sum_withZoneStat_rewritesToValues() {
60+
// Given SUM(volume) — the Java writer emits a per-zone SUM stat, so ZoneReducer folds every
61+
// zone metadata-only and the whole rewrite succeeds
62+
// When / Then — answered from the zone-map table, no Aggregate/TableScan left
63+
assertThat(optimize("select sum(volume) from ohlc")).contains("LogicalValues").doesNotContain("Aggregate");
64+
}
65+
66+
@Test
67+
void sumOverComputedExpression_abandonsRewrite() {
68+
// Given SUM(volume + 1) — the projected input is an expression, not a bare column ref, so
69+
// resolveColumn returns null and the SUM branch abandons
6270
// When / Then — the Aggregate survives for the normal scan path
63-
assertThat(optimize("select sum(volume) from ohlc")).contains("Aggregate");
71+
assertThat(optimize("select sum(volume + 1) from ohlc")).contains("Aggregate");
6472
}
6573

6674
@Test

0 commit comments

Comments
 (0)