huangsheng created CALCITE-7924:
-----------------------------------

             Summary: RelMdExpressionLineage Cartesian expansion over UNION ALL 
causes excessive memory use and GC
                 Key: CALCITE-7924
                 URL: https://issues.apache.org/jira/browse/CALCITE-7924
             Project: Calcite
          Issue Type: Bug
          Components: core
            Reporter: huangsheng


h2. Summary

Expression-lineage inference can enumerate the Cartesian product of possible 
column origins across UNION ALL inputs. For an expression referencing K columns 
over N branches, this can construct N^K candidate expressions, with no work 
budget in the expansion helper.

This is reproducible using Calcite alone, without Feldera classes, data 
ingestion, or a running database. Both window expressions (RexOver in 
Project/Calc) and ordinary scalar expressions are affected. The resulting 
allocations can dominate planning time and memory.

This issue was discovered while compiling SQL in Feldera and was subsequently 
reproduced in Calcite alone.
Related issue: https://github.com/feldera/feldera/issues/7870

h2. Parameterized reproducer

The following storage-free helper generates SQL with adjustable branch count, 
referenced-column count, and scalar/window expression type. Put it in 
core/src/test/java/org/apache/calcite/rel/metadata/ExpressionLineageProgram.java
 (with the normal ASF source header).

{code:java}
package org.apache.calcite.rel.metadata;

import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.calcite.schema.impl.AbstractTable;
import org.apache.calcite.sql.SqlNode;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.tools.Frameworks;
import org.apache.calcite.tools.Planner;

import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static com.google.common.base.Preconditions.checkArgument;

final class ExpressionLineageProgram {
  private ExpressionLineageProgram() {
  }

  static String sql(int branches, int columns, boolean window) {
    checkArgument(branches >= 1, "branches must be positive");
    checkArgument(columns >= 1, "columns must be positive");
    String fields = IntStream.range(0, columns).mapToObj(i -> "c" + i)
        .collect(Collectors.joining(", "));
    String expression;
    if (window) {
      String partition = columns == 1 ? "" : "partition by "
          + IntStream.range(0, columns - 1).mapToObj(i -> "c" + i)
              .collect(Collectors.joining(", ")) + " ";
      expression = "row_number() over (" + partition + "order by c" + (columns 
- 1) + ")";
    } else {
      expression = IntStream.range(0, columns).mapToObj(i -> "c" + i)
          .collect(Collectors.joining(" + "));
    }
    String inputs = IntStream.range(0, branches)
        .mapToObj(i -> "select " + fields + " from events")
        .collect(Collectors.joining(" union all "));
    return "select " + fields + ", " + expression + " as computed_value from (" 
+ inputs + ") u";
  }

  static RelNode rel(int branches, int columns, boolean window) throws 
Exception {
    String sql = sql(branches, columns, window);
    SchemaPlus schema = Frameworks.createRootSchema(true);
    schema.add("EVENTS", new AbstractTable() {
      @Override public RelDataType getRowType(RelDataTypeFactory typeFactory) {
        RelDataTypeFactory.Builder builder = typeFactory.builder();
        for (int i = 0; i < columns; i++) {
          builder.add("C" + i, SqlTypeName.INTEGER);
        }
        return builder.build();
      }
    });
    try (Planner planner = Frameworks.getPlanner(Frameworks.newConfigBuilder()
        .defaultSchema(schema).build())) {
      SqlNode validated = planner.validate(planner.parse(sql));
      return planner.rel(validated).project();
    }
  }
}
{code}

A bounded regression test can be placed in the same package as 
RelMdExpressionLineageTest.java:

{code:java}
package org.apache.calcite.rel.metadata;

import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rex.RexInputRef;
import org.apache.calcite.rex.RexNode;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;

class RelMdExpressionLineageTest {
  @Test void testGeneratedProgramExceedsBudget() throws Exception {
    RelNode rel = ExpressionLineageProgram.rel(16, 4, true);
    Set<RexNode> lineage = 
rel.getCluster().getMetadataQuery().getExpressionLineage(rel,
        RexInputRef.of(4, rel.getRowType().getFieldList()));
    assertTrue(lineage == null, () -> "Expected unknown lineage, not "
        + (lineage == null ? "null" : lineage.size()) + " candidates");
  }
}
{code}

{code}
./gradlew -PjdkBuildVersion=21 :core:test   --tests 
org.apache.calcite.rel.metadata.RelMdExpressionLineageTest
{code}

The assertion above expresses the proposed bounded behavior with a 
10,000-candidate prototype budget; it is not a claim that this budget is 
already an upstream contract. On unchanged upstream it fails with 65,536 
candidates; with the prototype it passes. There is no need to trigger an actual 
OOM in the regression test.

Observed upstream candidate counts for four referenced columns:

||UNION ALL branches||Candidates||
|2|16|
|4|256|
|8|4,096|
|16|65,536|
|32|1,048,576|

The exponent comes from the referenced columns, not UNION nesting depth. 
Setting window=false demonstrates that the expansion problem is not exclusive 
to window functions.

h2. Root-cause analysis

The relevant path is:
{code}
ExpandDisjunctionForTableRule.apply
  -> RelMetadataQuery.getExpressionLineage
  -> Project/Calc lineage
  -> Union lineage collects candidate origins per referenced column
  -> RelMdExpressionLineage.createAllPossibleExpressions
  -> createExpressions copies an expression for each combination
{code}

The common helper recursively forms the Cartesian product of replacement sets 
and retains candidate RexNodes in sets. The bloat check in 
ExpandDisjunctionForTableRule occurs after the lineage request, so it does not 
bound this earlier work.

There is also a representation difference: the relational Window handler 
already returns unknown for window-result fields, while RexOver inside 
Project/Calc can enter the expansion path. However, a window-only guard would 
not cover the scalar-expression case shown above.

h2. Proposed budget prototype (for discussion)

Rather than rejecting all window lineage, the prototype keeps existing behavior 
within a configurable per-expansion budget and returns unknown when the 
candidate-construction work would exceed it. It never returns a truncated set 
as complete lineage.

In createAllPossibleExpressions, after extracting referenced fields and 
handling the no-input-reference case, the candidate product can be checked 
before copying RexNodes:

{code:java}
if (maxExpansions >= 0) {
  int remaining = maxExpansions;
  for (Map.Entry<RexInputRef, Set<RexNode>> entry : mapping.entrySet()) {
    if (predFieldsUsed.get(entry.getKey().getIndex())) {
      int count = entry.getValue().size();
      if (count > remaining) {
        return null;
      }
      if (count > 0) {
        remaining /= count;
      }
    }
  }
}
{code}

This counts construction attempts before deduplication and avoids integer 
overflow. Repeated occurrences of the same input reference share a 
substitution; unused mapping keys do not contribute a dimension.

The local prototype uses calcite.metadata.expression.lineage.max.expansions 
with default 10,000, -1 for unlimited, and 0 to disable expansions involving 
input references. The configuration mechanism, default, and appropriate scope 
are open for discussion. This is a per-expansion work bound, not a query-wide 
memory limit or a bound on the size of an individual expression. AllPredicates 
metadata also uses this helper and therefore needs regression coverage.

h2. Pure Calcite measurements

Median of three runs per variant. Time measures the lineage metadata call; 
memory is peak process RSS.

||Case||Upstream metadata time||Budget prototype||Upstream peak RSS||Prototype 
peak RSS||
|8 branches x 4 columns, window|22.82 ms|22.84 ms|164.00 MiB|164.28 MiB|
|16 branches x 4 columns, window|203.93 ms|5.77 ms|241.55 MiB|164.64 MiB|
|32 branches x 4 columns, window|2026.93 ms|10.97 ms|879.31 MiB|169.55 MiB|
|32 branches x 4 columns, scalar|1809.44 ms|11.78 ms|927.88 MiB|167.16 MiB|

The 8-branch window case preserves all 4,096 candidates in both variants. 
Larger cases return unknown in the prototype, so the savings are from bounded 
optional inference, not from computing the complete large candidate set faster.

For the 32-branch window case, total heap used after an explicit GC while 
retaining the plan/result was approximately 436.18 MiB versus 9.15 MiB. These 
are total live-heap observations, not a retained-size attribution to one object.

h2. Downstream confirmation (separate from the pure Calcite benchmark)

The full anonymized SQL is already public at:
https://github.com/user-attachments/files/34479263/oom-anonymized.sql.txt

For the same full anonymized SQL, changing only the Calcite budget 
implementation:

||Metric||Unmodified||Calcite budget prototype||
|SQL-to-Rust generation|402.32 s|4.83 s|
|Peak process RSS|8.35 GiB|522.48 MiB|
|Full GC count|142|0|
|Exit code|0|0|

These timings cover SQL-to-Rust generation, not native Rust compilation.


Would a per-expansion budget in this common helper be an appropriate starting 
point, or should the budget be accounted at a broader metadata-query scope?




--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to