IGNITE-27822 Support recursive query for Calcite engine - #13479
IGNITE-27822 Support recursive query for Calcite engine#13479vldpyatkov wants to merge 15 commits into
Conversation
…hin planning context.
There was a problem hiding this comment.
Pull request overview
Adds support for recursive common table expressions (CTEs) in Ignite’s Calcite-based engine by introducing dedicated planner rules and coordinator-side execution nodes, along with SQL and Java integration tests to validate planning, execution, and quota behavior.
Changes:
- Introduce new physical rels and execution nodes for recursive UNION ALL evaluation and query-local recursive “delta” state management.
- Add planner converter rules and utilities to recognize/validate Calcite recursive CTE structures and optimize invariant subtrees.
- Add SQL and integration tests covering hierarchy traversal, sequence generation, subquery/correlation usage, plan shape, and memory quota enforcement.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| modules/calcite/src/test/sql/hierarchy/test_recursive_subquery.test | Adds SQL-script tests for recursive CTEs used inside subqueries/correlated EXISTS. |
| modules/calcite/src/test/sql/hierarchy/test_recursive_sequence.test | Adds SQL-script tests for recursive sequence generation patterns. |
| modules/calcite/src/test/sql/hierarchy/test_recursive_hierarchy.test | Adds SQL-script tests for hierarchical traversal and empty-seed behavior. |
| modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/RecursiveCteIntegrationTest.java | Expands integration coverage for recursive CTE execution, explain-plan shape, validation errors, and quotas. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/RecursiveTableSpoolConverterRule.java | Converts Calcite transient table spools to recursive delta spools. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/RecursiveTableScanConverterRule.java | Converts transient recursive table scans to query-local delta scans. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/RecursiveStaticSpoolConverterRule.java | Materializes invariant recursive-term subtrees for reuse across iterations. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/RecursiveCteUtils.java | Adds utilities for identifying recursive scans, counting references, invariance checks, and state IDs. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/RecursiveCteConverterRule.java | Converts Calcite LogicalRepeatUnion into Ignite coordinator-side recursive union execution. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/logical/IgniteLogicalRecursiveStaticSpool.java | Adds a logical marker node for static recursive-term inputs that should be spooled. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteRepeatUnion.java | Introduces a physical rel representing coordinator-side iterative UNION ALL. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteRelVisitor.java | Extends the visitor interface to include the new recursive rels. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteRecursiveTableSpool.java | Adds a physical rel to commit/replace the current recursive delta at end-of-input. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteRecursiveTableScan.java | Adds a physical rel to scan the current query-local recursive delta. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/PlannerPhase.java | Registers the new recursive planning rules into the planning pipeline. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteRelShuttle.java | Updates rel shuttle to traverse/handle new recursive rels. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/Cloner.java | Updates cloning logic to support new recursive rels. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/BaseQueryContext.java | Adds per-planning-context query-local IDs for recursive transient tables. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/metadata/IgniteMdFragmentMapping.java | Adds fragment mapping for recursive table scan as coordinator-local. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/RepeatUnionNode.java | Adds coordinator-side executor implementing iterative recursive UNION ALL control flow. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/RecursiveTableSpoolNode.java | Adds executor node that atomically commits the next delta at end-of-input. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/RecursiveCteState.java | Adds query-local recursive state storage with memory tracking. |
| modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java | Wires new recursive rels into execution node construction and shared state management. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| import java.util.List; | ||
| import org.apache.calcite.plan.RelTraitSet; | ||
| import org.apache.calcite.rel.RelNode; | ||
| import org.apache.calcite.rel.SingleRel; | ||
|
|
||
| /** Logical marker for a static recursive-term input that must be materialized on the coordinator. */ | ||
| public class IgniteLogicalRecursiveStaticSpool extends SingleRel { | ||
| /** */ | ||
| public IgniteLogicalRecursiveStaticSpool(RelNode input) { | ||
| super(input.getCluster(), input.getCluster().traitSet(), input); | ||
| } | ||
|
|
||
| /** {@inheritDoc} */ | ||
| @Override public RelNode copy(RelTraitSet traitSet, List<RelNode> inputs) { | ||
| return new IgniteLogicalRecursiveStaticSpool(sole(inputs)); | ||
| } | ||
| } |
There was a problem hiding this comment.
The same comment from me - seems you can merge RecursiveTableSpoolNode and existing TableSpoolNode, also i can`t find different implementations in calcite thus i think - this is correct approach
There was a problem hiding this comment.
- IgniteLogicalRecursiveStaticSpool#copy is definitely ignoring traitSet, which formally is a contract violation. (fixed)
- I believe that the traitSet has not to be inherited for input. Look at the Calcite class LogicalTableSpool.
- RecursiveTableSpoolNode and TableSpoolNode are different classes. TableSpoolNode accumulates rows one time and replays them on rewind(), but RecursiveTableSpoolNode makes a new row set in each iteration. We can use TableSpoolNode for only static datasets. Look at RecursiveStaticSpoolConverterRule. It creates IgniteTableSpool, which uses TableSpoolNode.
|
ok, looks good for me, let`s wait additional review |
| private final RecursiveCteState<Row> state; | ||
|
|
||
| /** Maximum number of recursive iterations, or a negative value for no limit. */ | ||
| private final int iterationLimit; |
There was a problem hiding this comment.
As far as I understand there is no way to now to set iterationLimit to value other than -1. Maybe we should limit it in execution node?
There was a problem hiding this comment.
I understand the intention to protect the query from infinite recursion. However, I would prefer not to hard-code an arbitrary positive limit, such as 100, because the SQL standard does not define such a limit and valid queries may require more iterations. Moreover, we always can create an explicit recursive parameter (like LEVEL in Oracle) to limit recursion depth through the query.
Calcite represents (RepeatUnion#iterationLimit) the iteration limit explicitly and defines the following semantics: -1 means unlimited recursion, 0 returns only the seed, and N allows no more than N recursive iterations. We currently preserve this value throughout the execution path:
LogicalRepeatUnion → RecursiveCteConverterRule → IgniteRepeatUnion → RepeatUnionNode.
If we want a safety limit, I think it should be configurable rather than hard-coded. We can create a separate ticket to expose it through a query-level hint, for example MAX_RECURSION(100). Do you think we should create such a ticket?
There was a problem hiding this comment.
In case of wrong query user can fail the whole cluster. I think there should be at least some configuration (maybe distributed property) with reasonable default limit (and error message with a hint that property can be changed if limit exceeded).
|
|
||
| DeterminismChecker checker = new DeterminismChecker(); | ||
|
|
||
| rel.accept(checker); |
There was a problem hiding this comment.
Looks like ProjectableFilterableTableScan, IgniteSortedIndexSpool, IgniteHashIndexSpool are not implement accept method as required (not sure if this can affect this ticket).
There was a problem hiding this comment.
This comment is definitely relevant. We use RexShuttle to determine whether a subtree of the recursive term is invariant and can be materialized only once. Without an accept implementation, expressions owned by these nodes are not visited, so a subtree containing non-deterministic expressions could be incorrectly treated as invariant and not re-evaluated on subsequent iterations.
I implemented accept for ProjectableFilterableTableScan, IgniteHashIndexSpool, and IgniteSortedIndexSpool, covering projections, conditions, and search rows.
This behavior is covered by the following tests in RecursiveCteIntegrationTest:
- testIndependentNonDeterministicSubtreeIsEvaluatedForEveryIteration
- testNonDeterministicTableScanIsNotMaterialized
- testNonDeterministicExpressionsInIndexSpoolsAreDetected — covers both hash and sorted index spools.
Add coverage for multiple seed branches. Make IgniteRepeatUnion extend Calcite RepeatUnion. Move the recursive delta quota test to MemoryQuotasIntegrationTest. Implement RexShuttle traversal for table scans and index spools. Add tests for non-deterministic recursive subtrees. More changes will follow....
More changes will follow...
More changes will follow...
|
|
||
| LogicalTableSpool spool = (LogicalTableSpool)rel; | ||
|
|
||
| if (!RecursiveCteUtils.sameTransientTable(spool.getTable(), table)) { |
| private final RecursiveCteState<Row> state; | ||
|
|
||
| /** Maximum number of recursive iterations, or a negative value for no limit. */ | ||
| private final int iterationLimit; |
There was a problem hiding this comment.
In case of wrong query user can fail the whole cluster. I think there should be at least some configuration (maybe distributed property) with reasonable default limit (and error message with a hint that property can be changed if limit exceeded).
| assertRecursivePlan(plan); | ||
| IgniteRepeatUnion repeatUnion = findFirstNode(plan, byClass(IgniteRepeatUnion.class)); | ||
|
|
||
| assertTrue(planDescription(plan), repeatUnion.getLeft() instanceof IgniteValues); | ||
| assertEquals(1, findNodes(plan, byClass(IgniteRecursiveTableScan.class)).size()); | ||
|
|
||
| checkSplitAndSerialization(plan, schema); |
There was a problem hiding this comment.
Let's avoid manual plans check and use assertPlan instead:
assertPlan(sql, schema, isInstanceOf(IgniteRepeatUnion.class)
.and(hasDistribution(IgniteDistributions.single()))
.and(input(0, isInstanceOf(IgniteValues.class)))
.and(input(1, hasChildThat(isInstanceOf(IgniteRecursiveTableScan.class)))
));
| } | ||
|
|
||
| /** Creates an employee table with the requested distribution and optional manager index. */ | ||
| private static IgniteSchema hierarchySchema(IgniteDistribution distribution, boolean withManagerIndex) { |
There was a problem hiding this comment.
Abbreviation should be used for "index"
|
|
||
| IgniteRel plan = physicalPlan( | ||
| "WITH RECURSIVE numbers(n) AS (" + | ||
| "SELECT 1 " + |
There was a problem hiding this comment.
Values are merged on union and this is not quite informative test. Lets check that "union" for seed remains and nested into left hand of RepeatUnion, for example, something like:
IgniteSchema schema = createSchema(createTable("T", IgniteDistributions.single(), "ID", SqlTypeName.INTEGER));
String sql = "WITH RECURSIVE numbers(n) AS (" +
"SELECT ID FROM T WHERE ID = 1 " +
"UNION ALL " +
"SELECT ID FROM T WHERE ID = 2 " +
"UNION ALL " +
"SELECT n + 1 FROM numbers WHERE n < 3" +
") " +
"SELECT n FROM numbers";
assertPlan(sql, schema, isInstanceOf(IgniteRepeatUnion.class)
.and(input(0, isInstanceOf(IgniteUnionAll.class)
.and(input(0, isTableScan("T")))
.and(input(1, isTableScan("T")))
))
);
|
|
||
| /** */ | ||
| @Test | ||
| public void testNonDeterministicTableScanIsNotMaterialized() { |
There was a problem hiding this comment.
Result is not checked. Only plan. Maybe move it to the planner test?
| import static org.apache.ignite.internal.processors.query.calcite.trait.IgniteDistributions.single; | ||
|
|
||
| /** Converts Calcite's logical recursive union to coordinator-side execution. */ | ||
| public class RecursiveCteConverterRule extends AbstractIgniteConverterRule<LogicalRepeatUnion> { |
There was a problem hiding this comment.
RepeatUnionConverterRule?
| private final ExpressionFactory<Row> expressionFactory; | ||
|
|
||
| /** Query-local recursive CTE states, keyed by transient table identifier. */ | ||
| private final Map<String, RecursiveCteState<Row>> recursiveStates = new HashMap<>(); |
There was a problem hiding this comment.
Maybe it's worth to store cte state inside execution context, not inside rel implementor?
| } | ||
|
|
||
| /** Returns an identifier unique for the given recursive CTE within this planning context. */ | ||
| public synchronized String recursiveCteStateId(RelOptTable table) { |
There was a problem hiding this comment.
State id only generated on planning phase, looks like planning context is more suitable place for this method.
|
|
||
| for (RelNode input : inputs) { | ||
| if (referenceCount(input, table) == 0 && isInvariant(input)) | ||
| newInputs.add(new StaticInput(input)); |
There was a problem hiding this comment.
Can we convert traits in markStaticInputs instead of creating new rel node and new rule for this relationship node?
https://issues.apache.org/jira/browse/IGNITE-27822