Skip to content

IGNITE-27822 Support recursive query for Calcite engine - #13479

Open
vldpyatkov wants to merge 15 commits into
apache:masterfrom
vldpyatkov:ignite-27822
Open

IGNITE-27822 Support recursive query for Calcite engine#13479
vldpyatkov wants to merge 15 commits into
apache:masterfrom
vldpyatkov:ignite-27822

Conversation

@vldpyatkov

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +20 to +36
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));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. IgniteLogicalRecursiveStaticSpool#copy is definitely ignoring traitSet, which formally is a contract violation. (fixed)
  2. I believe that the traitSet has not to be inherited for input. Look at the Calcite class LogicalTableSpool.
  3. 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.

@zstan
zstan requested a review from alex-plekhanov August 18, 2026 06:53
@zstan

zstan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like ProjectableFilterableTableScan, IgniteSortedIndexSpool, IgniteHashIndexSpool are not implement accept method as required (not sure if this can affect this ticket).

@vldpyatkov vldpyatkov Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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....

LogicalTableSpool spool = (LogicalTableSpool)rel;

if (!RecursiveCteUtils.sameTransientTable(spool.getTable(), table)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant braces

private final RecursiveCteState<Row> state;

/** Maximum number of recursive iterations, or a negative value for no limit. */
private final int iterationLimit;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +69 to +75
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abbreviation should be used for "index"


IgniteRel plan = physicalPlan(
"WITH RECURSIVE numbers(n) AS (" +
"SELECT 1 " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we convert traits in markStaticInputs instead of creating new rel node and new rule for this relationship node?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants