Skip to content
6 changes: 6 additions & 0 deletions docs/changelog/132138.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pr: 132138
summary: Fix lookup index resolution when field-caps returns empty mapping
area: ES|QL
type: bug
issues:
- 132105
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -289,6 +290,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(
Expand Down Expand Up @@ -362,10 +366,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(
Expand All @@ -374,6 +375,25 @@ 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 into returning empty mapping
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())
);
assertThat(ex.getMessage(), containsString("Unknown column [v] in right side of join"));

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 (
// Using local_tag as key which is not present in remote index
Expand All @@ -393,6 +413,42 @@ 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 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("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("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");

// 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")) {
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 {
Expand Down Expand Up @@ -528,4 +584,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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -518,6 +523,16 @@ private PreAnalysisResult receiveLookupIndexResolution(
}
return result.addLookupIndexResolution(index, lookupIndexResolution);
}

if (lookupIndexResolution.get().indexNameWithModes().isEmpty() && lookupIndexResolution.resolvedIndices().isEmpty() == false) {
Copy link
Contributor

Choose a reason for hiding this comment

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

In which scenario is this possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the same scenario we're discussing, but instead of local cluster we have remote clusters. Then the branch above will not be executed, but the code will try to validate remote cluster's indices. But since we've got the empty mappings, it would be confused into thinking there's no indices there, and report a wrong error. So this part fixes it and let's the verifier report the correct error.

Copy link
Contributor

Choose a reason for hiding this comment

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

same scenario we're discussing

Can you please, be specific? (scenarios confusion led to long discussions and prolonging this PR merge)

In my mind there are these three scenarios plus the ones where the mapping of the lookup index is not empty but the query uses keep v. Plus those involving CCS.
Note: Every scenario above should be tested with local only indices (no CCS) as well, btw (can be done in a follow up PR or opened an issue for it to be addressed later).

Copy link
Contributor Author

@smalyshev smalyshev Jul 31, 2025

Choose a reason for hiding this comment

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

This is the scenario where we are calling field-caps to resolve lookup join, with field list set to some restricted list (not "*") and the lookup index does not have any of those fields, so we're receiving the field-caps response with an empty map. Example:

FROM remote:logs-*| LOOKUP JOIN values_lookup ON v | KEEP v

this is the same query as in the issue description, just with the remote cluster, not the local one. The difference exists because local-only and remote scenarios are handled by different code branches, due to skip_unavailable complexities etc., so the initial fix only covered the local-only branch and led to the fact that the remote scenario produced wrong error message - "unknown index" instead of "field missing".

testLookupJoinEmptyIndex tests the three empty index scenarios you've described (as much as I could reproduce them from transport level test - it doesn't really do JSON REST APIs there) and it tests them in both local and remote scenarios.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you!

// 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<String, String> clustersWithResolvedIndices = new HashMap<>(lookupIndexResolution.resolvedIndices().size());
lookupIndexResolution.get().indexNameWithModes().forEach((indexName, indexMode) -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }