From 43e7e99ada5a97e2083f1a169f1cba1efc193f15 Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Tue, 29 Jul 2025 16:49:14 -0600 Subject: [PATCH 1/7] Fix index lookup when field-caps returns empty mapping --- .../esql/action/CrossClusterLookupJoinIT.java | 50 +++++++++++++++++-- .../xpack/esql/session/EsqlSession.java | 5 ++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java index 03a7bf4546d05..f3e6be6edf9b2 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java @@ -289,6 +289,9 @@ public void testLookupJoinMissingKey() throws IOException { populateLookupIndex(REMOTE_CLUSTER_1, "values_lookup", 10); setSkipUnavailable(REMOTE_CLUSTER_1, true); + + Exception ex; + try ( // Using local_tag as key which is not present in remote index EsqlQueryResponse resp = runQuery( @@ -362,10 +365,7 @@ public void testLookupJoinMissingKey() throws IOException { } // TODO: verify whether this should be an error or not when the key field is missing - Exception ex = expectThrows( - VerificationException.class, - () -> runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v", randomBoolean()) - ); + ex = expectThrows(VerificationException.class, () -> runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v", randomBoolean())); assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); ex = expectThrows( @@ -374,6 +374,34 @@ public void testLookupJoinMissingKey() throws IOException { ); assertThat(ex.getMessage(), containsString("Unknown column [local_tag] in right side of join")); + // Add KEEP clause to try and trick the field-caps result parser + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); + + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM logs-*,c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) + ); + // FIXME: strictly speaking this message is not correct, as the index is available, but the field is not + assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available")); + + try (EsqlQueryResponse resp = runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean())) { + List> values = getValuesList(resp); + assertThat(values, hasSize(0)); + EsqlExecutionInfo executionInfo = resp.getExecutionInfo(); + assertThat(executionInfo.getClusters().size(), equalTo(1)); + assertTrue(executionInfo.isPartial()); + + var remoteCluster = executionInfo.getCluster(REMOTE_CLUSTER_1); + assertThat(remoteCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SKIPPED)); + assertThat(remoteCluster.getFailures().size(), equalTo(1)); + var failure = remoteCluster.getFailures().get(0); + assertThat(failure.reason(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); + } + setSkipUnavailable(REMOTE_CLUSTER_1, false); try ( // Using local_tag as key which is not present in remote index @@ -393,6 +421,20 @@ public void testLookupJoinMissingKey() throws IOException { // FIXME: verify whether we need to succeed or fail here assertThat(remoteCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL)); } + + // Add KEEP clause to try and trick the field-caps result parser + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); + + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM logs-*,c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); + } public void testLookupJoinIndexMode() throws IOException { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java index 34ece5661c8d7..be34625b63cca 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java @@ -499,6 +499,11 @@ private PreAnalysisResult receiveLookupIndexResolution( } if (executionInfo.getClusters().isEmpty() || executionInfo.isCrossClusterSearch() == false) { // Local only case, still do some checks, since we moved analysis checks here + if (lookupIndexResolution.get().indexNameWithModes().isEmpty()) { + // This is not OK, but we proceed with it as we do with invalid resolution, and it will fail on the verification + // because lookup field will be missing. + return result.addLookupIndexResolution(index, lookupIndexResolution); + } if (lookupIndexResolution.get().indexNameWithModes().size() > 1) { throw new VerificationException( "Lookup Join requires a single lookup mode index; [" + index + "] resolves to multiple indices" From 2db1609446b398ef2784de615ea651a6524c8de4 Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Tue, 29 Jul 2025 17:00:41 -0600 Subject: [PATCH 2/7] Update docs/changelog/132138.yaml --- docs/changelog/132138.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/changelog/132138.yaml diff --git a/docs/changelog/132138.yaml b/docs/changelog/132138.yaml new file mode 100644 index 0000000000000..66e9999644677 --- /dev/null +++ b/docs/changelog/132138.yaml @@ -0,0 +1,6 @@ +pr: 132138 +summary: Fix index lookup when field-caps returns empty mapping +area: ES|QL +type: bug +issues: + - 132105 From a29e50309d60746d89589cb8aa7ab094011af4b4 Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Tue, 29 Jul 2025 17:01:20 -0600 Subject: [PATCH 3/7] Update 132138.yaml --- docs/changelog/132138.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/132138.yaml b/docs/changelog/132138.yaml index 66e9999644677..543303e22d99e 100644 --- a/docs/changelog/132138.yaml +++ b/docs/changelog/132138.yaml @@ -1,5 +1,5 @@ pr: 132138 -summary: Fix index lookup when field-caps returns empty mapping +summary: Fix lookup index resolution when field-caps returns empty mapping area: ES|QL type: bug issues: From f18b5b740b7fbcbdfa7a8830eddffd70118ff943 Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Thu, 31 Jul 2025 11:49:59 -0600 Subject: [PATCH 4/7] More fixes --- .../esql/action/CrossClusterLookupJoinIT.java | 70 ++++++++++++++----- .../xpack/esql/session/EsqlSession.java | 10 +++ .../xpack/esql/session/IndexResolver.java | 5 ++ .../test/esql/190_lookup_join.yml | 10 +++ 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java index f3e6be6edf9b2..2c5c0bac16119 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java @@ -10,6 +10,7 @@ import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequestBuilder; import org.elasticsearch.client.internal.Client; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.xpack.core.enrich.EnrichPolicy; import org.elasticsearch.xpack.core.enrich.action.ExecuteEnrichPolicyAction; import org.elasticsearch.xpack.core.enrich.action.PutEnrichPolicyAction; @@ -374,7 +375,7 @@ public void testLookupJoinMissingKey() throws IOException { ); assertThat(ex.getMessage(), containsString("Unknown column [local_tag] in right side of join")); - // Add KEEP clause to try and trick the field-caps result parser + // Add KEEP clause to try and trick the field-caps result parser into returning empty mapping ex = expectThrows( VerificationException.class, () -> runQuery("FROM logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) @@ -385,22 +386,13 @@ public void testLookupJoinMissingKey() throws IOException { VerificationException.class, () -> runQuery("FROM logs-*,c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) ); - // FIXME: strictly speaking this message is not correct, as the index is available, but the field is not - assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available")); - - try (EsqlQueryResponse resp = runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean())) { - List> values = getValuesList(resp); - assertThat(values, hasSize(0)); - EsqlExecutionInfo executionInfo = resp.getExecutionInfo(); - assertThat(executionInfo.getClusters().size(), equalTo(1)); - assertTrue(executionInfo.isPartial()); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); - var remoteCluster = executionInfo.getCluster(REMOTE_CLUSTER_1); - assertThat(remoteCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SKIPPED)); - assertThat(remoteCluster.getFailures().size(), equalTo(1)); - var failure = remoteCluster.getFailures().get(0); - assertThat(failure.reason(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); - } + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); setSkipUnavailable(REMOTE_CLUSTER_1, false); try ( @@ -422,19 +414,40 @@ public void testLookupJoinMissingKey() throws IOException { assertThat(remoteCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL)); } - // Add KEEP clause to try and trick the field-caps result parser + // Add KEEP clause to try and trick the field-caps result parser into returning empty mapping ex = expectThrows( VerificationException.class, () -> runQuery("FROM c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) ); - assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); ex = expectThrows( VerificationException.class, () -> runQuery("FROM logs-*,c*:logs-* | LOOKUP JOIN values_lookup ON v | KEEP v", randomBoolean()) ); - assertThat(ex.getMessage(), containsString("lookup index [values_lookup] is not available in remote cluster [cluster-a]")); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); + } + + public void testLookupJoinEmptyIndex() throws IOException { + setupClusters(2); + populateEmptyIndices(LOCAL_CLUSTER, "values_lookup"); + populateEmptyIndices(REMOTE_CLUSTER_1, "values_lookup"); + + setSkipUnavailable(REMOTE_CLUSTER_1, false); + Exception ex; + for (String index : List.of("values_lookup", "values_lookup_map", "values_lookup_map_lookup")) { + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM logs-* | LOOKUP JOIN " + index + " ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); + ex = expectThrows( + VerificationException.class, + () -> runQuery("FROM c*:logs-* | LOOKUP JOIN " + index + " ON v | KEEP v", randomBoolean()) + ); + assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join")); + } } public void testLookupJoinIndexMode() throws IOException { @@ -570,4 +583,23 @@ protected void setupAlias(String clusterAlias, String indexName, String aliasNam assertAcked(client.admin().indices().aliases(indicesAliasesRequestBuilder.request())); } + protected void populateEmptyIndices(String clusterAlias, String indexName) { + Client client = client(clusterAlias); + // Empty body + assertAcked(client.admin().indices().prepareCreate(indexName)); + client.admin().indices().prepareRefresh(indexName).get(); + // mappings + settings + assertAcked( + client.admin() + .indices() + .prepareCreate(indexName + "_map_lookup") + .setMapping() + .setSettings(Settings.builder().put("index.mode", "lookup")) + ); + client.admin().indices().prepareRefresh(indexName + "_map_lookup").get(); + // mappings only + assertAcked(client.admin().indices().prepareCreate(indexName + "_map").setMapping()); + client.admin().indices().prepareRefresh(indexName + "_map").get(); + } + } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java index be34625b63cca..3d5b61af44dc2 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlSession.java @@ -523,6 +523,16 @@ private PreAnalysisResult receiveLookupIndexResolution( } return result.addLookupIndexResolution(index, lookupIndexResolution); } + + if (lookupIndexResolution.get().indexNameWithModes().isEmpty() && lookupIndexResolution.resolvedIndices().isEmpty() == false) { + // This is a weird situation - we have empty index list but non-empty resolution. This is likely because IndexResolver + // got an empty map and pretends to have an empty resolution. This means this query will fail, since lookup fields will not + // match, but here we can pretend it's ok to pass it on to the verifier and generate a correct error message. + // Note this only happens if the map is completely empty, which means it's going to error out anyway, since we should have + // at least the key field there. + return result.addLookupIndexResolution(index, lookupIndexResolution); + } + // Collect resolved clusters from the index resolution, verify that each cluster has a single resolution for the lookup index Map clustersWithResolvedIndices = new HashMap<>(lookupIndexResolution.resolvedIndices().size()); lookupIndexResolution.get().indexNameWithModes().forEach((indexName, indexMode) -> { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/IndexResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/IndexResolver.java index 16401574b0f58..d72f3ef0529ad 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/IndexResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/IndexResolver.java @@ -157,6 +157,11 @@ public static IndexResolution mergedMappings(String indexPattern, FieldCapabilit allEmpty &= ir.get().isEmpty(); } // If all the mappings are empty we return an empty set of resolved indices to line up with QL + // Introduced with #46775 + // We need to be able to differentiate between an empty mapping index and an empty index due to fields not being found. An empty + // mapping index will generate no columns (important) for a query like FROM empty-mapping-index, whereas an empty result here but + // for fields that do not exist in the index (but the index has a mapping) will result in "VerificationException Unknown column" + // errors. var index = new EsIndex(indexPattern, rootFields, allEmpty ? Map.of() : concreteIndices, partiallyUnmappedFields); var failures = EsqlCCSUtils.groupFailuresPerCluster(fieldCapsResponse.getFailures()); return IndexResolution.valid(index, concreteIndices.keySet(), failures); diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml index 65d3a750e8a41..0a10c58e1398a 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml @@ -230,3 +230,13 @@ lookup-no-key: - match: { error.type: "verification_exception" } - contains: { error.reason: "Unknown column [key] in right side of join" } +--- +lookup-no-key-only-key: + - do: + esql.query: + body: + query: 'FROM test | LOOKUP JOIN test-lookup-no-key ON key | KEEP key' + catch: "bad_request" + + - match: { error.type: "verification_exception" } + - contains: { error.reason: "Unknown column [key] in right side of join" } From 5f11cede8b2800d13287e37bad6ced0ee2731a7f Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Thu, 31 Jul 2025 12:05:37 -0600 Subject: [PATCH 5/7] randomize skip_un here --- .../xpack/esql/action/CrossClusterLookupJoinIT.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java index 2c5c0bac16119..fc6207a6e6872 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterLookupJoinIT.java @@ -433,7 +433,8 @@ public void testLookupJoinEmptyIndex() throws IOException { populateEmptyIndices(LOCAL_CLUSTER, "values_lookup"); populateEmptyIndices(REMOTE_CLUSTER_1, "values_lookup"); - setSkipUnavailable(REMOTE_CLUSTER_1, false); + // Should work the same with both settings + setSkipUnavailable(REMOTE_CLUSTER_1, randomBoolean()); Exception ex; for (String index : List.of("values_lookup", "values_lookup_map", "values_lookup_map_lookup")) { From a710ac216f779e34c9c5cc4d47fdcfc6caca98dd Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Thu, 31 Jul 2025 14:09:42 -0600 Subject: [PATCH 6/7] Mute test on compat runs --- x-pack/plugin/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugin/build.gradle b/x-pack/plugin/build.gradle index f7d28c1ef8bfe..7057fc41d834c 100644 --- a/x-pack/plugin/build.gradle +++ b/x-pack/plugin/build.gradle @@ -139,6 +139,7 @@ tasks.named("yamlRestCompatTestTransform").configure({ task -> task.skipTest("esql/192_lookup_join_on_aliases/alias-pattern-multiple", "Error message changed") task.skipTest("esql/192_lookup_join_on_aliases/fails when alias or pattern resolves to multiple", "Error message changed") task.skipTest("esql/10_basic/Test wrong LIMIT parameter", "Error message changed") + task.skipTest("esql/190_lookup_join/lookup-no-key-only-key", "Requires the fix") }) tasks.named('yamlRestCompatTest').configure { From a50715b6118cb26f00c4843a076ecd5170a1547a Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Fri, 1 Aug 2025 10:01:41 -0600 Subject: [PATCH 7/7] remove this for now, I'll add it later --- .../rest-api-spec/test/esql/190_lookup_join.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml index 0a10c58e1398a..65d3a750e8a41 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/190_lookup_join.yml @@ -230,13 +230,3 @@ lookup-no-key: - match: { error.type: "verification_exception" } - contains: { error.reason: "Unknown column [key] in right side of join" } ---- -lookup-no-key-only-key: - - do: - esql.query: - body: - query: 'FROM test | LOOKUP JOIN test-lookup-no-key ON key | KEEP key' - catch: "bad_request" - - - match: { error.type: "verification_exception" } - - contains: { error.reason: "Unknown column [key] in right side of join" }