CPU 100% / query never finishes: UnaliasSymbolReferences dedup creates mapping cycle from equal constants
Summary
UnaliasSymbolReferences optimizer enters an infinite loop (or OOM) when processing a query that causes the symbol mapping to contain a cycle. The root cause is the deduplication logic in canonicalize(Assignments), which merges different symbols that fold to equal ConstantExpression values. Because ConstantExpression.equals()/hashCode() is value-based, distinct columns filtered to the same literal via IN('...') become "equal" and get mapped to each other. Across multiple ProjectNodes the mappings form a 2-node cycle (A→B, B→A), causing the while loop in canonicalize(VariableReferenceExpression) to spin forever.
Affected Version
Reproduced on presto-0.296.
Steps to Reproduce
1. Create the table
CREATE TABLE ads.ads_bi_tob_exp_app_mall_pool_1d_d_s0(
`exp_salt` string,
`exp_layer_id` string,
`exp_id` string,
`exp_layer_name` string,
`exp_name` string,
`mall_pool_id` string,
`mall_tag` string,
`mall_tag_value` string,
`is_mall_online_ad_goods_cnt_above_99pct` string,
`is_mall_ad_bill_spend_above_99pct` string,
`buk_cnt` double,
`ad_bill_spend_1d_buk` double,
`mall_pool_type` string,
`est_use_amt_buck` bigint,
`abflag` bigint,
`est_use_amt` double
)
PARTITIONED BY (`pt` string)
STORED AS ORC;Only the columns referenced by the reproducer SQL are included above. The original table has 81 columns; the extra columns are irrelevant to the bug.
2. Run the reproducer query
It must be this SQL statement. Reproducing this bug requires extremely strict conditions.
SELECT `col_1`,`col_2`,`col_3`,`col_4`,`col_5`,`col_6`,`col_7`,`col_8`,`col_9`,`col_10`,`col_11`,`col_12`,`col_13`
FROM (
WITH tmp_exp AS
(
SELECT
pt,
exp_layer_name,
exp_name,
abflag,
is_mall_online_ad_goods_cnt_above_99pct,
is_mall_ad_bill_spend_above_99pct,
mall_tag,
mall_tag_value,
mall_pool_id,
mall_pool_type,
sum(buk_cnt) AS buk_cnt,
sum(ad_bill_spend_1d_buk*buk_cnt)/sum(buk_cnt) AS ad_bill_spend_1d_buk,
nvl(sum(nvl(est_use_amt,est_use_amt_buck)*buk_cnt)/sum(buk_cnt),0.0) AS est_use_amt
FROM ads.ads_bi_tob_exp_app_mall_pool_1d_d_s0
WHERE pt BETWEEN '2026-09-07' AND '2026-09-09'
AND exp_layer_name IN ('goods_subsidy_popup_full')
AND is_mall_online_ad_goods_cnt_above_99pct IN ('ALL')
AND is_mall_ad_bill_spend_above_99pct IN ('ALL')
AND mall_pool_id IN ('200056')
AND mall_pool_type IN ('cumulative_pool')
AND mall_tag IN ('ALL')
AND mall_tag_value IN ('ALL')
AND exp_name IN ('GoodsSubsidyPopupFull1')
GROUP BY
pt, exp_layer_name, exp_name, abflag,
is_mall_online_ad_goods_cnt_above_99pct,
is_mall_ad_bill_spend_above_99pct,
mall_tag, mall_tag_value, mall_pool_id, mall_pool_type
),
tmp_base AS
(
SELECT
pt,
exp_layer_name,
exp_name,
abflag,
is_mall_online_ad_goods_cnt_above_99pct,
is_mall_ad_bill_spend_above_99pct,
mall_tag,
mall_tag_value,
mall_pool_id,
mall_pool_type,
sum(buk_cnt) AS buk_cnt,
sum(ad_bill_spend_1d_buk*buk_cnt)/sum(buk_cnt) AS ad_bill_spend_1d_buk,
nvl(sum(nvl(est_use_amt,est_use_amt_buck)*buk_cnt)/sum(buk_cnt),0.0) AS est_use_amt
FROM ads.ads_bi_tob_exp_app_mall_pool_1d_d_s0
WHERE pt BETWEEN '2026-09-07' AND '2026-09-09'
AND exp_layer_name IN ('goods_subsidy_popup_full')
AND is_mall_online_ad_goods_cnt_above_99pct IN ('ALL')
AND is_mall_ad_bill_spend_above_99pct IN ('ALL')
AND mall_pool_id IN ('200056')
AND mall_pool_type IN ('cumulative_pool')
AND mall_tag IN ('ALL')
AND mall_tag_value IN ('ALL')
AND exp_name IN ('goods_subsidy_popup_full_base')
GROUP BY
pt, exp_layer_name, exp_name, abflag,
is_mall_online_ad_goods_cnt_above_99pct,
is_mall_ad_bill_spend_above_99pct,
mall_tag, mall_tag_value, mall_pool_id, mall_pool_type
)
SELECT
nvl(exp.pt,'PLACEHOLDER') AS `col_1`,
exp.exp_layer_name AS `col_2`,
exp.exp_name AS `col_3`,
base.exp_name AS `col_4`,
exp.buk_cnt AS `col_5`,
base.buk_cnt AS `col_6`,
exp.is_mall_ad_bill_spend_above_99pct AS `col_7`,
exp.is_mall_online_ad_goods_cnt_above_99pct AS `col_8`,
exp.mall_pool_type AS `col_9`,
exp.mall_pool_id AS `col_10`,
exp.mall_tag AS `col_11`,
exp.mall_tag_value AS `col_12`,
(sum(exp.real_ad_spend)-sum(base.real_ad_spend))
/(sum(nvl(exp.est_use_amt,0.0))-sum(nvl(base.est_use_amt,0.0))) AS `col_13`
FROM (
SELECT
*,
nvl(ad_bill_spend_1d_buk,0.0)-nvl(est_use_amt,0.0) real_ad_spend
FROM tmp_exp
) exp
LEFT JOIN (
SELECT
*,
nvl(ad_bill_spend_1d_buk,0.0)-nvl(est_use_amt,0.0) real_ad_spend
FROM tmp_base
) base
ON exp.pt = base.pt
AND exp.exp_layer_name = base.exp_layer_name
AND exp.is_mall_online_ad_goods_cnt_above_99pct = base.is_mall_online_ad_goods_cnt_above_99pct
AND exp.is_mall_ad_bill_spend_above_99pct = base.is_mall_ad_bill_spend_above_99pct
AND exp.mall_pool_id = base.mall_pool_id
AND exp.mall_pool_type = base.mall_pool_type
AND exp.mall_tag = base.mall_Tag
AND exp.mall_tag_value = base.mall_tag_value
AND ((exp.abflag=base.abflag) OR (exp.abflag IS NULL AND base.abflag IS NULL))
GROUP BY GROUPING SETS (
(exp.pt, exp.exp_layer_name, exp.exp_name, base.exp_name, exp.buk_cnt, base.buk_cnt,
exp.is_mall_ad_bill_spend_above_99pct, exp.is_mall_online_ad_goods_cnt_above_99pct,
exp.mall_pool_type, exp.mall_pool_id, exp.mall_tag, exp.mall_tag_value),
(exp.exp_layer_name, exp.exp_name, base.exp_name, exp.buk_cnt, base.buk_cnt,
exp.is_mall_ad_bill_spend_above_99pct, exp.is_mall_online_ad_goods_cnt_above_99pct,
exp.mall_pool_type, exp.mall_pool_id, exp.mall_tag, exp.mall_tag_value)
)
ORDER BY exp.exp_layer_name, exp.exp_name, base.exp_name
) t LIMIT 50000;3. Observed behavior
The query never completes. CPU spikes to 100% on a single core and the query times out (or the coordinator OOMs, depending on heap size).
Root Cause
The dedup logic in canonicalize(Assignments)
UnaliasSymbolReferences.canonicalize(Assignments) has a dedup optimization: within a single ProjectNode, if two different keys map to the same deterministic expression, they are merged into one symbol:
// UnaliasSymbolReferences.java, canonicalize(Assignments)
else if (!isNull(expression) && determinismEvaluator.isDeterministic(expression)) {
VariableReferenceExpression computedVariable = computedExpressions.get(expression);
if (computedVariable == null) {
computedExpressions.put(expression, entry.getKey());
} else {
map(entry.getKey(), computedVariable); // merge: key -> computedVariable
}
}computedExpressions is a HashMap<RowExpression, VariableReferenceExpression>. The problem is that ConstantExpression.equals()/hashCode() is value-based:
// ConstantExpression.java
public int hashCode() {
return Objects.hash(value, type);
}
public boolean equals(Object obj) {
...
return Objects.equals(this.value, other.value)
&& Objects.equals(this.type, other.type);
}For VARCHAR type, value is a Slice (byte buffer), and Slice.equals compares byte content. So two ConstantExpression('ALL', VARCHAR) from different columns are equals == true with the same hashCode, even though they represent semantically distinct grouping dimensions.
How the cycle forms
Four columns (is_mall_online_ad_goods_cnt_above_99pct, is_mall_ad_bill_spend_above_99pct, mall_tag, mall_tag_value) are filtered to the same single value 'ALL' via IN('ALL'). After constant folding, they all become ConstantExpression('ALL', VARCHAR).
By adding per-ProjectNode debug logging, the cycle formation was traced:
ProjectNode #3 (columns appear in order A, B, mall_tag, mall_tag_value):
key='is_mall_online_ad_goods_cnt_above_99pct' expr=ConstantExpression('ALL') ← first seen, record
key='is_mall_ad_bill_spend_above_99pct' expr=ConstantExpression('ALL') ← equals hit!
→ map('is_mall_ad_bill_spend_above_99pct' → 'is_mall_online_ad_goods_cnt_above_99pct') i.e. B→A
key='mall_tag' expr=ConstantExpression('ALL') ← equals hit!
→ map('mall_tag' → 'is_mall_online_ad_goods_cnt_above_99pct')
key='mall_tag_value' expr=ConstantExpression('ALL') ← equals hit!
→ map('mall_tag_value' → 'is_mall_online_ad_goods_cnt_above_99pct')ProjectNode #9 (same constants, but in order B, A):
key='is_mall_ad_bill_spend_above_99pct' expr=ConstantExpression('ALL') ← first seen, record
key='is_mall_online_ad_goods_cnt_above_99pct' expr=ConstantExpression('ALL') ← equals hit!
→ map('is_mall_online_ad_goods_cnt_above_99pct' → 'is_mall_ad_bill_spend_above_99pct') i.e. A→B ← CYCLE!ProjectNode #3 establishes B→A, ProjectNode #9 establishes A→B — a 2-node cycle.
The while loop in canonicalize(VariableReferenceExpression) then spins forever:
private VariableReferenceExpression canonicalize(VariableReferenceExpression variable)
{
String canonical = variable.getName();
while (mapping.containsKey(canonical)) { // ← A↔B cycle, infinite loop
canonical = mapping.get(canonical);
}
...
}Why this is a bug (not just a missing cycle guard)
The dedup optimization assumes "identical deterministic expressions can be merged into one symbol." This assumption is wrong for GROUPING SETS — the grouping dimension columns are semantically distinct even if their values happen to be identical. Merging them breaks the grouping semantics.
Why the reproducer cannot be trivially small
The bug does not depend on SQL topology alone — it depends on plan depth. With a small SQL, the constant is folded once at the CTE entry and reused as a single symbol across all downstream ProjectNodes, so dedup never triggers. Only when the plan is deep enough (CTE + derived column layer + join + grouping sets + a complex aggregate expression that references both a derived column and a raw column) does the optimizer fold the constant independently in multiple ProjectNodes, producing multiple ConstantExpression('ALL') instances that are equals but come from different columns.
Proposed Fix
Two-layer guard in UnaliasSymbolReferences.java:
1. Cycle detection in map() (primary fix)
Before establishing variable → canonical, check whether canonical already (transitively) maps back to variable. If so, skip the mapping to keep both symbols independent:
private void map(VariableReferenceExpression variable, VariableReferenceExpression canonical)
{
Preconditions.checkArgument(!variable.equals(canonical), "Can't map variable to itself: %s", variable);
String variableName = variable.getName();
String canonicalName = canonical.getName();
if (wouldCreateCycle(variableName, canonicalName)) {
return;
}
mapping.put(variableName, canonicalName);
}
private boolean wouldCreateCycle(String from, String to)
{
String current = to;
while (mapping.containsKey(current)) {
current = mapping.get(current);
if (current.equals(from)) {
return true;
}
}
return false;
}Skipping the mapping is safe: a cycle means the two symbols are being merged into each other via different paths. Keeping them independent does not lose any information — the dedup optimization is an optimization, not a correctness requirement.
2. Hop limit in canonicalize(VariableReferenceExpression) (defensive guard)
private VariableReferenceExpression canonicalize(VariableReferenceExpression variable)
{
String canonical = variable.getName();
int hops = 0;
while (mapping.containsKey(canonical)) {
canonical = mapping.get(canonical);
if (++hops > mapping.size()) {
break; // cycle detected, avoid infinite loop
}
}
return new VariableReferenceExpression(variable.getSourceLocation(), canonical,
types.get(new SymbolReference(getNodeLocation(variable.getSourceLocation()), canonical)));
}Verification
| Scenario | Before fix | After fix |
|---|---|---|
| Reproducer SQL (108 lines) | Timeout / OOM | Success (~1s) |
| Original production SQL (385 lines) | Timeout (130s) | Success (2.7s) |
TestUnaliasSymbolReferences unit tests |
— | 6/6 passed |
Source: prestodb/presto