- Status: Accepted — Phases 0–2 (schema/scan, filter + projection push-down, MIN/MAX/COUNT
aggregate push-down) implemented in the
calcite/module; SUM/AVG push-down pending - Date: 2026-06-24
- Deciders: project maintainer
- Supersedes: —
- Superseded by: —
- Related: ADR 0013 — Compute primitives, ADR 0016 — vortex-arrow bridge, ADR 0005 — Vector API adoption
The reader exposes typed zero-copy columns and (ADR 0013) a path to filter/aggregate
push-down over the encoded form and the zone-map stats table. What it does not have is a
query language. A recurring ask is "can I run SQL over a Vortex file?" — SELECT … WHERE … GROUP BY … rather than hand-written scan loops.
Two ways to answer that:
- Build a SQL engine. Parser, type system, logical plan, cost-based optimiser, join algorithms, aggregation/spilling, NULL semantics. Person-years, none of it Vortex-specific — it is solved, generic, and not where this project's advantage lies.
- Adapt to an existing engine. Plug the Vortex scan into a mature SQL front-end and let it own parsing/planning/joins. The project keeps owning the one thing only it can do well: a fast columnar scan with push-down into the encoding and the stats table.
The project's advantage is the scan, not the engine. An external engine only ever sees
decoded values, so it has already paid the cost Vortex could have skipped — chunk-skipping
via zone maps, encoded-domain comparison (compare(ALPArray, scalar) without materialising
doubles), and answering MIN/MAX/COUNT/SUM straight from the stats table. That asymmetry
only exists inside Vortex. So the goal is to expose the scan + push-down to an engine, not to
reimplement the engine.
Apache Calcite is the natural JVM host: a pure-Java SQL parser + relational optimiser + pluggable adapter framework (the substrate under Drill, Flink-SQL, Beam-SQL). It does the generic work; the adapter contributes a table that knows how to push work down.
A calcite module (vortex-calcite, Calcite 1.40.0) was built to de-risk and to anchor this
ADR in working code rather than speculation:
- JDK 25 / Janino gate.
CalciteSmokeTestruns a query that forces Calcite's Enumerable convention to generate Java and compile it with Janino. It passes on JDK 25 — Calcite's runtime codegen works on this project's target, so the interpreter fallback is not needed. This was the one real unknown (Janino has historically lagged on new class-file versions — the same pressure that produced the in-house codegen of ADR 0017). VortexTable implements ScannableTable— derives the SQL row type from the file'sDType.Structand decodes chunks intoObject[]rows. Baseline only: every full-scan row crosses theObject[]boundary.VortexSchema— maps a SQL table name to a.vortexfile.VortexAggregates— the push-down core, deliberately built beside Calcite (not yet as a planner rule):MIN/MAX/COUNTare read from footer zone-map stats with no data decode;SUM/AVGfall back to a streaming scan because the writer emits no per-zoneSUMstat yet.OhlcSqlDemoTest— 120 000-row OHLC file, computes the five aggregates via Calcite (full scan, ground truth) and via push-down, asserts they agree.
Measured on the demo (120 000 rows, 12 zones):
AGGREGATE VALUE SOURCE
MIN(low) 3.65 ZONE_STATS_PUSHDOWN
MAX(high) 1048.1 ZONE_STATS_PUSHDOWN
COUNT(*) 120000 ZONE_STATS_PUSHDOWN
SUM(volume) 120023864741 FULL_SCAN
AVG(volume) 1000198.87 FULL_SCAN
full scan (Calcite): ~577 ms | push-down (min/max/count from stats): ~5.9 ms (~100×)
Two SQL-semantics facts surfaced and must be carried forward by the production adapter:
dateis a SQL reserved word — a column of that name must be quoted, or queries avoid it.- Calcite's
AVGover aBIGINTcolumn does integer division; a true mean needsAVG(CAST(col AS DOUBLE)). The adapter's own aggregate path must match whichever semantics Calcite's planner applies, so a rule-based push-down stays bit-compatible with the fallback.
Ship a vortex-calcite adapter that makes Vortex a push-down SQL source for Apache Calcite.
Do not build a SQL engine. Keep the prototype's structure; productionise it in phases. The
heavy Calcite dependency tree (Avatica, Guava, Janino) is quarantined in this module —
core/reader/writer stay dependency-light, the same isolation rule applied to the planned
vortex-arrow bridge (ADR 0016).
Phased productionisation:
- Phase 0 — queryable (prototype, done).
ScannableTable+VortexSchema;SELECT *and any aggregate work via full scan. Proves the wiring and the JDK 25 codegen path. - Phase 1 — filter + project push-down. Implement
ProjectableFilterableTable: projection prunes columns (decode only what's asked); filters translate CalciteRexNodes into the ADR 0013Predicatevocabulary and push into the scan'sRowFilter(zone-map chunk-skip + encoded-domain compare). UnsupportedRexNodes are left for Calcite — push-down is best-effort, never a parse failure. - Phase 2 — aggregate push-down (the payoff). A
RelOptRulematchingAggregate(TableScan)forMIN/MAX/SUM/COUNTrewrites to a stats-backed physical scan folding the zone-map stats table — promoting today'sVortexAggregatesfrom a side helper to a planner rule.MIN/MAX/COUNTwork now;SUM/AVGneed the writer to emit a per-zoneSUMstat first (ADR 0013 §6 — the same increment that also wantsNULL_COUNT).
Phases 0–2 are implemented and tested:
- Phase 1 landed.
VortexTableis aProjectableFilterableTable: projection prunes columns;=, <>, <, <=, >, >=, AND, BETWEEN, INtranslate to a readerRowFilterfor zone-map chunk skipping. Filters are pushed but not consumed (whole-chunk pruning is approximate, so Calcite still row-filters). Demo: adaterange over 1M rows decodes 1 of 100 chunks (99% pruned), exact result, andEXPLAINshowsfilters/projectsfolded intoBindableTableScan. - Phase 2 landed (MIN/MAX/COUNT).
VortexAggregatePushDownRulerewrites a whole-tableMIN/MAX/COUNT(noGROUP BY, numeric columns) into a single-rowLogicalValuesfrom the stats — the optimized plan has no scan and no aggregate.SUM/AVGremain a scan pending the writer per-zoneSUMstat.
Gotchas found and recorded for the production adapter:
- Reserved words. Beyond
date, the OHLC columnsclose/open(CLOSE/OPEN cursor) must be quoted. A production adapter should quote all identifiers it emits. - Integer stats are
Long. Zone-map integer stats decode asLong, floats asDouble, regardless of column width. A filter literal boxed at the column's natural width (IntegerforI32) silently disables pruning becauseScanIterator.compareValuesswallows the resultingClassCastExceptionand returns0(= "cannot prune"). The adapter coerces integer literals toLong; the underlying reader trap is filed as issue #159. BETWEEN/INareSEARCH(Sarg), notAND(>=,<=)/OR— expand withRexUtil.expandSearchbefore translating.- Calcite 1.40 removed
RelRule.Config.EMPTY. The modernRelRule.Configpath needs the Immutables annotation processor; the deprecatedRelOptRuleoperand constructor is lighter for a single adapter rule (localized@SuppressWarnings("deprecation")).
The prototype registers the Phase-2 rule through a HepPlanner in a test. Wiring it into the
bare jdbc:calcite: planner (so SQL over JDBC is auto-rewritten) is the main remaining
productionisation step, alongside the writer SUM zone-stat.
Two doors, chosen by query shape. Calcite is the right tool for reducing queries (filter
/ aggregate / group-by), where push-down shrinks the result and the Object[] boundary
amortises to near-nothing. It is the wrong tool for bulk columnar extract (SELECT * over a
large file, weak/no filter): every row boxes. That shape should bypass Calcite via the Arrow
C-Data export of ADR 0016 (Option B), columnar and zero-boxing. Calcite is never Vortex's
execution engine — the moment many rows flow through it, the wrong door was used. The
adapter's job is to push filter/project/aggregate down hard so what reaches Object[] is small
by construction.
- SQL over Vortex with no engine to build or maintain; Calcite owns parse/plan/optimise/join.
- The push-down surface is exactly the ADR 0013 primitive set — the adapter is the first real consumer of that vocabulary, validating it against a real planner.
- Dependency isolation: only consumers who add
vortex-calcitepay the Calcite/Janino/Guava cost; the core stays clean and (per ADR 0017) JPMS-viable. - Aggregate push-down gives the headline result —
MIN/MAX/COUNTanswered from a tiny stats table, ~100× faster than a full scan on the prototype, exact against ground truth. - Concrete motivation for the writer-side
SUM/NULL_COUNTzone-stat increment: once emitted,SUM/AVG/COUNTjoin the no-decode tier.
- Calcite's Enumerable execution is row-at-a-time
Object[]with autoboxing. Acceptable for a source (push-down does the heavy work before rows materialise), but it caps throughput on any query that emits many rows — which is why bulk extract is pushed to the Arrow door, not Calcite. - A second public surface (SQL) with its own semantics gotchas (reserved words,
AVGinteger division, three-valued logic) the adapter must honour exactly to stay consistent with the push-down path. - The
RelOptRulefor aggregate push-down is non-trivial Calcite-internal work; the kernel matrix (Array variant × Predicate variant) from ADR 0013 still has to exist underneath.
- Push-down/fallback divergence. A pushed-down aggregate must produce bit-identical results
to Calcite's own computation over the scanned rows (NULL handling, integer vs double
AVG, overflow). Test every pushed aggregate against the full-scan ground truth, as the prototype does. - Calcite version churn. Calcite's adapter SPI and Avatica move between releases; pin the version and keep the smoke test as the JDK-compatibility tripwire (re-run on JDK upgrades).
- Temptation to make Calcite columnar. Bridging Vortex into a vectorised execution engine is
the rabbit hole; that is DuckDB/DataFusion territory (native — the
vortex-jniworld). Resist it. Keep Calcite as front-end + planner only. - Lifetime.
Object[]rows hold decoded values, but any zero-copy path (future) must keep the reader'sArenaalive while theEnumeratoris open — the same release-callback hazard flagged for the Arrow C-Data bridge (ADR 0016).
A hand-rolled parser + executor over the ADR 0013 kernels (single table, WHERE, simple
aggregates, no joins). Attractive as a demo of push-down, but it dies at the first join or
optimiser requirement and re-implements solved, generic machinery. Rejected as a product
direction; the prototype's value is the adapter + push-down helper, not a query language.
Export decoded columns through the Arrow C-Data interface (ADR 0016 Option B) and let a mature native engine run all SQL. Least code, fastest execution — but the engine receives decoded Arrow, so encoded-domain push-down (Vortex's whole edge) is lost; only zone-map chunk-skip survives, and only if Vortex pre-filters before export. This is the right path for bulk extract and for native consumers, and it coexists with this ADR as the "second door" — not a replacement for JVM SQL with push-down.
Implement a complete Convention with vectorised Vortex operators so execution stays columnar
end-to-end. Maximum performance, but it means building a vectorised execution engine inside
Calcite — most of option A's cost plus Calcite-internal complexity. Deferred indefinitely; the
Object[] Enumerable path plus aggressive push-down is sufficient for the source role, and bulk
throughput belongs to the Arrow door.
- Prototype:
feat/vortex-calcite-demo—calcite/module (VortexTable,VortexSchema,VortexAggregates,CalciteSmokeTest,OhlcSqlDemoTest) - ADR 0013 — Compute primitives §6 (aggregate push-down via
zone-map stats;
SUM/NULL_COUNTwriter increment) - ADR 0016 — vortex-arrow bridge (the "bulk extract" door)
- ADR 0017 — in-house FlatBuffers codegen (JDK 25 / generated-code pressure that motivated the Janino smoke test)
- Apache Calcite adapter SPI — https://calcite.apache.org/docs/adapter.html
- Proposal: Adding Vortex as an Iceberg File Format — Iceberg's File Format API (1.11.0) integration plan; this adapter is the working demonstration of the projection/filter/aggregate push-down it relies on (push-down source, metrics from zone-map statistics)