Skip to content

ESQL: Prevent circular alias references in DeduplicateAggs#139175

Merged
elasticsearchmachine merged 19 commits intoelastic:mainfrom
kanoshiou:fix-enrich-alias-cycle
Dec 16, 2025
Merged

ESQL: Prevent circular alias references in DeduplicateAggs#139175
elasticsearchmachine merged 19 commits intoelastic:mainfrom
kanoshiou:fix-enrich-alias-cycle

Conversation

@kanoshiou
Copy link
Copy Markdown
Contributor

@kanoshiou kanoshiou commented Dec 7, 2025

Summary

Fixes circular reference detection in alias chains during DeduplicateAggs optimizations.

Problem

The PushDownEnrich optimization can create circular references when pushing Enrich past Project operations, causing ql_illegal_argument_exception: Potential cycle detected errors when DeduplicateAggs.

Example query:

FROM languages_lookup 
| RENAME language_name AS message  
| ENRICH languages_policy ON language_code 
| STATS a = count(to_lower(language_name)), b = count(language_name)

Changes

Skip aliases that would create circular references by checking if alias.toAttribute() equals the resolved alias.child().

Closes #138346
Closes #139541

@elasticsearchmachine elasticsearchmachine added v9.3.0 external-contributor Pull request authored by a developer outside the Elasticsearch team needs:triage Requires assignment of a team area label labels Dec 7, 2025
@idegtiarenko idegtiarenko added Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) :Analytics/ES|QL AKA ESQL labels Dec 10, 2025
@elasticsearchmachine elasticsearchmachine removed the needs:triage Requires assignment of a team area label label Dec 10, 2025
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

Pinging @elastic/es-analytical-engine (Team:Analytics)

@bpintea bpintea self-assigned this Dec 10, 2025
Copy link
Copy Markdown
Contributor

@bpintea bpintea left a comment

Choose a reason for hiding this comment

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

Thanks @kanoshiou for the fix!

Didn't look too deep at it yet, but we'd need unit tests too. This should also make it easier to understand how the defect occurs and how the fix patches it. 🙏

;


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

Could this test be "minimised" i.e. reduced to a minimal reproducer? This is not a must, however, if the other tests do actually that; but it would be, if there are multiple "minimum" scenarios that trigger the issue.

Also, could we make it "human" friendly. Those random identifiers are a pain to track.

Finally, would it be OK to capitalise the language keywords, to stay consistent? I'm not sure if that's always the case with these csv-spec tests, but it makes it again easier to parse the query when reviewing.

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.

In another PR, I was asked to include the original generated query in the CSV tests, so I have consistently followed that requirement. I assumed it was mandatory when addressing generative CSV test failures. I was also asked to include the original issue in the comment above the new capability.

/**
* Fix for circular reference in alias chains during PushDownEnrich and aggregate deduplication.
* Prevents "Potential cycle detected" errors when aliases reference each other.
* https://github.com/elastic/elasticsearch/issues/XXXXX
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.

Did you intend to add the PR number here? Or really an issue?
In any case, the GH link is optional, git will contain the history (but up to you).

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.

Sorry, this was AI generated and I didn’t review it carefully. This situation will not happen again.

# Conflicts:
#	x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@kanoshiou
Copy link
Copy Markdown
Contributor Author

Thank you for your review, @bpintea! Could you please advise on the types of tests that should be added? Would LogicalPlan optimizer tests be suitable, or is there any existing reference or guideline I should consult?

@ncordon ncordon self-requested a review December 11, 2025 08:53
@ncordon ncordon self-assigned this Dec 11, 2025
@ncordon ncordon added the >bug label Dec 11, 2025
@bpintea
Copy link
Copy Markdown
Contributor

bpintea commented Dec 11, 2025

Ah, sorry, that wasn't clear.

Would LogicalPlan optimizer tests be suitable

Yes, the patch updates a logical plan optimiser rule, so some tests in the LogicalPlanOptimizerTests suite should do.

@ncordon
Copy link
Copy Markdown
Member

ncordon commented Dec 12, 2025

buildkite test this please

assertThat(agg2.name(), is("b"));
var count2 = as(agg2.child(), Count.class);
var field2 = as(count2.field(), ReferenceAttribute.class);
assertThat(field2.name(), is("$$language_name$temp_name$23"));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This check will fail. If you execute the full suite you see a different id than if you only execute the particular test.

It's best to just check the name starts with "$$language_name$temp_name$"

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.

Thanks for your review! I have updated this test.

Copy link
Copy Markdown
Member

@ncordon ncordon left a comment

Choose a reason for hiding this comment

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

The PR looks good from my viewpoint, thanks a lot for addressing this @kanoshiou! The only think I would maybe tweak would be what we are testing exactly.

@bpintea opinions?

FROM firewall_logs*,languages_lookup
| RENAME language_name AS message
| LOOKUP JOIN message_types_lookup ON message
| DISSECT message "%{a}"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need tests with DISECT?

I think we should just have one test with RENAME after the ENRICH and with RENAME before the ENRICH instead

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 actually comes from #138346 (comment), and I didn't look into it in depth. I can remove this test if you’d like, since I believe the query below is the minimal reproducer.

FROM languages_lookup 
| RENAME language_name AS message  
| ENRICH languages_policy ON language_code 
| STATS a = count(to_lower(language_name)), b = count(language_name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm inclined to leave only that query if you don't mind 😄

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 will remove circularRefComplexEnrich and circularRefEnrichDissect once the CI finishes 😄

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Actually I think it might be good to test all possible combinations of aliasing ENRICH can introduce:

FROM languages_lookup 
| RENAME language_name AS message  
| ENRICH languages_policy ON language_code 
| STATS a = count(to_lower(language_name)), b = count(message)
FROM languages_lookup 
| ENRICH languages_policy ON language_code
| RENAME language_name AS message
| STATS a = count(to_lower(message)), b = count(message)
FROM languages_lookup 
| ENRICH languages_policy ON language_code WITH message=language_name
| RENAME language_name AS message
| STATS a = count(to_lower(message)), b = count(message)
FROM languages_lookup 
| ENRICH languages_policy ON language_code WITH message=language_name
| STATS a = count(to_lower(language_name)), b = count(message)

Do you mind adding those to the pr? After that I'm happy to approve and merge

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'm glad to add these tests. Thanks for providing them! 😊

@ncordon
Copy link
Copy Markdown
Member

ncordon commented Dec 12, 2025

buildkite test this please

# Conflicts:
#	x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
@kanoshiou
Copy link
Copy Markdown
Contributor Author

kanoshiou commented Dec 16, 2025

Hi @alex-spies @bpintea,

I have updated the branch. Regarding the issue, I see two possible solutions here:

  1. Update the nameId of ReferenceAttribute when modifying the generated ENRICH names. This is a safe change that fundamentally resolves potential circular references in the future. The only downside is that the new nameIds are often semantically meaningless.
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/Enrich.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/Enrich.java
@@ -234,7 +234,7 @@ public class Enrich extends UnaryPlan
             if (enrichField.name().equals(newName)) {
                 newEnrichFields.add(enrichField);
             } else if (enrichField instanceof ReferenceAttribute ra) {
-                newEnrichFields.add(new Alias(ra.source(), newName, ra, new NameId(), ra.synthetic()));
+                newEnrichFields.add(new Alias(ra.source(), newName, ra.withId(new NameId()), new NameId(), ra.synthetic()));
             } else if (enrichField instanceof Alias a) {
                 newEnrichFields.add(new Alias(a.source(), newName, a.child(), new NameId(), a.synthetic()));
             } else {

  1. Stick with the current patch. This limits the scope to PushDownEnrich. However, we cannot guarantee that similar bugs won't arise in other optimizer rules.

Which one do you think is better? I'd like to know your preference.

Copy link
Copy Markdown
Contributor

@alex-spies alex-spies left a comment

Choose a reason for hiding this comment

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

Update the nameId of ReferenceAttribute when modifying the generated ENRICH names. This is a safe change that fundamentally resolves potential circular references in the future. The only downside is that the new nameIds are often semantically meaningless.

I think this is the best change we can make.

It's a little unfortunate that Enrich doesn't just always have aliases for its output fields. ... | EVAL 2*x will still generate an alias behind the scenes, whereas ... | ENRICH policy ON field WITH field, foo = otherfield will only create an alias for otherfield.

However, Enrich's internal fields that say which physical fields we should fetch from the enrich policy don't matter to the plan - downstream nodes can only reference the output fields. Arguably, the enrich policy fields could be represented without the use of attributes or aliases altogether.

That is to say, the name ids of the original attributes are always semantically meaningless IMHO - they're only needed in the case without aliases. But ENRICH WITH field is equivalent to ENRICH WITH field = field; and turning an un-aliased enrich field into an aliased one with a new name id for the physical enrich policy field - that's essentially the same as re-writing ENRICH WITH field into ENRICH WITH field = field (+ renaming the left hand side of the alias to deal with the name conflict).

Comment on lines +118 to +126
UnaryPlan finalGeneratingPlan = generatingPlanWithRenamedAttributes;
if (finalGeneratingPlan instanceof Enrich enrich && renameGeneratedAttributeTo.isEmpty() == false) {
finalGeneratingPlan = (UnaryPlan) enrich.transformExpressionsOnly(Alias.class, alias -> {
if (alias.child() instanceof ReferenceAttribute ra && renameGeneratedAttributeTo.containsKey(ra.name())) {
return alias.replaceChild(ra.withId(new NameId()));
}
return alias;
});
}
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 think we could contain the change more inside Enrich.java, by assigning a new name id when calling withGeneratedNames here.

If that still passes tests, we should probably also add a comment saying "it's safe to generate new name ids when assigning new names - the original attribute representing the field in the enrich index cannot be directly referenced in downstream plans, anyway".

@kanoshiou
Copy link
Copy Markdown
Contributor Author

Thank you for the review, @alex-spies!

I’ve updated the branch, and it should be ready for testing now.

@bpintea
Copy link
Copy Markdown
Contributor

bpintea commented Dec 16, 2025

buildkite test this please

Copy link
Copy Markdown
Contributor

@bpintea bpintea left a comment

Choose a reason for hiding this comment

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

Thanks for this contribution, @kanoshiou!

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.

It'd have been great to have one test with ENRICH + SORT (besides RENAME), but this can come in a next PR (you may add one yourself, @kanoshiou, or I'll make a mental note to do it myself in some other PR).

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.

Thanks for your review @bpintea! I will add this test in my next PR ✍️

@bpintea bpintea added auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) v9.2.4 auto-backport Automatically create backport pull requests when merged v9.1.10 labels Dec 16, 2025
@elasticsearchmachine elasticsearchmachine merged commit 2289e17 into elastic:main Dec 16, 2025
37 checks passed
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

💔 Backport failed

Status Branch Result
9.1 Commit could not be cherrypicked due to conflicts
9.2 Commit could not be cherrypicked due to conflicts

You can use sqren/backport to manually backport by running backport --upstream elastic/elasticsearch --pr 139175

@kanoshiou
Copy link
Copy Markdown
Contributor Author

Hi @bpintea,

If you don’t have the time, I’m happy to take care of the manual backport myself.

bpintea pushed a commit to bpintea/elasticsearch that referenced this pull request Dec 16, 2025
…#139175)

Fixes circular reference detection in alias chains during
`DeduplicateAggs` optimizations.

The `PushDownEnrich` optimization can create circular references when
pushing `Enrich` past `Project` operations, causing
`ql_illegal_argument_exception: Potential cycle detected` errors when
`DeduplicateAggs`.

Example query:

```esql
FROM languages_lookup
| RENAME language_name AS message
| ENRICH languages_policy ON language_code
| STATS a = count(to_lower(language_name)), b = count(language_name)
```

Skip aliases that would create circular references by checking if
`alias.toAttribute()` equals the resolved `alias.child()`.

Closes elastic#138346 Closes elastic#139541

(cherry picked from commit 2289e17)
@bpintea
Copy link
Copy Markdown
Contributor

bpintea commented Dec 16, 2025

Hi @bpintea,
If you don’t have the time, I’m happy to take care of the manual backport myself.

Oh, that'd be great, thank you! I've meanwhile opened #139614 for 9.2, feel free to go on with the 9.1 backport please.

kanoshiou added a commit to kanoshiou/elasticsearch that referenced this pull request Dec 16, 2025
…#139175)

## Summary

Fixes circular reference detection in alias chains during
`DeduplicateAggs` optimizations.

## Problem

The `PushDownEnrich` optimization can create circular references when
pushing `Enrich` past `Project` operations, causing
`ql_illegal_argument_exception: Potential cycle detected` errors when
`DeduplicateAggs`.

Example query:

```esql
FROM languages_lookup
| RENAME language_name AS message
| ENRICH languages_policy ON language_code
| STATS a = count(to_lower(language_name)), b = count(language_name)
```

## Changes

Skip aliases that would create circular references by checking if
`alias.toAttribute()` equals the resolved `alias.child()`.

Closes elastic#138346 Closes elastic#139541

(cherry picked from commit 2289e17)

# Conflicts:
#	x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java
#	x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
#	x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
@bpintea
Copy link
Copy Markdown
Contributor

bpintea commented Dec 16, 2025

I've meanwhile opened #139614 for 9.2

I'll close it, I see you've opened yourself #139615

@kanoshiou
Copy link
Copy Markdown
Contributor Author

💚 All backports created successfully

Status Branch Result
9.1

Questions ?

Please refer to the Backport tool documentation

kanoshiou added a commit to kanoshiou/elasticsearch that referenced this pull request Dec 16, 2025
…#139175)

## Summary

Fixes circular reference detection in alias chains during
`DeduplicateAggs` optimizations.

## Problem

The `PushDownEnrich` optimization can create circular references when
pushing `Enrich` past `Project` operations, causing
`ql_illegal_argument_exception: Potential cycle detected` errors when
`DeduplicateAggs`.

Example query:

```esql
FROM languages_lookup
| RENAME language_name AS message
| ENRICH languages_policy ON language_code
| STATS a = count(to_lower(language_name)), b = count(language_name)
```

## Changes

Skip aliases that would create circular references by checking if
`alias.toAttribute()` equals the resolved `alias.child()`.

Closes elastic#138346 Closes elastic#139541

(cherry picked from commit 2289e17)

# Conflicts:
#	x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java
#	x-pack/plugin/esql/qa/testFixtures/src/main/resources/enrich.csv-spec
#	x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
#	x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
elasticsearchmachine pushed a commit that referenced this pull request Dec 16, 2025
#139615)

## Summary

Fixes circular reference detection in alias chains during
`DeduplicateAggs` optimizations.

## Problem

The `PushDownEnrich` optimization can create circular references when
pushing `Enrich` past `Project` operations, causing
`ql_illegal_argument_exception: Potential cycle detected` errors when
`DeduplicateAggs`.

Example query:

```esql
FROM languages_lookup
| RENAME language_name AS message
| ENRICH languages_policy ON language_code
| STATS a = count(to_lower(language_name)), b = count(language_name)
```

## Changes

Skip aliases that would create circular references by checking if
`alias.toAttribute()` equals the resolved `alias.child()`.

Closes #138346 Closes #139541

(cherry picked from commit 2289e17)

# Conflicts:
#	x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java
#	x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
#	x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
elasticsearchmachine pushed a commit that referenced this pull request Jan 23, 2026
…139175) (#139617)

* ESQL: Prevent circular alias references in `DeduplicateAggs` (#139175)

## Summary

Fixes circular reference detection in alias chains during
`DeduplicateAggs` optimizations.

## Problem

The `PushDownEnrich` optimization can create circular references when
pushing `Enrich` past `Project` operations, causing
`ql_illegal_argument_exception: Potential cycle detected` errors when
`DeduplicateAggs`.

Example query:

```esql
FROM languages_lookup
| RENAME language_name AS message
| ENRICH languages_policy ON language_code
| STATS a = count(to_lower(language_name)), b = count(language_name)
```

## Changes

Skip aliases that would create circular references by checking if
`alias.toAttribute()` equals the resolved `alias.child()`.

Closes #138346 Closes #139541

(cherry picked from commit 2289e17)

# Conflicts:
#	x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java
#	x-pack/plugin/esql/qa/testFixtures/src/main/resources/enrich.csv-spec
#	x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
#	x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java

* unmute GenerativeIT

* Fix tests

Lookup indices are not loaded in multi-cluster tests in 9.1. Instead of
languages_lookup we can also just use languages: same index, just
without the lookup mode set.

* Revert "Fix tests"

This reverts commit b0696b5.

Turns out, we cannot use the languages index because this one is also
used for enrich, and the multi cluster tests index this two times, on
the remote AND on the local cluster.

* Use index that isn't duplicated in multi-cluster

* Fix more tests...

---------

Co-authored-by: Alexander Spies <alexander.spies@elastic.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL auto-backport Automatically create backport pull requests when merged auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) backport pending >bug external-contributor Pull request authored by a developer outside the Elasticsearch team Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.1.10 v9.2.4 v9.3.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CI] GenerativeIT test failing ES|QL: Potential cycle detected on JOIN planning

6 participants