Skip to content

Commit b63eb62

Browse files
committed
Solves a bug
1 parent 5e6b993 commit b63eb62

6 files changed

Lines changed: 218 additions & 21 deletions

File tree

x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/TestAnalyzer.java

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,9 @@ public TestAnalyzer addLookupIndex(String name, String mappingLocation) {
392392
}
393393

394394
/**
395-
* Add an error resolving enrich indices.
395+
* Add an error resolving enrich indices. If {@code policyName}/{@code mode} already has a pending registration (from an
396+
* earlier {@link #addEnrichPolicy}/{@link #addEnrichError} call), this one is matched to the next occurrence of that
397+
* policy name/mode in the query instead of the first - see {@link #resolveEnrichResolution}.
396398
*/
397399
public TestAnalyzer addEnrichError(String policyName, Enrich.Mode mode, String reason) {
398400
pendingEnrichResolutions.add(new PendingEnrich(policyName, mode, null, reason));
@@ -450,7 +452,10 @@ public TestAnalyzer addEnrichPolicy(String policyType, String policy, String fie
450452
}
451453

452454
/**
453-
* Adds an enrich policy resolution with a specific mode by loading the mapping from a resource file.
455+
* Adds an enrich policy resolution with a specific mode by loading the mapping from a resource file. If
456+
* {@code policy}/{@code mode} already has a pending registration (from an earlier {@link #addEnrichPolicy}/
457+
* {@link #addEnrichError} call), this one is matched to the next occurrence of that policy name/mode in the query
458+
* instead of the first - see {@link #resolveEnrichResolution}.
454459
*/
455460
public TestAnalyzer addEnrichPolicy(Enrich.Mode mode, String policyType, String policy, String field, String index, String mapping) {
456461
IndexResolution indexResolution = loadMapping(mapping, index, IndexMode.STANDARD);
@@ -464,7 +469,11 @@ public TestAnalyzer addEnrichPolicy(Enrich.Mode mode, String policyType, String
464469
}
465470

466471
/**
467-
* Adds an enrich policy resolution with a specific mode by loading the mapping from a resource file.
472+
* Adds an enrich policy resolution with a specific mode. If {@code policy}/{@code mode} already has a pending
473+
* registration (from an earlier {@link #addEnrichPolicy}/{@link #addEnrichError} call), this one is matched to the next
474+
* occurrence of that policy name/mode in the query instead of the first - see {@link #resolveEnrichResolution}. This
475+
* lets a test give two occurrences of the same policy name/mode (e.g. one per subquery branch) different resolutions,
476+
* simply by calling {@link #addEnrichPolicy}/{@link #addEnrichError} more than once for that policy name/mode.
468477
*/
469478
public TestAnalyzer addEnrichPolicy(Enrich.Mode mode, String policy, ResolvedEnrichPolicy resolved) {
470479
pendingEnrichResolutions.add(new PendingEnrich(policy, mode, resolved, null));
@@ -475,23 +484,38 @@ public TestAnalyzer addEnrichPolicy(Enrich.Mode mode, String policy, ResolvedEnr
475484
* Matches pending {@link #addEnrichPolicy}/{@link #addEnrichError} registrations (queued by policy name + mode before the
476485
* query was known) against the actual {@link Enrich} occurrences in the now-parsed plan, and registers each match into the
477486
* real {@link #enrichResolution} keyed by that occurrence's {@code Source} - mirroring how {@code EnrichPolicyResolver}
478-
* keys production resolutions. If several occurrences share the same policy name and mode, every one of them is
479-
* registered with the same resolution.
487+
* keys production resolutions.
488+
* <p>
489+
* Registrations for the same policy name/mode are consumed in registration order, matched 1:1 against occurrences of
490+
* that policy name/mode in the order {@link Enrich} nodes are visited by {@link LogicalPlan#forEachUp} over the parsed
491+
* plan (for a top-to-bottom or left-to-right query this matches the order the occurrences appear in the query text): the
492+
* first registration goes to the first occurrence, the second to the second, and so on. If there are more occurrences
493+
* than registrations, the extra occurrences reuse the last registration - which is also why a single registration for a
494+
* given policy name/mode applies to every occurrence of it, as it always has before this per-occurrence matching
495+
* existed.
480496
*/
481497
private void resolveEnrichResolution(LogicalPlan plan) {
498+
Map<PolicyKey, List<PendingEnrich>> byKey = pendingEnrichResolutions.stream()
499+
.collect(groupingBy(p -> new PolicyKey(p.policyName(), p.mode()), LinkedHashMap::new, Collectors.toList()));
500+
Map<PolicyKey, Integer> occurrencesSeen = new HashMap<>();
482501
plan.forEachUp(Enrich.class, enrich -> {
483-
for (PendingEnrich pending : pendingEnrichResolutions) {
484-
if (pending.policyName().equals(enrich.resolvedPolicyName()) && pending.mode() == enrich.mode()) {
485-
if (pending.resolved() != null) {
486-
enrichResolution.addResolvedPolicy(enrich.source(), pending.resolved());
487-
} else {
488-
enrichResolution.addError(enrich.source(), pending.error());
489-
}
490-
}
502+
PolicyKey key = new PolicyKey(enrich.resolvedPolicyName(), enrich.mode());
503+
List<PendingEnrich> candidates = byKey.get(key);
504+
if (candidates == null) {
505+
return;
506+
}
507+
int occurrence = occurrencesSeen.merge(key, 1, Integer::sum) - 1;
508+
PendingEnrich matched = candidates.get(Math.min(occurrence, candidates.size() - 1));
509+
if (matched.resolved() != null) {
510+
enrichResolution.addResolvedPolicy(enrich.source(), matched.resolved());
511+
} else {
512+
enrichResolution.addError(enrich.source(), matched.error());
491513
}
492514
});
493515
}
494516

517+
private record PolicyKey(String policyName, Enrich.Mode mode) {}
518+
495519
/**
496520
* Set external source resolution.
497521
*/

x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterInSubqueryIT.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,106 @@ public void testEnrichWithRemoteModePolicyMixedScopesSucceeds() {
11731173
}
11741174
}
11751175

1176+
/**
1177+
* Two occurrences of the same ENRICH command - same policy name, same mode - sharing the exact same {@code Source}
1178+
*/
1179+
public void testEnrichWithViewReferencedTwiceSharesSource() {
1180+
assumeTrue("Requires FROM-subquery support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());
1181+
setupEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich", 10);
1182+
setSkipUnavailable(REMOTE_CLUSTER_1, false);
1183+
setSkipUnavailable(REMOTE_CLUSTER_2, false);
1184+
createViewOnCluster(
1185+
LOCAL_CLUSTER,
1186+
"enrich_view",
1187+
"FROM logs-* | WHERE v > 1 AND v < 7 | ENRICH values_enrich ON v WITH enrich_name | KEEP v"
1188+
);
1189+
try {
1190+
try (EsqlQueryResponse resp = runQuery("""
1191+
FROM (FROM enrich_view), (FROM cluster-a:logs-*)
1192+
| WHERE v IN (FROM enrich_view)
1193+
| KEEP v
1194+
| SORT v
1195+
""", false)) {
1196+
assertThat(
1197+
getValuesList(resp),
1198+
equalTo(List.of(List.of(2L), List.of(3L), List.of(4L), List.of(4L), List.of(5L), List.of(6L)))
1199+
);
1200+
}
1201+
} finally {
1202+
deleteEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich");
1203+
clearSkipUnavailable(3);
1204+
}
1205+
}
1206+
1207+
/**
1208+
* The same policy name used with three different modes (ANY, COORDINATOR, REMOTE) in one query: each occurrence has
1209+
* its own mode-specific target-cluster requirement (see {@code EnrichPolicyResolver#calculateTargetClusters}), so all
1210+
* three must resolve independently even though they share a policy name - the REMOTE occurrence only needs
1211+
* {@code cluster-a} (its own scope), the ANY occurrence needs both remotes the outer {@code *:logs-*} touches (that
1212+
* pattern spans {@code cluster-a} and {@code remote-b}, not the local cluster) plus {@code _local} (ANY always
1213+
* requires it), and the COORDINATOR occurrence only ever needs {@code _local}.
1214+
*/
1215+
public void testEnrichSamePolicyDifferentModesAllSucceed() {
1216+
setupEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich", 10);
1217+
setupEnrichPolicy(client(REMOTE_CLUSTER_1), "values_enrich", 10);
1218+
setupEnrichPolicy(client(REMOTE_CLUSTER_2), "values_enrich", 10);
1219+
setSkipUnavailable(REMOTE_CLUSTER_1, false);
1220+
setSkipUnavailable(REMOTE_CLUSTER_2, false);
1221+
try {
1222+
try (EsqlQueryResponse resp = runQuery("""
1223+
FROM *:logs-*
1224+
| WHERE v IN (
1225+
FROM cluster-a:logs-*
1226+
| WHERE v > 1 AND v < 7
1227+
| ENRICH _remote:values_enrich ON v WITH enrich_name
1228+
| KEEP v
1229+
)
1230+
| ENRICH values_enrich ON v WITH enrich_name
1231+
| ENRICH _coordinator:values_enrich ON v WITH enrich_name
1232+
| KEEP v
1233+
| SORT v
1234+
""", false)) {
1235+
// *:logs-* spans cluster-a and remote-b only; each contributes a single v=4 row (i=2 -> 2^2=4).
1236+
assertThat(getValuesList(resp), equalTo(List.of(List.of(4L), List.of(4L))));
1237+
}
1238+
} finally {
1239+
deleteEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich");
1240+
deleteEnrichPolicy(client(REMOTE_CLUSTER_1), "values_enrich");
1241+
deleteEnrichPolicy(client(REMOTE_CLUSTER_2), "values_enrich");
1242+
clearSkipUnavailable(3);
1243+
}
1244+
}
1245+
1246+
/**
1247+
* The same policy name used with two different modes where only one occurrence's requirement is satisfied: REMOTE
1248+
* mode (scoped to {@code cluster-a} only) fails because the policy is missing there, while COORDINATOR mode
1249+
* (scoped to {@code _local} only, elsewhere in the same query) would succeed on its own since the policy exists
1250+
* locally. The failure must report exactly the REMOTE occurrence's own missing cluster ({@code cluster-a}), proving
1251+
* the two occurrences are resolved independently rather than one's requirement leaking into the other's error.
1252+
*/
1253+
public void testEnrichSamePolicyDifferentModesFailureIsolatedPerOccurrence() {
1254+
setupEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich", 10);
1255+
setSkipUnavailable(REMOTE_CLUSTER_1, false);
1256+
setSkipUnavailable(REMOTE_CLUSTER_2, false);
1257+
try {
1258+
VerificationException ex = expectThrows(VerificationException.class, () -> runQuery("""
1259+
FROM logs-*
1260+
| WHERE v IN (
1261+
FROM cluster-a:logs-*
1262+
| WHERE v > 1 AND v < 7
1263+
| ENRICH _remote:values_enrich ON v WITH enrich_name
1264+
| KEEP v
1265+
)
1266+
| ENRICH _coordinator:values_enrich ON v WITH enrich_name
1267+
| KEEP v
1268+
""", false));
1269+
assertThat(ex.getMessage(), containsString("cannot find enrich policy [values_enrich] on clusters [cluster-a]"));
1270+
} finally {
1271+
deleteEnrichPolicy(client(LOCAL_CLUSTER), "values_enrich");
1272+
clearSkipUnavailable(3);
1273+
}
1274+
}
1275+
11761276
// ---- helpers ----
11771277

11781278
/**

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3381,12 +3381,6 @@ public enum Cap {
33813381
*/
33823382
SPATIAL_BBOX_VALIDATION_FIX,
33833383

3384-
/**
3385-
* ENRICH policy resolution is scoped to the clusters that actually feed rows into that specific ENRICH (its own
3386-
* FROM-subquery branch or WHERE-IN subquery), instead of every cluster touched anywhere in the whole query.
3387-
*/
3388-
ENRICH_SCOPED_TO_SUBQUERY,
3389-
33903384
// Last capability should still have a comma for fewer merge conflicts when adding new ones :)
33913385
// This comment prevents the semicolon from being on the previous capability when Spotless formats the file.
33923386
;

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,14 +1514,17 @@ static Set<String> computeEnrichScope(Enrich enrich, Map<IndexPattern, IndexReso
15141514
* that failed to connect during main index resolution (skipped, e.g. behind {@code skip_unavailable=true}) can still show
15151515
* up in a wildcard pattern's {@code originalIndices()}, but the policy should not be required there.
15161516
*/
1517-
private static Map<Source, Set<String>> computeEnrichScopes(
1517+
// package-private static so EsqlSessionTests can drive it directly, e.g. to exercise the same-Source union above
1518+
static Map<Source, Set<String>> computeEnrichScopes(
15181519
List<Enrich> enriches,
15191520
Map<IndexPattern, IndexResolution> indexResolution,
15201521
EsqlExecutionInfo executionInfo
15211522
) {
15221523
Map<Source, Set<String>> enrichScopes = new HashMap<>();
15231524
for (Enrich enrich : enriches) {
1524-
Set<String> scope = EsqlCCSUtils.onlyRunning(executionInfo, computeEnrichScope(enrich, indexResolution));
1525+
Set<String> scope = new HashSet<>(EsqlCCSUtils.onlyRunning(executionInfo, computeEnrichScope(enrich, indexResolution)));
1526+
// onlyRunning can return an immutable Set (e.g. Set.of(...) when no cluster is tracked yet), so copy it into a
1527+
// mutable one before it's potentially unioned in place by a later same-Source occurrence, below.
15251528
enrichScopes.merge(enrich.source(), scope, (existing, additional) -> {
15261529
existing.addAll(additional);
15271530
return existing;

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,6 +2079,52 @@ public void testEnrichFieldsIncludeMatchField() {
20792079
assertThat(Expressions.names(limit.output()), contains("language_name", "language_code"));
20802080
}
20812081

2082+
/**
2083+
* Two occurrences of the same policy name and mode - registered with different resolutions via two separate
2084+
* {@code addEnrichPolicy} calls - resolve independently rather than both getting whichever resolution was registered
2085+
* first: {@code TestAnalyzer} matches registrations to occurrences of the same policy name/mode in registration order
2086+
* (first call -> first occurrence, second call -> second occurrence, etc). This exercises
2087+
* {@code Analyzer.ResolveEnrich}'s per-{@code Source} dispatch: {@code EnrichResolution} is keyed by the originating
2088+
* {@code Enrich} node's {@code Source}, not by policy name/mode, precisely so that occurrences like these two - which
2089+
* share a policy name and mode but can be resolved differently (e.g. because they live in differently-scoped subquery
2090+
* branches in production) - don't collide.
2091+
*/
2092+
public void testEnrichPolicyDifferentResolutionPerOccurrence() {
2093+
IndexResolution languageIndex = loadMapping("mapping-languages.json", "languages");
2094+
LogicalPlan plan = basic().addEnrichPolicy(
2095+
Enrich.Mode.ANY,
2096+
"languages",
2097+
new ResolvedEnrichPolicy(
2098+
"language_code",
2099+
EnrichPolicy.MATCH_TYPE,
2100+
List.of("language_name"),
2101+
Map.of("", "languages"),
2102+
languageIndex.get().mapping()
2103+
)
2104+
)
2105+
.addEnrichPolicy(
2106+
Enrich.Mode.ANY,
2107+
"languages",
2108+
new ResolvedEnrichPolicy(
2109+
"language_code",
2110+
EnrichPolicy.MATCH_TYPE,
2111+
List.of("language_code"),
2112+
Map.of("", "languages"),
2113+
languageIndex.get().mapping()
2114+
)
2115+
)
2116+
.query("""
2117+
FROM test
2118+
| EVAL x = to_string(languages)
2119+
| ENRICH languages ON x
2120+
| EVAL y = to_string(languages)
2121+
| ENRICH languages ON y
2122+
| KEEP language_name, language_code
2123+
""");
2124+
var limit = as(plan, Limit.class);
2125+
assertThat(Expressions.names(limit.output()), contains("language_name", "language_code"));
2126+
}
2127+
20822128
public void testChainedEvalFieldsUse() {
20832129
var query = "from test | eval x0 = pow(salary, 1), x1 = pow(x0, 2), x2 = pow(x1, 3)";
20842130
int additionalEvals = randomIntBetween(0, 5);

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/session/EsqlSessionTests.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.elasticsearch.test.ESTestCase;
1717
import org.elasticsearch.transport.RemoteClusterAware;
1818
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
19+
import org.elasticsearch.xpack.esql.action.EsqlExecutionInfo;
1920
import org.elasticsearch.xpack.esql.action.EsqlQueryRequest;
2021
import org.elasticsearch.xpack.esql.analysis.InSubqueryResolver;
2122
import org.elasticsearch.xpack.esql.analysis.PreAnalyzer;
@@ -57,6 +58,7 @@
5758
import static org.hamcrest.Matchers.containsString;
5859
import static org.hamcrest.Matchers.equalTo;
5960
import static org.hamcrest.Matchers.hasSize;
61+
import static org.hamcrest.Matchers.not;
6062
import static org.hamcrest.Matchers.sameInstance;
6163

6264
public class EsqlSessionTests extends ESTestCase {
@@ -568,6 +570,34 @@ public void testComputeEnrichScopeMixedSubqueries() {
568570
}
569571
}
570572

573+
/**
574+
* Two distinct {@link Enrich} occurrences can share the exact same {@link Source} today: a view containing an ENRICH is
575+
* re-parsed independently every time it's referenced, so referencing it from two differently-scoped subquery branches
576+
* produces two structurally-identical (and therefore {@code Source.equals()}) {@link Enrich} nodes. Parsing the same
577+
* literal query text twice below reproduces that same-Source collision without needing an actual view.
578+
* <p>
579+
* {@link EsqlSession#computeEnrichScopes} unions the two occurrences' scopes rather than letting the second overwrite the
580+
* first. This also exercises the case where {@link EsqlCCSUtils#onlyRunning} hands back an immutable {@code Set.of(...)}
581+
* (no cluster tracked yet) for the first occurrence - that value must not be mutated in place once the second,
582+
* same-Source occurrence is merged into it.
583+
*/
584+
public void testComputeEnrichScopesUnionsDuplicateSource() {
585+
var plan1 = TEST_PARSER.parseQuery("ROW key = 1 | ENRICH policy ON key");
586+
var plan2 = TEST_PARSER.parseQuery("ROW key = 1 | ENRICH policy ON key");
587+
Enrich enrich1 = enrichNamed(plan1, "policy");
588+
Enrich enrich2 = enrichNamed(plan2, "policy");
589+
assertThat(enrich1, not(sameInstance(enrich2)));
590+
assertThat(enrich1.source(), equalTo(enrich2.source()));
591+
592+
var resolution = createIndexResolution();
593+
EsqlExecutionInfo executionInfo = new EsqlExecutionInfo(alias -> false, EsqlExecutionInfo.IncludeExecutionMetadata.NEVER);
594+
595+
Map<Source, Set<String>> scopes = EsqlSession.computeEnrichScopes(List.of(enrich1, enrich2), resolution, executionInfo);
596+
597+
assertThat(scopes.keySet(), hasSize(1));
598+
assertThat(scopes.get(enrich1.source()), equalTo(Set.of(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY)));
599+
}
600+
571601
/**
572602
* Returns the sole {@link Enrich} node with the given (resolved) policy name in {@code plan}, failing if there isn't
573603
* exactly one - test queries give each ENRICH occurrence under test a distinct policy name to disambiguate it.

0 commit comments

Comments
 (0)