Skip to content

Commit d7ecd98

Browse files
dfa1claude
andcommitted
feat(calcite): auto-register aggregate push-down over bare jdbc, push AVG
Phase-2 productionisation (ADR 0018). Until now the aggregate push-down rule only fired when a test wired it onto the planner by hand via Hook.PLANNER; a plain `jdbc:calcite:` connection ran every aggregate as a full scan. VortexTable becomes a TranslatableTable that translates to a custom VortexTableScan whose register() installs the rules the first time the planner sees the node — so SELECT MIN/MAX/COUNT/SUM over a VortexSchema is now answered from zone-map statistics with no caller wiring. The scan immediately expands to a stock LogicalTableScan (VortexExpandTableScanRule), leaving Calcite's Bindable column-projection and WHERE chunk-skip push-down untouched (LogicalTableScan is final and its register() is a no-op, so the custom-scan + expand-rule pair is the seam that adds the registration hook without disturbing the scan/execute path). AVG joins the pushed aggregates: register() also adds Calcite's AggregateReduceFunctionsRule, which rewrites AVG to SUM/COUNT — both of which the rule answers from stats — so a whole-table AVG also decodes no data segment. The two JDBC end-to-end tests drop their Hook.PLANNER install to prove the rule auto-registers; OhlcSqlDemoTest's full-scan baseline switches off `count(*)` (now itself pushed down to zero chunks) to a prune-nothing filtered scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0485cb2 commit d7ecd98

7 files changed

Lines changed: 172 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- The Calcite aggregate push-down rule now **auto-registers** over a bare `jdbc:calcite:` connection: a `VortexTable` translates to a `VortexTableScan` whose `register()` installs the rules when the planner first sees it, so `SELECT MIN/MAX/COUNT/SUM` over a `VortexSchema` is answered from zone-map statistics with no caller wiring (previously the rule had to be attached to the planner by hand). `AVG` joins them — `AggregateReduceFunctionsRule` reduces it to `SUM`/`COUNT`, both of which push down, so a whole-table `AVG` also decodes no data segment. Column projection and `WHERE` chunk-skip push-down are unchanged. ([24b64b32](https://github.com/dfa1/vortex-java/commit/24b64b32))
1213
- The Calcite aggregate push-down rule now rewrites a whole-table `SUM(col)` to a single-row `Values` computed from the zone-map table (via `VortexTable.zoneSum`), so `SELECT SUM(col)` answers metadata-only with no data segment decoded — joining the existing `MIN`/`MAX`/`COUNT` push-down. `SUM` over an all-null (or empty) column answers the SQL `NULL`, and the rule abandons to a normal scan when a zone carries no usable sum (no zone map, or an overflowed zone) or when a `WHERE` filters the aggregate. ([24b64b32](https://github.com/dfa1/vortex-java/commit/24b64b32))
1314
- `ScanIterator.columnZoneStats(column)` surfaces per-zone min/max/sum/null-count from a column's `vortex.stats` zone-map table without decoding any data segment — the read side of aggregate push-down (ADR 0013 §6). `ArrayStats` gains a `sum` component, decoded from the zone-map table (where the Rust reference stores it too), so the Calcite adapter now answers `SUM`/`AVG` metadata-only when every zone carries a sum, falling back to a streaming scan only for columns without a zone map. ([05dd9204](https://github.com/dfa1/vortex-java/commit/05dd9204))
1415

TODO.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ Per-encoding gotchas:
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.
103103
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.
104+
extracts), consumed by the planner: `VortexAggregatePushDownRule` rewrites a whole-table
105+
`MIN`/`MAX`/`COUNT`/`SUM`/`AVG` to a single-row `Values`, abandoning to the scan only when a zone
106+
carries no usable sum (an all-null column answers SQL `NULL`; `AVG` reduces to `SUM`/`COUNT`). The
107+
rule auto-registers over a bare `jdbc:calcite:` connection via `VortexTableScan.register()`, so
108+
SQL over JDBC is rewritten with no caller wiring. A `SUM` with a `WHERE` still abandons (whole-zone
109+
stats can't answer a filtered aggregate) — that is the residual tier below.
108110
Next: the residual tier — give `ZoneReducer` predicate support (whole-zone fold for fully-selected
109111
zones + boundary-zone streaming for partially-selected ones), then let the rule push `SUM` with a
110112
`WHERE`. `Mask`/`Predicate`/kernel vocab on top.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import org.apache.calcite.linq4j.AbstractEnumerable;
2222
import org.apache.calcite.linq4j.Enumerable;
2323
import org.apache.calcite.linq4j.Enumerator;
24+
import org.apache.calcite.plan.RelOptTable;
25+
import org.apache.calcite.rel.RelNode;
2426
import org.apache.calcite.rel.type.RelDataType;
2527
import org.apache.calcite.rel.type.RelDataTypeFactory;
2628
import org.apache.calcite.rex.RexBuilder;
@@ -30,6 +32,7 @@
3032
import org.apache.calcite.rex.RexNode;
3133
import org.apache.calcite.rex.RexUtil;
3234
import org.apache.calcite.schema.ProjectableFilterableTable;
35+
import org.apache.calcite.schema.TranslatableTable;
3336
import org.apache.calcite.schema.impl.AbstractTable;
3437
import org.apache.calcite.sql.type.SqlTypeName;
3538

@@ -50,7 +53,7 @@
5053
/// consumed: zone-map pruning is approximate (it drops whole chunks that cannot match, not
5154
/// individual rows), so Calcite must still apply the predicate row-by-row for exactness. The
5255
/// win is decoding far fewer chunks when the filter is selective on a clustered column.
53-
public final class VortexTable extends AbstractTable implements ProjectableFilterableTable {
56+
public final class VortexTable extends AbstractTable implements ProjectableFilterableTable, TranslatableTable {
5457

5558
/// Used only to expand `SEARCH`/`Sarg` predicates (e.g. `BETWEEN`, `IN`) back into ordinary
5659
/// comparison trees the [RowFilter] translation understands.
@@ -143,6 +146,19 @@ public long totalRows() {
143146
}
144147
}
145148

149+
/// Translates a reference to this table into a [VortexTableScan] — the seam that auto-registers
150+
/// the aggregate push-down rules on the planner (see [VortexTableScan#register]). The scan
151+
/// immediately expands to a stock `LogicalTableScan`, so projection and filter push-down run
152+
/// through Calcite's `Bindables` convention unchanged.
153+
///
154+
/// @param context the planning context supplying the cluster
155+
/// @param relOptTable the planner's handle to this table
156+
/// @return a `VortexTableScan` over this table
157+
@Override
158+
public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable relOptTable) {
159+
return VortexTableScan.create(context.getCluster(), relOptTable);
160+
}
161+
146162
@Override
147163
public RelDataType getRowType(RelDataTypeFactory typeFactory) {
148164
DType.Struct struct = struct();
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package io.github.dfa1.vortex.calcite;
2+
3+
import org.apache.calcite.plan.Convention;
4+
import org.apache.calcite.plan.RelOptCluster;
5+
import org.apache.calcite.plan.RelOptPlanner;
6+
import org.apache.calcite.plan.RelOptRule;
7+
import org.apache.calcite.plan.RelOptRuleCall;
8+
import org.apache.calcite.plan.RelOptTable;
9+
import org.apache.calcite.plan.RelTraitSet;
10+
import org.apache.calcite.rel.RelNode;
11+
import org.apache.calcite.rel.core.TableScan;
12+
import org.apache.calcite.rel.logical.LogicalTableScan;
13+
import org.apache.calcite.rel.rules.CoreRules;
14+
15+
import java.util.List;
16+
17+
/// The logical scan a [VortexTable] translates to. Its sole purpose over the stock
18+
/// [LogicalTableScan] is the [#register(RelOptPlanner)] hook: when the planner first sees this
19+
/// node class it installs the Vortex aggregate push-down rules, so a plain `jdbc:calcite:`
20+
/// connection rewrites whole-table aggregates from zone-map statistics **without the caller wiring
21+
/// the rule onto the planner** (ADR 0018 Phase 2 productionisation).
22+
///
23+
/// The node carries no projection or filter of its own. A `VortexExpandTableScanRule` immediately
24+
/// rewrites it to a [LogicalTableScan], so Calcite's stock `Bindables` rules drive column
25+
/// projection and `RowFilter` chunk-skip push-down exactly as before — this class only adds the
26+
/// rule-registration seam, it does not change the scan/execute path.
27+
final class VortexTableScan extends TableScan {
28+
29+
VortexTableScan(RelOptCluster cluster, RelTraitSet traitSet, RelOptTable table) {
30+
super(cluster, traitSet, List.of(), table);
31+
}
32+
33+
@Override
34+
public RelNode copy(RelTraitSet traitSet, List<RelNode> inputs) {
35+
assert inputs.isEmpty() : "VortexTableScan has no inputs";
36+
return new VortexTableScan(getCluster(), traitSet, table);
37+
}
38+
39+
/// Called once by the planner the first time a node of this class is registered. Installs the
40+
/// whole-table aggregate push-down rules, the `AVG → SUM/COUNT` reduction that lets `AVG` ride
41+
/// that path, and the rule that expands this node to a stock [LogicalTableScan].
42+
///
43+
/// @param planner the planner registering this node class
44+
@Override
45+
public void register(RelOptPlanner planner) {
46+
VortexAggregatePushDownRule.RULES.forEach(planner::addRule);
47+
// Reduce AVG (and STDDEV/VAR) to SUM/COUNT so the aggregate rule can answer AVG from the
48+
// per-zone SUM + row/null counts instead of streaming the column.
49+
planner.addRule(CoreRules.AGGREGATE_REDUCE_FUNCTIONS);
50+
planner.addRule(VortexExpandTableScanRule.INSTANCE);
51+
}
52+
53+
/// Rewrites a [VortexTableScan] to a stock [LogicalTableScan], handing the scan back to
54+
/// Calcite's `Bindables` convention for projection / filter push-down and execution. Without
55+
/// this expansion the `Convention.NONE` [VortexTableScan] has no physical implementation.
56+
// Same justification as VortexAggregatePushDownRule: the classic operand() constructor is
57+
// deprecated but far lighter than the Immutables-based RelRule.Config for a one-line rule.
58+
@SuppressWarnings("deprecation")
59+
static final class VortexExpandTableScanRule extends RelOptRule {
60+
61+
static final VortexExpandTableScanRule INSTANCE = new VortexExpandTableScanRule();
62+
63+
private VortexExpandTableScanRule() {
64+
super(operand(VortexTableScan.class, any()), "VortexExpandTableScanRule");
65+
}
66+
67+
@Override
68+
public void onMatch(RelOptRuleCall call) {
69+
VortexTableScan scan = call.rel(0);
70+
call.transformTo(LogicalTableScan.create(
71+
scan.getCluster(), scan.getTable(), scan.getHints()));
72+
}
73+
}
74+
75+
static VortexTableScan create(RelOptCluster cluster, RelOptTable table) {
76+
return new VortexTableScan(cluster, cluster.traitSetOf(Convention.NONE), table);
77+
}
78+
}

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

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
import io.github.dfa1.vortex.reader.VortexReader;
44

55
import org.apache.calcite.jdbc.CalciteConnection;
6-
import org.apache.calcite.plan.RelOptPlanner;
76
import org.apache.calcite.plan.RelOptUtil;
87
import org.apache.calcite.plan.hep.HepPlanner;
98
import org.apache.calcite.plan.hep.HepProgram;
109
import org.apache.calcite.plan.hep.HepProgramBuilder;
1110
import org.apache.calcite.rel.RelNode;
1211
import org.apache.calcite.rel.core.Values;
1312
import org.apache.calcite.rex.RexLiteral;
14-
import org.apache.calcite.runtime.Hook;
1513
import org.apache.calcite.schema.SchemaPlus;
1614
import org.apache.calcite.sql.SqlNode;
1715
import org.apache.calcite.tools.FrameworkConfig;
@@ -29,7 +27,6 @@
2927
import java.util.List;
3028
import java.util.Map;
3129
import java.util.Properties;
32-
import java.util.function.Consumer;
3330

3431
import static org.assertj.core.api.Assertions.assertThat;
3532

@@ -138,17 +135,14 @@ void sumRewritesToValuesFromZoneStats() throws Exception {
138135
}
139136

140137
@Test
141-
@SuppressWarnings("try") // the Hook.Closeable is used only for its scope (deregister on close)
142138
void pushDownRunsEndToEndThroughJdbcPlanner() throws Exception {
143-
// Given a JDBC Calcite connection over the OHLC schema with the rule registered on the
144-
// (Volcano) planner via Hook.PLANNER
139+
// Given a plain JDBC Calcite connection over the OHLC schema — no rule is wired by hand; the
140+
// VortexTableScan auto-registers the push-down rules when the planner first sees it
145141
Properties info = new Properties();
146142
info.setProperty("lex", "JAVA");
147143
String sql = "select min(low) lo, max(high) hi, count(*) c from vtx.ohlc";
148144

149-
try (Hook.Closeable ignored = Hook.PLANNER.addThread(
150-
(Consumer<RelOptPlanner>) planner -> VortexAggregatePushDownRule.RULES.forEach(planner::addRule));
151-
Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
145+
try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
152146
conn.unwrap(CalciteConnection.class).getRootSchema()
153147
.add("vtx", new VortexSchema(Map.of("ohlc", file)));
154148

@@ -186,15 +180,12 @@ void pushDownRunsEndToEndThroughJdbcPlanner() throws Exception {
186180
}
187181

188182
@Test
189-
@SuppressWarnings("try") // the Hook.Closeable is used only for its scope (deregister on close)
190183
void filteredAggregateIsNotAnsweredFromWholeTableStats() throws Exception {
191-
// Given the rule on the JDBC (Volcano) planner and a WHERE that pushes a predicate into the
192-
// scan — whole-table stats must not be used to answer a filtered aggregate
184+
// Given the auto-registered rule and a WHERE that pushes a predicate into the scan — whole-
185+
// table stats must not be used to answer a filtered aggregate
193186
Properties info = new Properties();
194187
info.setProperty("lex", "JAVA");
195-
try (Hook.Closeable ignored = Hook.PLANNER.addThread(
196-
(Consumer<RelOptPlanner>) planner -> VortexAggregatePushDownRule.RULES.forEach(planner::addRule));
197-
Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
188+
try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
198189
conn.unwrap(CalciteConnection.class).getRootSchema()
199190
.add("vtx", new VortexSchema(Map.of("ohlc", file)));
200191

@@ -212,6 +203,42 @@ void filteredAggregateIsNotAnsweredFromWholeTableStats() throws Exception {
212203
}
213204
}
214205

206+
@Test
207+
void avgRewritesViaSumAndCountWithNoScan() throws Exception {
208+
// Given a plain JDBC connection — AVG must be reduced to SUM/COUNT by the auto-registered
209+
// AggregateReduceFunctionsRule so both halves ride the zone-map push-down, leaving no scan
210+
Properties info = new Properties();
211+
info.setProperty("lex", "JAVA");
212+
try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
213+
conn.unwrap(CalciteConnection.class).getRootSchema()
214+
.add("vtx", new VortexSchema(Map.of("ohlc", file)));
215+
216+
// When EXPLAIN runs over AVG of a non-null DOUBLE column
217+
String plan;
218+
try (Statement st = conn.createStatement();
219+
ResultSet rs = st.executeQuery("explain plan for select avg(low) a from vtx.ohlc")) {
220+
rs.next();
221+
plan = rs.getString(1);
222+
}
223+
224+
// Then the plan decodes no data segment — the SUM and COUNT were both answered from stats
225+
assertThat(plan).doesNotContain("TableScan");
226+
227+
// And the value equals sum/count folded from the zone-map stats (low is non-null, so
228+
// COUNT = ROWS); AVG over a DOUBLE column is exact double division, no integer truncation
229+
double avg;
230+
try (Statement st = conn.createStatement();
231+
ResultSet rs = st.executeQuery("select avg(low) a from vtx.ohlc")) {
232+
rs.next();
233+
avg = rs.getDouble("a");
234+
}
235+
try (VortexReader reader = VortexReader.open(file)) {
236+
double sum = VortexAggregates.of(reader, "low").sum().doubleValue();
237+
assertThat(avg).isEqualTo(sum / ROWS);
238+
}
239+
}
240+
}
241+
215242
// Note: the absent-MIN/MAX-stat and absent-NULL_COUNT guards in VortexAggregatePushDownRule are
216243
// defensive for files whose writer omits per-column stats (e.g. some Rust-written columns or
217244
// future stat-less encodings). The in-house Java writer always emits min/max/null_count for

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,12 @@ void filterPushDownPrunesChunksAndShowsInExplain() throws Exception {
148148
String windowed = "select count(*) c, max(high) h from vtx.ohlc "
149149
+ "where `date` between " + lo + " and " + hi;
150150

151-
// When the unfiltered query runs, every chunk is decoded
151+
// When a full-scan baseline runs, every chunk is decoded. A `WHERE high > -1` matches
152+
// every row (high is always positive) so zone maps prune nothing — and the predicate
153+
// also stops the aggregate rule from answering count(*) from stats, which would
154+
// otherwise decode zero chunks.
152155
try (Statement st = conn.createStatement();
153-
ResultSet rs = st.executeQuery("select count(*) c from vtx.ohlc")) {
156+
ResultSet rs = st.executeQuery("select count(*) c from vtx.ohlc where high > -1")) {
154157
rs.next();
155158
}
156159
long chunksFull = schema.table("ohlc").chunksScannedLastQuery();

0 commit comments

Comments
 (0)