[ES|QL] Replace an IN subquery evaluated as a false filter with an empty local relation - #155648
Conversation
|
Hi @fang-xing-esql, I've created a changelog YAML for you. |
🔍 Preview links for changed docs⏳ Building and deploying preview... View progress This comment will be updated with preview links when the build is complete. |
ℹ️ Important: Docs version tagging👋 Thanks for updating the docs! Just a friendly reminder that our docs are now cumulative. This means all 9.x versions are documented on the same page and published off of the main branch, instead of creating separate pages for each minor version. We use applies_to tags to mark version-specific features and changes. Expand for a quick overviewWhen to use applies_to tags:✅ At the page level to indicate which products/deployments the content applies to (mandatory) What NOT to do:❌ Don't remove or replace information that applies to an older version 🤔 Need help?
|
3e67b88 to
5989987
Compare
|
Hi @fang-xing-esql, I've created a changelog YAML for you. |
There was a problem hiding this comment.
Pull request overview
This PR fixes an ES|QL failure mode where WHERE <field> IN (subquery) (and related NULL/empty-result short-circuit cases) could fold a plan into a coordinator-only path that then crashed during physical planning (reported as IndexOutOfBoundsException: toIndex = 2) when the subquery produced zero rows, particularly for external datasets / data federation and CCS scenarios.
Changes:
- Rewrite SEMI join’s “empty right side” and shared NULL short-circuit plans to return an empty
LocalRelation(instead ofFilter(FALSE)), and simplify ANTI join’s “empty right side” plan to return the left child. - Preserve prior shard accounting for clusters that were already searched by subplans when the main plan becomes coordinator-only.
- Add regression tests for empty-result IN subqueries over datasets, and update existing unit/CCS tests to reflect the new rewrite shape.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/AbstractSubqueryJoin.java | Changes NULL short-circuit plan to return empty LocalRelation and updates related docstrings. |
| x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/SemiJoin.java | Makes empty-right SEMI join collapse to an empty LocalRelation via EmptyLocalSupplier. |
| x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/AntiJoin.java | Makes empty-right ANTI join collapse directly to the left child plan. |
| x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java | Avoids overwriting shard stats when coordinator-only main plan follows subplans that already searched shards. |
| x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/logical/join/SubqueryJoinTests.java | Updates unit tests to assert the new LocalRelation collapse behavior for empty/NULL short-circuit paths. |
| x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/FromDatasetSubqueryIT.java | Adds regression coverage for empty IN-subquery followed by STATS/SORT over external datasets; includes ANTI/NULL variants. |
| x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterInSubqueryIT.java | Updates CCS shard-count expectations for cases where the main plan no longer searches remotes. |
| docs/changelog/155648.yaml | Adds changelog entry for the bug fix. |
Suppressed comments (2)
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/logical/join/SubqueryJoinTests.java:156
- This Javadoc claims the empty-result path is equivalent to pruning a constant-false filter, but in this test the ANTI empty-right case rewrites to the LHS child (not a FALSE filter). The helper is really asserting that inlineData returned a LocalRelation with the left output and optionally an EmptyLocalSupplier.
* The empty-result and NULL short-circuit paths collapse the join to an empty {@link LocalRelation} — the same
* rewrite {@code PruneFilters} applies to a constant-false filter, inlined into the join because subquery
* substitution runs after the logical optimizer and a {@code Filter(FALSE)} would otherwise survive to physical
* planning. The relation must carry the join's output (the left side's attributes) so references above resolve.
*/
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/FromDatasetSubqueryIT.java:1238
AntiJoin#buildEmptyRightSidePlanno longer substitutes a constant-true filter; it returns the left child directly. The Javadoc should be updated so it matches the implementation.
* ANTI join, empty right side: {@code x NOT IN ()} is TRUE for every row, so {@code AntiJoin#buildEmptyRightSidePlan} substitutes
* a constant-true filter and the whole dataset survives. The trailing STATS pins that the surviving scan still composes with the
* external aggregation split (the counterpart of {@link #testEmptyInSubqueryThenStatsOnDataset}).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ); | ||
|
|
||
| // ANTI with empty result -> TRUE | ||
| // ANTI with empty result: x IN NOT () is TRUE for every row, so the join collapses LHS child |
| * An IN subquery with an empty result substitutes a constant-false filter into the main plan without re-running the logical optimizer, | ||
| * and split discovery then prunes every file of the dataset scan. The gather exchange must survive that empty scan when the plan ends | ||
| * in STATS: the final aggregation reads its child's output as intermediate state, which only an exchange boundary delivers |
Fixes: #155563
Steps to reproduce the bug
Root cause analysis
The
ExchangeExecis removed from the plan byapplyExternalDistributionStrategyinComputeService. In non-external dataset path, the exchange is not removed, so we don't hit this issue with regular indices.The fix
If an in subquery returns empty results, the
SemiJoincan be replaced by an emptyLocalRelation, instead of putting aFilter(false)on top of its LHS child, then it won't causeapplyExternalDistributionStrategyinComputeServiceto remove theExchangeExec. It is a better choice to replaceFilter(false)with an emptyLocalRelationinAbstractSubqueryJoinandSemiJoinfrom performance perspective, as this plan is turned into a coordinator only plan, and it doesn't need to reach data nodes.With the empty LocalRelation in place, the rewritten main plan is:
If an in subquery returns empty results, the
AntiJoincan be replaced by its LHS child, instead of putting aFilter(true)on top of its LHS child.