Skip to content

[CALCITE-7924] RelMdExpressionLineage Cartesian expansion over UNION ALL causes excessive memory use and GC - #5396

Open
Mrhs121 wants to merge 2 commits into
apache:mainfrom
Mrhs121:fix-expression-lineage-budget-20261107
Open

Mrhs121 wants to merge 2 commits into
apache:mainfrom
Mrhs121:fix-expression-lineage-budget-20261107

Conversation

@Mrhs121

@Mrhs121 Mrhs121 commented Nov 6, 2026 •

Copy link
Copy Markdown

Jira Link

CALCITE-7924

Changes Proposed

Problem

This issue was discovered while compiling SQL in Feldera and reproduced using Calcite alone. Expression-lineage inference over UNION ALL can construct the Cartesian product of the possible origins of each referenced column. With N branches and K referenced columns, this can produce N^K candidate expressions and cause excessive allocation and GC during planning. Both scalar expressions and window expressions in Project/Calc are affected.

Proposed solution

Bound candidate construction in the shared RelMdExpressionLineage.createAllPossibleExpressions helper, before it copies any candidate RexNode:

  • Check the product of replacement-set sizes for referenced input columns against a configurable work budget. Repeated occurrences of a reference share a substitution, and unused mapping keys do not add a dimension.
  • Use division to check the product without integer overflow. Count construction attempts before deduplication, rather than the size of the final result set.
  • If the budget would be exceeded, return null (unknown metadata), never a truncated set presented as complete lineage. Keep the existing expansion and results within the budget, including small window-expression cases; this is not a blanket rejection of window lineage.
  • Introduce calcite.metadata.expression.lineage.max.expansions, defaulting to 10000. -1 restores unlimited expansion, and 0 disables expansions involving input references. Constants require no expansion and remain unaffected. Values below -1 fall back to the default.
  • Document the bounded behavior in the metadata contract. The shared helper also covers all-predicates inference.

The budget is per expansion, not query-wide. It does not bound metadata traversal or the size of an individual expression. The default threshold and configuration mechanism are open for discussion.

Reproducer and regression coverage

Add a parameterized, storage-free program generator with adjustable UNION ALL branch count, referenced-column count, and scalar/window expression type. The tests exercise both Project and Calc, budget boundaries, unlimited/zero budgets, duplicate candidates, repeated and unused references, overflow prevention, metadata cache invalidation, and all-predicates inference.

For example, ExpressionLineageProgram.rel(16, 4, true) produces a plan whose computed-column lineage expands to 65,536 candidates without the limit. With the proposed default it returns unknown before enumerating those candidates. Smaller cases still return complete lineage, and querying a passthrough column remains supported after a budget rejection.

Observed impact

Pure Calcite benchmark: In the 32-branch, four-column window case, median lineage-call time decreased from 2,026.93 ms to 10.97 ms, with peak process RSS decreasing from 879.31 MiB to 169.55 MiB. The 8-branch case remains within budget and retains all 4,096 candidates. This benchmark runs Calcite alone, without Feldera.

Feldera SQL compilation test: For the full anonymized SQL from feldera/feldera#7870, changing only the Calcite budget implementation reduced SQL-to-Rust generation from 402.32 s to 4.83 s and peak process RSS from 8.35 GiB to 522.48 MiB. This test uses the Feldera SQL compiler; the comparison does not include native Rust compilation or pipeline execution.

The improvement comes from bounding optional metadata inference, not from computing the same large candidate set faster. The reproducer and detailed measurements are in the linked Jira issue.

…ALL causes excessive memory use and GC

Bound Cartesian candidate construction before copying expressions, returning unknown metadata rather than a partial result when the configurable per-expansion budget is exceeded.

Preserve within-budget lineage and add a parameterized SQL generator with Project, Calc, all-predicates, and budget-boundary regression coverage.
@Mrhs121

Mrhs121 commented Nov 6, 2026

Copy link
Copy Markdown
Author

@mihaibudiu Could you please review this proposed per-expansion work budget? The PR includes a parameterized Calcite reproducer and returns unknown lineage before enumerating an over-budget Cartesian product, while preserving within-budget behavior. I would appreciate your feedback on the budget scope, default threshold, and configuration mechanism. The original Feldera report is feldera/feldera#7870. Thank you!

@mihaibudiu mihaibudiu left a comment

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.

This looks fine in essence, but I think it can be improved.


if (maxExpansions >= 0) {
// Every referenced column adds a dimension to the Cartesian product.
// Count before copying RexNodes, even if equal candidates would later

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.

estimate the count before...

the comment about division should be on the division operator.
it should say that instead of computing the product of counts and comparing with remaining you rather divide remaining to avoid overflow in the product

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.


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

/** Generates storage-free SQL programs whose expression lineage has

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.

what is a storage-free SQL program, and why is it important for this test?
I would make this class a static class in RelMdExpressionLineageTest

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.


/** Generates a SQL program with passthrough columns and a final computed
* column, either an arithmetic expression or a window expression. */
static String sql(int branches, int columns, boolean window) {

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 you show in a comment an example program generated for a small value of branches?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

return "select " + fields + ", " + expression + " as computed_value from (" + inputs + ") u";
}

/** Parses and validates the generated SQL using only Calcite. */

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.

reading this javadoc I don't know what this function does.
Either refer to sql() above or describe what this function returns: the representation of a large SQL program which requires exponential cost to analyze lineage

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

RelMetadataQuery mq = rel.getCluster().getMetadataQuery();
RexNode result = RexInputRef.of(columns, rel.getRowType().getFieldList());
Set<RexNode> lineage = mq.getExpressionLineage(rel, result);
if (Math.pow(branches, columns) > 10_000) {

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 you use the value of the property instead of 10K?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

assertNotNull(lineage);
assertEquals((int) Math.pow(branches, columns), lineage.size());
}
// An expensive result must not poison other columns or later queries.

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.

I don't know how results poison columns or queries.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

}
}

@Test void testBudgetBoundaryAndMappingUnchanged() {

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.

is this test and the next one needed? Doesn't the previous one cover them?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

assertNull(expand(expression, mapping, 3));
}

@Test void testRepeatedAndUnusedReferences() {

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.

how is this test related to the current change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Only distinct, referenced columns contribute to the estimate. That was the reason for the test, but it doesn't need a separate case here. Removed.

assertNull(expand(expression, mapping, 1));
}

@Test void testZeroBudgetAndConstants() {

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.

this is also a low-value test

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

assertThrows(IllegalArgumentException.class, () -> ExpressionLineageProgram.sql(2, 0, false));
}

@Test void testAllPredicatesUsesTheSameBudget() throws Exception {

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.

I am also not sure how this test is relevant for this change. Too many tests are not good either.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

AllPredicates uses the same expansion helper. Since this change adds no predicate-specific logic, I removed the separate test.

@mihaibudiu

Copy link
Copy Markdown
Contributor

what is the status of this PR?

@mihaibudiu mihaibudiu left a comment

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.

please squash the commits so we can merge this

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.

2 participants