From 55556b7509ffec26194f4b7509a4b763c6fe03b9 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 31 Oct 2025 10:08:08 +0100 Subject: [PATCH 01/50] Restrict EnrichPolicyResolver to supported types --- .../qa/rest/AllSupportedFieldsTestCase.java | 1 - .../esql/enrich/EnrichPolicyResolver.java | 6 +- .../xpack/esql/session/EsqlSession.java | 5 +- .../xpack/esql/session/IndexResolver.java | 58 ++++++++++++++----- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 48a38fbb3202b..cbc55a2480544 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -651,7 +651,6 @@ private Matcher expectedType(DataType type) throws IOException { yield equalTo("aggregate_metric_double"); } case DENSE_VECTOR -> { - logger.error("ADFDAFAF " + minVersion()); if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false || minVersion().supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { yield equalTo("unsupported"); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 0744fd126999d..f3b7f54af0364 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -447,7 +447,7 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas } try (ThreadContext.StoredContext ignored = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) { String indexName = EnrichPolicy.getBaseName(policyName); - indexResolver.resolveAsMergedMapping( + indexResolver.resolveAsMergedMappingForVersion( indexName, IndexResolver.ALL_FIELDS, null, @@ -455,6 +455,10 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas // Disable aggregate_metric_double and dense_vector until we get version checks in planning false, false, + // We construct the mapping with the coordinator's transport version, so it doesn't contain types that the + // coordinator may not know. We do not build for the minimum transport version of the query, though; + // we let the coordinator deal with that, so it may have to strip down this mapping further. + channel.getVersion(), refs.acquire(indexResult -> { if (indexResult.isValid() && indexResult.get().concreteIndices().size() == 1) { EsIndex esIndex = indexResult.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 3fca6e7ee31ce..604f4e72d09b6 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 @@ -610,7 +610,7 @@ private void preAnalyzeLookupIndex( ThreadPool.Names.SEARCH_COORDINATION, ThreadPool.Names.SYSTEM_READ ); - indexResolver.resolveAsMergedMapping( + indexResolver.resolveAsMergedMappingForVersion( EsqlCCSUtils.createQualifiedLookupIndexExpressionFromAvailableClusters(executionInfo, localPattern), result.wildcardJoinIndices().contains(localPattern) ? IndexResolver.ALL_FIELDS : result.fieldNames, null, @@ -618,6 +618,7 @@ private void preAnalyzeLookupIndex( // Disable aggregate_metric_double and dense_vector until we get version checks in planning false, false, + result.minimumTransportVersion(), listener.map(indexResolution -> receiveLookupIndexResolution(result, localPattern, executionInfo, indexResolution)) ); } @@ -851,7 +852,7 @@ private void preAnalyzeMainIndices( result.withIndices(indexPattern, IndexResolution.valid(new EsIndex(indexPattern.indexPattern(), Map.of(), Map.of()))) ); } else { - indexResolver.resolveAsMergedMappingAndRetrieveMinimumVersion( + indexResolver.resolveAsMergedMapping( indexPattern.indexPattern(), result.fieldNames, // Maybe if no indices are returned, retry without index mode and provide a clearer error message. 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 57d0db5d04714..1299877763ebc 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 @@ -84,58 +84,88 @@ public IndexResolver(Client client) { } /** - * Resolves a pattern to one (potentially compound meaning that spawns multiple indices) mapping. + * Like {@link #resolveAsMergedMapping(String, Set, QueryBuilder, boolean, boolean, boolean, ActionListener)}, + * but uses a pre-determined minimum transport version. */ - public void resolveAsMergedMapping( - String indexWildcard, + public void resolveAsMergedMappingForVersion( + String indexPattern, Set fieldNames, QueryBuilder requestFilter, boolean includeAllDimensions, boolean useAggregateMetricDoubleWhenNotSupported, boolean useDenseVectorWhenNotSupported, + TransportVersion minimumVersion, ActionListener listener ) { - ActionListener> ignoreVersion = listener.delegateFailureAndWrap( + ActionListener> versionIgnoringListener = listener.delegateFailureAndWrap( (l, versionedResolution) -> l.onResponse(versionedResolution.inner()) ); - resolveAsMergedMappingAndRetrieveMinimumVersion( - indexWildcard, + resolveAsMergedMapping( + indexPattern, fieldNames, requestFilter, includeAllDimensions, useAggregateMetricDoubleWhenNotSupported, useDenseVectorWhenNotSupported, - ignoreVersion + minimumVersion, + versionIgnoringListener ); } /** - * Resolves a pattern to one (potentially compound meaning that spawns multiple indices) mapping. Also retrieves the minimum transport - * version available in the cluster (and remotes). + * Perform a field caps request to resolve a pattern to one mapping (potentially compound, meaning it spans multiple indices). + * The field caps response contains the minimum transport version of all clusters that apply to the pattern, + * and it is used to deal with previously unsupported data types during resolution. + *

+ * The retrieved minimum version is passed on to the listener. + *

+ * If a field's type is not supported on the minimum version, it will be {@link DataType#UNSUPPORTED}. */ - public void resolveAsMergedMappingAndRetrieveMinimumVersion( - String indexWildcard, + public void resolveAsMergedMapping( + String indexPattern, + Set fieldNames, + QueryBuilder requestFilter, + boolean includeAllDimensions, + boolean useAggregateMetricDoubleWhenNotSupported, + boolean useDenseVectorWhenNotSupported, + ActionListener> listener + ) { + resolveAsMergedMapping( + indexPattern, + fieldNames, + requestFilter, + includeAllDimensions, + useAggregateMetricDoubleWhenNotSupported, + useDenseVectorWhenNotSupported, + null, + listener + ); + } + + private void resolveAsMergedMapping( + String indexPattern, Set fieldNames, QueryBuilder requestFilter, boolean includeAllDimensions, boolean useAggregateMetricDoubleWhenNotSupported, boolean useDenseVectorWhenNotSupported, + @Nullable TransportVersion minimumVersion, ActionListener> listener ) { client.execute( EsqlResolveFieldsAction.TYPE, - createFieldCapsRequest(indexWildcard, fieldNames, requestFilter, includeAllDimensions), + createFieldCapsRequest(indexPattern, fieldNames, requestFilter, includeAllDimensions), listener.delegateFailureAndWrap((l, response) -> { FieldsInfo info = new FieldsInfo( response.caps(), - response.caps().minTransportVersion(), + minimumVersion == null ? response.caps().minTransportVersion() : minimumVersion, Build.current().isSnapshot(), useAggregateMetricDoubleWhenNotSupported, useDenseVectorWhenNotSupported ); LOGGER.debug("minimum transport version {} {}", response.caps().minTransportVersion(), info.effectiveMinTransportVersion()); - l.onResponse(new Versioned<>(mergedMappings(indexWildcard, info), info.effectiveMinTransportVersion())); + l.onResponse(new Versioned<>(mergedMappings(indexPattern, info), info.effectiveMinTransportVersion())); }) ); } From 3b2aa0da477ed892c04576756753487519337cfb Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 31 Oct 2025 11:12:47 +0100 Subject: [PATCH 02/50] Update docs/changelog/137431.yaml --- docs/changelog/137431.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/137431.yaml diff --git a/docs/changelog/137431.yaml b/docs/changelog/137431.yaml new file mode 100644 index 0000000000000..451fc5eabc1a7 --- /dev/null +++ b/docs/changelog/137431.yaml @@ -0,0 +1,5 @@ +pr: 137431 +summary: Fix enrich and lookup join resolution based on min transport version +area: ES|QL +type: bug +issues: [] From 3ca4ed9afe54e7bcef45255ed9779378b9c2e253 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 31 Oct 2025 18:03:47 +0100 Subject: [PATCH 03/50] Plumb minimum version into ENRICH resolution --- ..._minimum_version_for_enrich_resolution.csv | 1 + .../resources/transport/upper_bounds/9.3.csv | 2 +- .../esql/enrich/EnrichPolicyResolver.java | 41 +++++++++++++++---- .../xpack/esql/session/EsqlSession.java | 7 +++- .../enrich/EnrichPolicyResolverTests.java | 3 +- .../telemetry/PlanExecutorMetricsTests.java | 3 +- 6 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv new file mode 100644 index 0000000000000..44b76d9df26ec --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -0,0 +1 @@ +9206000 diff --git a/server/src/main/resources/transport/upper_bounds/9.3.csv b/server/src/main/resources/transport/upper_bounds/9.3.csv index 1acd7ceede226..81ba937c6b397 100644 --- a/server/src/main/resources/transport/upper_bounds/9.3.csv +++ b/server/src/main/resources/transport/upper_bounds/9.3.csv @@ -1 +1 @@ -text_similarity_rank_docs_explain_chunks,9205000 +esql_use_minimum_version_for_enrich_resolution,9206000 diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index f3b7f54af0364..d089ccecd4092 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.esql.enrich; import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionListenerResponseHandler; import org.elasticsearch.action.support.ChannelActionListener; @@ -75,6 +76,10 @@ public class EnrichPolicyResolver { private static final String RESOLVE_ACTION_NAME = "cluster:monitor/xpack/enrich/esql/resolve_policy"; + private static final TransportVersion ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION = TransportVersion.fromName( + "esql_use_minimum_version_for_enrich_resolution" + ); + private final ClusterService clusterService; private final IndexResolver indexResolver; private final TransportService transportService; @@ -113,9 +118,16 @@ public static UnresolvedPolicy from(Enrich e) { * * @param enriches the unresolved policies * @param executionInfo the execution info + * @param minimumVersion the minimum transport version of all clusters involved in the query, used for making the resolved mapping + * compatible with all possible nodes * @param listener notified with the enrich resolution */ - public void resolvePolicies(List enriches, EsqlExecutionInfo executionInfo, ActionListener listener) { + public void resolvePolicies( + List enriches, + EsqlExecutionInfo executionInfo, + TransportVersion minimumVersion, + ActionListener listener + ) { if (enriches.isEmpty()) { listener.onResponse(new EnrichResolution()); return; @@ -125,6 +137,7 @@ public void resolvePolicies(List enriches, EsqlExecutionInfo executionIn executionInfo.clusterInfo.isEmpty() ? new HashSet<>() : executionInfo.getRunningClusterAliases().collect(toSet()), enriches.stream().map(EnrichPolicyResolver.UnresolvedPolicy::from).toList(), executionInfo, + minimumVersion, listener ); } @@ -133,6 +146,7 @@ protected void doResolvePolicies( Set remoteClusters, Collection unresolvedPolicies, EsqlExecutionInfo executionInfo, + TransportVersion minimumVersion, ActionListener listener ) { if (unresolvedPolicies.isEmpty()) { @@ -141,7 +155,7 @@ protected void doResolvePolicies( } final boolean includeLocal = remoteClusters.isEmpty() || remoteClusters.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); - lookupPolicies(remoteClusters, includeLocal, unresolvedPolicies, executionInfo, listener.map(lookupResponses -> { + lookupPolicies(remoteClusters, includeLocal, unresolvedPolicies, executionInfo, minimumVersion, listener.map(lookupResponses -> { final EnrichResolution enrichResolution = new EnrichResolution(); final Map lookupResponsesToProcess = new HashMap<>(); for (Map.Entry entry : lookupResponses.entrySet()) { @@ -305,6 +319,7 @@ private void lookupPolicies( boolean includeLocal, Collection unresolvedPolicies, EsqlExecutionInfo executionInfo, + TransportVersion minimumVersion, ActionListener> listener ) { final Map lookupResponses = ConcurrentCollections.newConcurrentMap(); @@ -324,7 +339,7 @@ public void onResponse(Transport.Connection connection) { transportService.sendRequest( connection, RESOLVE_ACTION_NAME, - new LookupRequest(cluster, remotePolicies), + new LookupRequest(cluster, remotePolicies, minimumVersion), TransportRequestOptions.EMPTY, new ActionListenerResponseHandler<>( lookupListener.delegateResponse((l, e) -> failIfSkipUnavailableFalse(e, skipOnFailure, l)), @@ -350,7 +365,7 @@ public void onFailure(Exception e) { transportService.sendRequest( transportService.getLocalNode(), RESOLVE_ACTION_NAME, - new LookupRequest(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, localPolicies), + new LookupRequest(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, localPolicies, minimumVersion), new ActionListenerResponseHandler<>( refs.acquire(resp -> lookupResponses.put(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, resp)), LookupResponse::new, @@ -372,21 +387,32 @@ private void failIfSkipUnavailableFalse(Exception e, boolean skipOnFailure, Acti private static class LookupRequest extends AbstractTransportRequest { private final String clusterAlias; private final Collection policyNames; + // The minimum version of all clusters involved in executing the ESQL query. + final TransportVersion minimumVersion; - LookupRequest(String clusterAlias, Collection policyNames) { + LookupRequest(String clusterAlias, Collection policyNames, TransportVersion minimumVersion) { this.clusterAlias = clusterAlias; this.policyNames = policyNames; + this.minimumVersion = minimumVersion; } LookupRequest(StreamInput in) throws IOException { this.clusterAlias = in.readString(); this.policyNames = in.readStringCollectionAsList(); + if (in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + this.minimumVersion = TransportVersion.readVersion(in); + } else { + this.minimumVersion = TransportVersion.minimumCompatible(); + } } @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(clusterAlias); out.writeStringCollection(policyNames); + if (out.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + TransportVersion.writeVersion(minimumVersion, out); + } } } @@ -455,10 +481,7 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas // Disable aggregate_metric_double and dense_vector until we get version checks in planning false, false, - // We construct the mapping with the coordinator's transport version, so it doesn't contain types that the - // coordinator may not know. We do not build for the minimum transport version of the query, though; - // we let the coordinator deal with that, so it may have to strip down this mapping further. - channel.getVersion(), + request.minimumVersion, refs.acquire(indexResult -> { if (indexResult.isValid() && indexResult.get().concreteIndices().size() == 1) { EsIndex esIndex = indexResult.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 604f4e72d09b6..9fcdcaa9e5fad 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 @@ -567,7 +567,12 @@ private void resolveIndicesAndAnalyze( }) .andThen((l, r) -> preAnalyzeLookupIndices(preAnalysis.lookupIndices().iterator(), r, executionInfo, l)) .andThen((l, r) -> { - enrichPolicyResolver.resolvePolicies(preAnalysis.enriches(), executionInfo, l.map(r::withEnrichResolution)); + enrichPolicyResolver.resolvePolicies( + preAnalysis.enriches(), + executionInfo, + r.minimumTransportVersion(), + l.map(r::withEnrichResolution) + ); }) .andThen((l, r) -> { inferenceService.inferenceResolver(functionRegistry).resolveInferenceIds(parsed, l.map(r::withInferenceResolution)); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java index 8ea5ecf231407..1607c40b3d9f8 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java @@ -454,7 +454,8 @@ EnrichResolution resolvePolicies(Collection clusters, Collection future = new PlainActionFuture<>(); - super.doResolvePolicies(new HashSet<>(clusters), unresolvedPolicies, esqlExecutionInfo, future); + // NOCOMMIT: We should pass in a sensible transport version in general and also test this with older ones. + super.doResolvePolicies(new HashSet<>(clusters), unresolvedPolicies, esqlExecutionInfo, TransportVersion.current(), future); return future.actionGet(30, TimeUnit.SECONDS); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java index 2dc9f48e1a1ec..9889d5de9074b 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.esql.telemetry; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.fieldcaps.FieldCapabilities; @@ -84,7 +85,7 @@ EnrichPolicyResolver mockEnrichResolver() { ActionListener listener = (ActionListener) arguments[arguments.length - 1]; listener.onResponse(new EnrichResolution()); return null; - }).when(enrichResolver).resolvePolicies(any(), any(), any()); + }).when(enrichResolver).resolvePolicies(any(), any(), TransportVersion.current(), any()); return enrichResolver; } From 117bccf141c282f870c7266a421790ae3d407628 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 31 Oct 2025 17:09:50 +0000 Subject: [PATCH 04/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index 44b76d9df26ec..44625f112155b 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9206000 +9206000,9185006 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 23dfcd8d57f3a..f2dda3e7cda07 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -esql_resolve_fields_response_used,9185005 +esql_use_minimum_version_for_enrich_resolution,9185006 From 6387ac61ca19c40e6f97aecad11e90193da843f3 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 31 Oct 2025 17:23:20 +0000 Subject: [PATCH 05/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 server/src/main/resources/transport/upper_bounds/9.2.csv diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index 9b32a5cf8b643..53b9a042fd319 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9211000 +9211000,9185007 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv new file mode 100644 index 0000000000000..06614a588ad6d --- /dev/null +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -0,0 +1 @@ +esql_use_minimum_version_for_enrich_resolution,9185007 From 04329c5f7d67a49c4352be3a1cef87f55694bcf0 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 12:36:26 +0100 Subject: [PATCH 06/50] Plumb minimumVersion into the profile --- .../xpack/esql/action/EsqlExecutionInfo.java | 2 +- .../xpack/esql/action/EsqlQueryResponse.java | 14 ++++++++-- .../esql/enrich/EnrichPolicyResolver.java | 3 +- .../xpack/esql/execution/PlanExecutor.java | 14 +++++++--- .../esql/plugin/TransportEsqlQueryAction.java | 28 +++++++++++-------- .../xpack/esql/querylog/EsqlQueryLog.java | 7 +++-- .../xpack/esql/session/EsqlCCSUtils.java | 12 ++++++-- .../xpack/esql/session/EsqlSession.java | 8 +++++- 8 files changed, 62 insertions(+), 26 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlExecutionInfo.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlExecutionInfo.java index 3116978f67696..087cc6855fd62 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlExecutionInfo.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlExecutionInfo.java @@ -70,7 +70,7 @@ public class EsqlExecutionInfo implements ChunkedToXContentObject, Writeable { // Updates to the Cluster occur with the updateCluster method that given the key to map transforms an // old Cluster Object to a new Cluster Object with the remapping function. public final ConcurrentMap clusterInfo; - // Is the clusterInfo map iinitialization in progress? If so, we should not try to serialize it. + // Is the clusterInfo map initialization in progress? If so, we should not try to serialize it. private transient volatile boolean clusterInfoInitializing; // whether the user has asked for CCS metadata to be in the JSON response (the overall took will always be present) private final boolean includeCCSMetadata; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java index 736cb9fdaa5b2..7c0ffd65fa7bb 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java @@ -36,6 +36,8 @@ import java.util.Objects; import java.util.Optional; +import static org.elasticsearch.xpack.esql.enrich.EnrichPolicyResolver.ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION; + public class EsqlQueryResponse extends org.elasticsearch.xpack.core.esql.action.EsqlQueryResponse implements ChunkedToXContentObject, @@ -275,6 +277,10 @@ public Iterator toXContentChunked(ToXContent.Params params })); content.add(ChunkedToXContentHelper.array("drivers", profile.drivers.iterator(), params)); content.add(ChunkedToXContentHelper.array("plans", profile.plans.iterator())); + content.add(ChunkedToXContentHelper.chunk((b, p) -> { + b.field("minimumVersion", profile.minimumVersion().id()); + return b; + })); content.add(ChunkedToXContentHelper.endObject()); } content.add(ChunkedToXContentHelper.endObject()); @@ -383,14 +389,15 @@ public EsqlResponse responseInternal() { return esqlResponse; } - public record Profile(List drivers, List plans) implements Writeable { + public record Profile(List drivers, List plans, TransportVersion minimumVersion) implements Writeable { public static Profile readFrom(StreamInput in) throws IOException { return new Profile( in.readCollectionAsImmutableList(DriverProfile::readFrom), in.getTransportVersion().supports(ESQL_PROFILE_INCLUDE_PLAN) ? in.readCollectionAsImmutableList(PlanProfile::readFrom) - : List.of() + : List.of(), + in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) ? TransportVersion.readVersion(in) : null ); } @@ -400,6 +407,9 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(ESQL_PROFILE_INCLUDE_PLAN)) { out.writeCollection(plans); } + if (out.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + TransportVersion.writeVersion(minimumVersion, out); + } } } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index d089ccecd4092..7c57622b852b1 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -76,7 +76,8 @@ public class EnrichPolicyResolver { private static final String RESOLVE_ACTION_NAME = "cluster:monitor/xpack/enrich/esql/resolve_policy"; - private static final TransportVersion ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION = TransportVersion.fromName( + // NOCOMMIT: rename to something that represents the overall change + public static final TransportVersion ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION = TransportVersion.fromName( "esql_use_minimum_version_for_enrich_resolution" ); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java index 90a0f5a3a88fe..cb1a7a77d3c80 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java @@ -26,6 +26,7 @@ import org.elasticsearch.xpack.esql.session.EsqlSession; import org.elasticsearch.xpack.esql.session.IndexResolver; import org.elasticsearch.xpack.esql.session.Result; +import org.elasticsearch.xpack.esql.session.Versioned; import org.elasticsearch.xpack.esql.telemetry.Metrics; import org.elasticsearch.xpack.esql.telemetry.PlanTelemetry; import org.elasticsearch.xpack.esql.telemetry.PlanTelemetryManager; @@ -73,7 +74,7 @@ public void esql( IndicesExpressionGrouper indicesExpressionGrouper, EsqlSession.PlanRunner planRunner, TransportActionServices services, - ActionListener listener + ActionListener> listener ) { final PlanTelemetry planTelemetry = new PlanTelemetry(functionRegistry); final var session = new EsqlSession( @@ -93,7 +94,7 @@ public void esql( metrics.total(clientId); var begin = System.nanoTime(); - ActionListener executeListener = wrap( + ActionListener> executeListener = wrap( x -> onQuerySuccess(request, listener, x, planTelemetry), ex -> onQueryFailure(request, listener, ex, clientId, planTelemetry, begin) ); @@ -102,7 +103,12 @@ public void esql( ActionListener.run(executeListener, l -> session.execute(request, executionInfo, planRunner, l)); } - private void onQuerySuccess(EsqlQueryRequest request, ActionListener listener, Result x, PlanTelemetry planTelemetry) { + private void onQuerySuccess( + EsqlQueryRequest request, + ActionListener> listener, + Versioned x, + PlanTelemetry planTelemetry + ) { planTelemetryManager.publish(planTelemetry, true); queryLog.onQueryPhase(x, request.query()); listener.onResponse(x); @@ -110,7 +116,7 @@ private void onQuerySuccess(EsqlQueryRequest request, ActionListener lis private void onQueryFailure( EsqlQueryRequest request, - ActionListener listener, + ActionListener> listener, Exception ex, QueryMetric clientId, PlanTelemetry planTelemetry, diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java index f8d24720231e5..2adfede000f04 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java @@ -55,6 +55,7 @@ import org.elasticsearch.xpack.esql.planner.PlannerSettings; import org.elasticsearch.xpack.esql.session.EsqlSession.PlanRunner; import org.elasticsearch.xpack.esql.session.Result; +import org.elasticsearch.xpack.esql.session.Versioned; import java.io.IOException; import java.util.ArrayList; @@ -369,8 +370,9 @@ private EsqlExecutionInfo createEsqlExecutionInfo(EsqlQueryRequest request) { ); } - private EsqlQueryResponse toResponse(Task task, EsqlQueryRequest request, boolean profileEnabled, Result result) { - List columns = result.schema().stream().map(c -> { + private EsqlQueryResponse toResponse(Task task, EsqlQueryRequest request, boolean profileEnabled, Versioned result) { + var innerResult = result.inner(); + List columns = innerResult.schema().stream().map(c -> { List originalTypes; if (c instanceof UnsupportedAttribute ua) { // Sort the original types so they are easier to test against and prettier. @@ -382,32 +384,36 @@ private EsqlQueryResponse toResponse(Task task, EsqlQueryRequest request, boolea return new ColumnInfoImpl(c.name(), c.dataType().outputType(), originalTypes); }).toList(); EsqlQueryResponse.Profile profile = profileEnabled - ? new EsqlQueryResponse.Profile(result.completionInfo().driverProfiles(), result.completionInfo().planProfiles()) + ? new EsqlQueryResponse.Profile( + innerResult.completionInfo().driverProfiles(), + innerResult.completionInfo().planProfiles(), + result.minimumVersion() + ) : null; if (task instanceof EsqlQueryTask asyncTask && request.keepOnCompletion()) { String asyncExecutionId = asyncTask.getExecutionId().getEncoded(); return new EsqlQueryResponse( columns, - result.pages(), - result.completionInfo().documentsFound(), - result.completionInfo().valuesLoaded(), + innerResult.pages(), + innerResult.completionInfo().documentsFound(), + innerResult.completionInfo().valuesLoaded(), profile, request.columnar(), asyncExecutionId, false, request.async(), - result.executionInfo() + innerResult.executionInfo() ); } return new EsqlQueryResponse( columns, - result.pages(), - result.completionInfo().documentsFound(), - result.completionInfo().valuesLoaded(), + innerResult.pages(), + innerResult.completionInfo().documentsFound(), + innerResult.completionInfo().valuesLoaded(), profile, request.columnar(), request.async(), - result.executionInfo() + innerResult.executionInfo() ); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java index cd410c8eba804..9607fcc7e920e 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java @@ -16,6 +16,7 @@ import org.elasticsearch.index.SlowLogFields; import org.elasticsearch.xcontent.json.JsonStringEncoder; import org.elasticsearch.xpack.esql.session.Result; +import org.elasticsearch.xpack.esql.session.Versioned; import java.nio.charset.StandardCharsets; import java.util.HashMap; @@ -62,12 +63,12 @@ public EsqlQueryLog(ClusterSettings settings, SlowLogFieldProvider slowLogFieldP this.additionalFields = slowLogFieldProvider.create(); } - public void onQueryPhase(Result esqlResult, String query) { + public void onQueryPhase(Versioned esqlResult, String query) { if (esqlResult == null) { return; // TODO review, it happens in some tests, not sure if it's a thing also in prod } - long tookInNanos = esqlResult.executionInfo().overallTook().nanos(); - log(() -> Message.of(esqlResult, query, includeUser ? additionalFields.queryFields() : Map.of()), tookInNanos); + long tookInNanos = esqlResult.inner().executionInfo().overallTook().nanos(); + log(() -> Message.of(esqlResult.inner(), query, includeUser ? additionalFields.queryFields() : Map.of()), tookInNanos); } public void onQueryFailure(String query, Exception ex, long tookInNanos) { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlCCSUtils.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlCCSUtils.java index a97b13a61a71e..2d7f4112d137e 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlCCSUtils.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/EsqlCCSUtils.java @@ -10,6 +10,7 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.fieldcaps.FieldCapabilitiesFailure; import org.elasticsearch.action.search.ShardSearchFailure; @@ -77,9 +78,9 @@ static Map determineUnavailableRemoteClusters( */ abstract static class CssPartialErrorsActionListener implements ActionListener> { private final EsqlExecutionInfo executionInfo; - private final ActionListener listener; + private final ActionListener> listener; - CssPartialErrorsActionListener(EsqlExecutionInfo executionInfo, ActionListener listener) { + CssPartialErrorsActionListener(EsqlExecutionInfo executionInfo, ActionListener> listener) { this.executionInfo = executionInfo; this.listener = listener; } @@ -88,7 +89,12 @@ abstract static class CssPartialErrorsActionListener implements ActionListener( + new Result(Analyzer.NO_FIELDS, Collections.emptyList(), DriverCompletionInfo.EMPTY, executionInfo), + TransportVersion.current() + ) + ); } else { listener.onFailure(e); } 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 9fcdcaa9e5fad..f3f185d75dbc3 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 @@ -178,7 +178,12 @@ public String sessionId() { /** * Execute an ESQL request. */ - public void execute(EsqlQueryRequest request, EsqlExecutionInfo executionInfo, PlanRunner planRunner, ActionListener listener) { + public void execute( + EsqlQueryRequest request, + EsqlExecutionInfo executionInfo, + PlanRunner planRunner, + ActionListener> listener + ) { assert ThreadPool.assertCurrentThreadPool(ThreadPool.Names.SEARCH); assert executionInfo != null : "Null EsqlExecutionInfo"; LOGGER.debug("ESQL query:\n{}", request.query()); @@ -250,6 +255,7 @@ public void onResponse(Versioned analyzedPlan) { l ) ) + .>andThen((l, r) -> l.onResponse(new Versioned<>(r, minimumVersion))) .addListener(listener); } } From 6a30c94920a33c33564a5032c6bf89cd48a0067a Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 13:10:14 +0100 Subject: [PATCH 07/50] Update tests --- .../xpack/esql/querylog/EsqlQueryLog.java | 2 +- .../action/EsqlQueryResponseProfileTests.java | 18 ++++++++++++++---- .../esql/action/EsqlQueryResponseTests.java | 7 +++++-- .../xpack/esql/querylog/EsqlQueryLogTests.java | 7 ++++++- .../telemetry/PlanExecutorMetricsTests.java | 8 ++++---- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java index 9607fcc7e920e..8722fa8b1c155 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLog.java @@ -64,7 +64,7 @@ public EsqlQueryLog(ClusterSettings settings, SlowLogFieldProvider slowLogFieldP } public void onQueryPhase(Versioned esqlResult, String query) { - if (esqlResult == null) { + if (esqlResult.inner() == null) { return; // TODO review, it happens in some tests, not sure if it's a thing also in prod } long tookInNanos = esqlResult.inner().executionInfo().overallTook().nanos(); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java index f62d065518bdf..09d0ce262a54b 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java @@ -15,9 +15,12 @@ import org.elasticsearch.compute.operator.OperatorStatus; import org.elasticsearch.compute.operator.PlanProfile; import org.elasticsearch.test.AbstractWireSerializingTestCase; +import org.elasticsearch.xpack.esql.EsqlTestUtils; import java.util.List; +import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomMinimumVersion; + public class EsqlQueryResponseProfileTests extends AbstractWireSerializingTestCase { @Override protected Writeable.Reader instanceReader() { @@ -26,14 +29,21 @@ protected Writeable.Reader instanceReader() { @Override protected EsqlQueryResponse.Profile createTestInstance() { - return new EsqlQueryResponse.Profile(randomDriverProfiles(), randomPlanProfiles()); + return new EsqlQueryResponse.Profile(randomDriverProfiles(), randomPlanProfiles(), randomMinimumVersion()); } @Override protected EsqlQueryResponse.Profile mutateInstance(EsqlQueryResponse.Profile instance) { - return randomBoolean() - ? new EsqlQueryResponse.Profile(randomValueOtherThan(instance.drivers(), this::randomDriverProfiles), instance.plans()) - : new EsqlQueryResponse.Profile(instance.drivers(), randomValueOtherThan(instance.plans(), this::randomPlanProfiles)); + var drivers = instance.drivers(); + var plans = instance.plans(); + var minimumVersion = instance.minimumVersion(); + + switch (between(0, 2)) { + case 0 -> drivers = randomValueOtherThan(drivers, this::randomDriverProfiles); + case 1 -> plans = randomValueOtherThan(plans, this::randomPlanProfiles); + case 2 -> minimumVersion = randomValueOtherThan(minimumVersion, EsqlTestUtils::randomMinimumVersion); + } + return new EsqlQueryResponse.Profile(drivers, plans, minimumVersion); } @Override diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java index edac6b1c5b3eb..15466166d0f4a 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java @@ -9,6 +9,7 @@ import org.apache.lucene.document.InetAddressPoint; import org.apache.lucene.util.BytesRef; +import org.elasticsearch.TransportVersion; import org.elasticsearch.common.Strings; import org.elasticsearch.common.breaker.CircuitBreaker; import org.elasticsearch.common.bytes.BytesReference; @@ -1017,7 +1018,8 @@ public void testProfileXContent() { DriverSleeps.empty() ) ), - List.of(new PlanProfile("test", "elasticsearch", "node-1", "plan tree")) + List.of(new PlanProfile("test", "elasticsearch", "node-1", "plan tree")), + new TransportVersion(1234) ), false, false, @@ -1080,7 +1082,8 @@ public void testProfileXContent() { "node_name" : "node-1", "plan" : "plan tree" } - ] + ], + "minimumVersion" : 1234 } }""")); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLogTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLogTests.java index b23517dd14088..38d1b8b185a76 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLogTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querylog/EsqlQueryLogTests.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.elasticsearch.TransportVersion; import org.elasticsearch.common.ValidationException; import org.elasticsearch.common.logging.ESLogMessage; import org.elasticsearch.common.logging.Loggers; @@ -25,6 +26,7 @@ import org.elasticsearch.xpack.esql.action.EsqlExecutionInfo; import org.elasticsearch.xpack.esql.plugin.EsqlPlugin; import org.elasticsearch.xpack.esql.session.Result; +import org.elasticsearch.xpack.esql.session.Versioned; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -106,7 +108,10 @@ public void testPrioritiesOnSuccess() { for (int i = 0; i < actualTook.length; i++) { EsqlExecutionInfo warnQuery = getEsqlExecutionInfo(actualTook[i], actualPlanningTook[i]); - queryLog.onQueryPhase(new Result(List.of(), List.of(), DriverCompletionInfo.EMPTY, warnQuery), query); + queryLog.onQueryPhase( + new Versioned<>(new Result(List.of(), List.of(), DriverCompletionInfo.EMPTY, warnQuery), TransportVersion.current()), + query + ); if (expectedLevel[i] != null) { assertThat(appender.lastEvent(), is(not(nullValue()))); var msg = (ESLogMessage) appender.lastMessage(); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java index 9889d5de9074b..4727a77b49b40 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java @@ -7,7 +7,6 @@ package org.elasticsearch.xpack.esql.telemetry; -import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.fieldcaps.FieldCapabilities; @@ -43,6 +42,7 @@ import org.elasticsearch.xpack.esql.session.EsqlSession; import org.elasticsearch.xpack.esql.session.IndexResolver; import org.elasticsearch.xpack.esql.session.Result; +import org.elasticsearch.xpack.esql.session.Versioned; import org.junit.After; import org.junit.Before; import org.mockito.stubbing.Answer; @@ -85,7 +85,7 @@ EnrichPolicyResolver mockEnrichResolver() { ActionListener listener = (ActionListener) arguments[arguments.length - 1]; listener.onResponse(new EnrichResolution()); return null; - }).when(enrichResolver).resolvePolicies(any(), any(), TransportVersion.current(), any()); + }).when(enrichResolver).resolvePolicies(any(), any(), any(), any()); return enrichResolver; } @@ -176,7 +176,7 @@ public void testFailedMetric() { EsqlTestUtils.MOCK_TRANSPORT_ACTION_SERVICES, new ActionListener<>() { @Override - public void onResponse(Result result) { + public void onResponse(Versioned result) { fail("this shouldn't happen"); } @@ -206,7 +206,7 @@ public void onFailure(Exception e) { EsqlTestUtils.MOCK_TRANSPORT_ACTION_SERVICES, new ActionListener<>() { @Override - public void onResponse(Result result) {} + public void onResponse(Versioned result) {} @Override public void onFailure(Exception e) { From 0ab7c07df2106141e5d1f8fbf92d9c5cda127de1 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 13:23:09 +0100 Subject: [PATCH 08/50] Assert minimum version in AllSupportedFields tests --- .../xpack/esql/qa/rest/AllSupportedFieldsTestCase.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 105462c60ba62..129f75805bd1b 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -400,6 +400,7 @@ public final void testFetchAggregateMetricDouble() throws IOException { assertMap(indexToRow(columns, values), expectedAllValues); } + @SuppressWarnings("unchecked") private Map esql(String query) throws IOException { Request request = new Request("POST", "_query"); XContentBuilder body = JsonXContent.contentBuilder().startObject(); @@ -418,6 +419,10 @@ private Map esql(String query) throws IOException { request.setJsonEntity(Strings.toString(body)); Map response = responseAsMap(client().performRequest(request)); + Map profile = (Map) response.get("profile"); + Integer minimumVersion = (Integer) profile.get("minimumVersion"); + assertNotNull(minimumVersion); + assertEquals(minVersion().id(), minimumVersion.intValue()); profileLogger.extractProfile(response, true); return response; } From fff61c3f90152326e38c7a82804831a39ca1a025 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 15:28:50 +0100 Subject: [PATCH 09/50] Fix tests some more --- .../qa/rest/AllSupportedFieldsTestCase.java | 70 ++++++++++++++++--- .../action/EsqlResolveFieldsResponse.java | 2 +- .../xpack/esql/session/IndexResolver.java | 1 + 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 129f75805bd1b..de5db7edacdc3 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -9,10 +9,12 @@ import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import org.apache.http.HttpHost; import org.apache.http.util.EntityUtils; import org.elasticsearch.Build; import org.elasticsearch.TransportVersion; import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; import org.elasticsearch.common.Strings; @@ -44,10 +46,12 @@ import static org.elasticsearch.test.ListMatcher.matchesList; import static org.elasticsearch.test.MapMatcher.assertMap; import static org.elasticsearch.test.MapMatcher.matchesMap; +import static org.elasticsearch.xpack.esql.action.EsqlResolveFieldsResponse.RESOLVE_FIELDS_RESPONSE_CREATED_TV; import static org.elasticsearch.xpack.esql.action.EsqlResolveFieldsResponse.RESOLVE_FIELDS_RESPONSE_USED_TV; import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION; import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.ESQL_DENSE_VECTOR_CREATED_VERSION; import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.INDEX_SOURCE; +import static org.elasticsearch.xpack.esql.enrich.EnrichPolicyResolver.ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION; import static org.hamcrest.Matchers.any; import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.containsString; @@ -100,7 +104,14 @@ protected AllSupportedFieldsTestCase(MappedFieldType.FieldExtractPreference extr this.indexMode = indexMode; } - protected record NodeInfo(String cluster, String id, boolean snapshot, TransportVersion version, Set roles) {} + protected record NodeInfo( + String cluster, + String id, + boolean snapshot, + TransportVersion version, + Set roles, + Set boundAddress + ) {} private static Map nodeToInfo; @@ -164,9 +175,12 @@ protected static Map fetchNodeToInfo(RestClient client, String String id = (String) n.getKey(); Map nodeInfo = (Map) n.getValue(); String nodeName = (String) extractValue(nodeInfo, "name"); + Map http = (Map) extractValue(nodeInfo, "http"); + List boundAddressUnparsed = (List) extractValue(http, "bound_address"); + Set boundAddress = boundAddressUnparsed.stream().map(s -> HttpHost.create((String) s)).collect(Collectors.toSet()); /* - * Figuring out is a node is a snapshot is kind of tricky. The main version + * Figuring out if a node is a snapshot is kind of tricky. The main version * doesn't include -SNAPSHOT. But ${VERSION}-SNAPSHOT is in the node info * *somewhere*. So we do this silly toString here. */ @@ -178,7 +192,14 @@ protected static Map fetchNodeToInfo(RestClient client, String nodeToInfo.put( nodeName, - new NodeInfo(cluster, id, snapshot, transportVersion, roles.stream().map(Object::toString).collect(Collectors.toSet())) + new NodeInfo( + cluster, + id, + snapshot, + transportVersion, + roles.stream().map(Object::toString).collect(Collectors.toSet()), + boundAddress + ) ); } @@ -418,13 +439,30 @@ private Map esql(String query) throws IOException { body.endObject(); request.setJsonEntity(Strings.toString(body)); - Map response = responseAsMap(client().performRequest(request)); - Map profile = (Map) response.get("profile"); - Integer minimumVersion = (Integer) profile.get("minimumVersion"); - assertNotNull(minimumVersion); - assertEquals(minVersion().id(), minimumVersion.intValue()); - profileLogger.extractProfile(response, true); - return response; + Response response = client().performRequest(request); + Map responseMap = responseAsMap(client().performRequest(request)); + HttpHost coordinatorHost = response.getHost(); + NodeInfo coordinator = allNodeToInfo().values().stream().filter(n -> n.boundAddress().contains(coordinatorHost)).findFirst().get(); + TransportVersion coordinatorVersion = coordinator.version(); + if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + Map profile = (Map) responseMap.get("profile"); + Integer minimumVersion = (Integer) profile.get("minimumVersion"); + assertNotNull(minimumVersion); + if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV)) { + // All nodes are new enough that their field caps responses should contain the minimum transport version + // of matching clusters. + assertEquals(minVersion().id(), minimumVersion.intValue()); + } else { + // One node is old enough that it doesn't provide version information in the field caps response. We must assume + // the oldest compatible version. + // Since we got minimumVersion in the profile, the coordinator is on a new version. + // Thus, it's on the same version as this code (bwc tests only use 2 different versions) and the oldest compatible version + // to the coordinator is given by TransportVersion.minimumCompatible(). + assertEquals(TransportVersion.minimumCompatible(), minimumVersion.intValue()); + } + } + profileLogger.extractProfile(responseMap, true); + return responseMap; } protected void createIndexForNode(RestClient client, String nodeName, String nodeId) throws IOException { @@ -696,7 +734,17 @@ private Map expectedIndices() throws IOException { name = e.getValue().cluster + ":" + name; } // We should only end up with one per cluster - result.put(name, new NodeInfo(e.getValue().cluster, null, e.getValue().snapshot(), e.getValue().version(), null)); + result.put( + name, + new NodeInfo( + e.getValue().cluster, + null, + e.getValue().snapshot(), + e.getValue().version(), + null, + e.getValue().boundAddress() + ) + ); } } return result; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlResolveFieldsResponse.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlResolveFieldsResponse.java index 48fa667de43ec..b952b84027e15 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlResolveFieldsResponse.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlResolveFieldsResponse.java @@ -17,7 +17,7 @@ import java.io.IOException; public class EsqlResolveFieldsResponse extends ActionResponse { - private static final TransportVersion RESOLVE_FIELDS_RESPONSE_CREATED_TV = TransportVersion.fromName( + public static final TransportVersion RESOLVE_FIELDS_RESPONSE_CREATED_TV = TransportVersion.fromName( "esql_resolve_fields_response_created" ); public static final TransportVersion RESOLVE_FIELDS_RESPONSE_REMOVED_MIN_TV = TransportVersion.fromName( 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 1299877763ebc..b1af0f899391e 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 @@ -164,6 +164,7 @@ private void resolveAsMergedMapping( useAggregateMetricDoubleWhenNotSupported, useDenseVectorWhenNotSupported ); + // NOCOMMIT: Update logging here as well LOGGER.debug("minimum transport version {} {}", response.caps().minTransportVersion(), info.effectiveMinTransportVersion()); l.onResponse(new Versioned<>(mergedMappings(indexPattern, info), info.effectiveMinTransportVersion())); }) From c923ad307746ffb3578324c6c115fc6043deefba Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 18:40:38 +0100 Subject: [PATCH 10/50] Fix test --- .../qa/single_node/AllSupportedFieldsIT.java | 15 +++++++++++++++ .../qa/rest/AllSupportedFieldsTestCase.java | 19 +++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java index aaa2be1750be4..38afdfb5356d8 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java @@ -9,6 +9,7 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import org.elasticsearch.TransportVersion; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; import org.elasticsearch.test.TestClustersThreadFilter; @@ -16,6 +17,9 @@ import org.elasticsearch.xpack.esql.qa.rest.AllSupportedFieldsTestCase; import org.junit.ClassRule; +import java.io.IOException; +import java.util.Map; + /** * Simple test for fetching all supported field types. */ @@ -32,4 +36,15 @@ public AllSupportedFieldsIT(MappedFieldType.FieldExtractPreference extractPrefer protected String getTestRestCluster() { return cluster.getHttpAddresses(); } + + @Override + @SuppressWarnings("unchecked") + protected void assertMinimumVersion(TransportVersion coordinatorVersion, Map responseMap) throws IOException { + Map profile = (Map) responseMap.get("profile"); + Integer minimumVersion = (Integer) profile.get("minimumVersion"); + assertNotNull(minimumVersion); + int minVersionInt = minimumVersion; + assertEquals(minVersion().id(), minVersionInt); + assertEquals(TransportVersion.current().id(), minVersionInt); + } } diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index de5db7edacdc3..a680affb030e5 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -176,8 +176,9 @@ protected static Map fetchNodeToInfo(RestClient client, String Map nodeInfo = (Map) n.getValue(); String nodeName = (String) extractValue(nodeInfo, "name"); Map http = (Map) extractValue(nodeInfo, "http"); - List boundAddressUnparsed = (List) extractValue(http, "bound_address"); - Set boundAddress = boundAddressUnparsed.stream().map(s -> HttpHost.create((String) s)).collect(Collectors.toSet()); + List unparsedBoundAddress = (List) extractValue(http, "bound_address"); + // The bound address can actually be 2 addresses, one ipv4 and one ipv6; stuff 'em in a set. + Set boundAddress = unparsedBoundAddress.stream().map(s -> HttpHost.create((String) s)).collect(Collectors.toSet()); /* * Figuring out if a node is a snapshot is kind of tricky. The main version @@ -440,10 +441,18 @@ private Map esql(String query) throws IOException { request.setJsonEntity(Strings.toString(body)); Response response = client().performRequest(request); - Map responseMap = responseAsMap(client().performRequest(request)); + Map responseMap = responseAsMap(response); HttpHost coordinatorHost = response.getHost(); NodeInfo coordinator = allNodeToInfo().values().stream().filter(n -> n.boundAddress().contains(coordinatorHost)).findFirst().get(); TransportVersion coordinatorVersion = coordinator.version(); + assertMinimumVersion(coordinatorVersion, responseMap); + + profileLogger.extractProfile(responseMap, true); + return responseMap; + } + + @SuppressWarnings("unchecked") + protected void assertMinimumVersion(TransportVersion coordinatorVersion, Map responseMap) throws IOException { if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { Map profile = (Map) responseMap.get("profile"); Integer minimumVersion = (Integer) profile.get("minimumVersion"); @@ -458,11 +467,9 @@ private Map esql(String query) throws IOException { // Since we got minimumVersion in the profile, the coordinator is on a new version. // Thus, it's on the same version as this code (bwc tests only use 2 different versions) and the oldest compatible version // to the coordinator is given by TransportVersion.minimumCompatible(). - assertEquals(TransportVersion.minimumCompatible(), minimumVersion.intValue()); + assertEquals(TransportVersion.minimumCompatible().id(), minimumVersion.intValue()); } } - profileLogger.extractProfile(responseMap, true); - return responseMap; } protected void createIndexForNode(RestClient client, String nodeName, String nodeId) throws IOException { From e7c628b84e2405a1e792305b1ffe5d233c6ffed5 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Tue, 4 Nov 2025 19:26:12 +0100 Subject: [PATCH 11/50] Fix test expectations --- .../qa/rest/AllSupportedFieldsTestCase.java | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index a680affb030e5..756d2425c76cf 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -18,6 +18,7 @@ import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; import org.elasticsearch.common.Strings; +import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; import org.elasticsearch.logging.LogManager; @@ -236,10 +237,12 @@ public void skipSnapshots() throws IOException { // TODO: Also add a test for _tsid once we can determine the minimum transport version of all nodes. public final void testFetchAll() throws IOException { - Map response = esql(""" + var responseAndCoordinatorVersion = esql(""" , _id, _ignored, _index_mode, _score, _source, _version | LIMIT 1000 """); + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); if ((Boolean) response.get("is_partial")) { throw new AssertionError("partial results: " + response); } @@ -251,7 +254,7 @@ public final void testFetchAll() throws IOException { if (supportedInIndex(type) == false) { continue; } - expectedColumns = expectedColumns.entry(fieldName(type), expectedType(type)); + expectedColumns = expectedColumns.entry(fieldName(type), expectedType(type, coordinatorVersion)); } expectedColumns = expectedColumns.entry("_id", "keyword") .entry("_ignored", "keyword") @@ -265,13 +268,12 @@ public final void testFetchAll() throws IOException { MapMatcher expectedAllValues = matchesMap(); for (Map.Entry e : expectedIndices().entrySet()) { String indexName = e.getKey(); - NodeInfo nodeInfo = e.getValue(); MapMatcher expectedValues = matchesMap(); for (DataType type : DataType.values()) { if (supportedInIndex(type) == false) { continue; } - expectedValues = expectedValues.entry(fieldName(type), expectedValue(type, nodeInfo)); + expectedValues = expectedValues.entry(fieldName(type), expectedValue(type, coordinatorVersion)); } expectedValues = expectedValues.entry("_id", any(String.class)) .entry("_ignored", nullValue()) @@ -302,7 +304,7 @@ public final void testFetchDenseVector() throws IOException { | EVAL k = v_l2_norm(f_dense_vector, [1]) // workaround to enable fetching dense_vector """ + request; } - response = esql(request); + response = esql(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -369,7 +371,7 @@ public final void testFetchAggregateMetricDouble() throws IOException { | EVAL junk = TO_AGGREGATE_METRIC_DOUBLE(1) // workaround to enable fetching aggregate_metric_double """ + request; } - response = esql(request); + response = esql(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -422,8 +424,11 @@ public final void testFetchAggregateMetricDouble() throws IOException { assertMap(indexToRow(columns, values), expectedAllValues); } + /** + * Run the query and return the response and the version of the coordinator. + */ @SuppressWarnings("unchecked") - private Map esql(String query) throws IOException { + private Tuple, TransportVersion> esql(String query) throws IOException { Request request = new Request("POST", "_query"); XContentBuilder body = JsonXContent.contentBuilder().startObject(); body.field("query", "FROM *:%mode%*,%mode%* METADATA _index".replace("%mode%", indexMode.toString()) + query); @@ -448,7 +453,7 @@ private Map esql(String query) throws IOException { assertMinimumVersion(coordinatorVersion, responseMap); profileLogger.extractProfile(responseMap, true); - return responseMap; + return new Tuple<>(responseMap, coordinatorVersion); } @SuppressWarnings("unchecked") @@ -575,7 +580,7 @@ private void createAllTypesDoc(RestClient client, String indexName) throws IOExc } // This will become dependent on the minimum transport version of all nodes once we can determine that. - private Matcher expectedValue(DataType type, NodeInfo nodeInfo) throws IOException { + private Matcher expectedValue(DataType type, TransportVersion coordinatorVersion) throws IOException { return switch (type) { case BOOLEAN -> equalTo(true); case COUNTER_LONG, LONG, COUNTER_INTEGER, INTEGER, UNSIGNED_LONG, SHORT, BYTE -> equalTo(1); @@ -594,14 +599,16 @@ private Matcher expectedValue(DataType type, NodeInfo nodeInfo) throws IOExce case GEO_SHAPE -> equalTo("POINT (-71.34 41.12)"); case NULL -> nullValue(); case AGGREGATE_METRIC_DOUBLE -> { - if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false + // See expectedType for an explanation + if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false || minVersion().supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { yield nullValue(); } yield equalTo("{\"min\":-302.5,\"max\":702.3,\"sum\":200.0,\"value_count\":25}"); } case DENSE_VECTOR -> { - if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false + // See expectedType for an explanation + if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false || minVersion().supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { yield nullValue(); } @@ -679,7 +686,7 @@ private Map nameToValue(List names, List values) { } // This will become dependent on the minimum transport version of all nodes once we can determine that. - private Matcher expectedType(DataType type) throws IOException { + private Matcher expectedType(DataType type, TransportVersion coordinatorVersion) throws IOException { return switch (type) { case COUNTER_DOUBLE, COUNTER_LONG, COUNTER_INTEGER -> { if (indexMode == IndexMode.TIME_SERIES) { @@ -691,18 +698,22 @@ private Matcher expectedType(DataType type) throws IOException { case HALF_FLOAT, SCALED_FLOAT, FLOAT -> equalTo("double"); case NULL -> equalTo("keyword"); case AGGREGATE_METRIC_DOUBLE -> { - // RESOLVE_FIELDS_RESPONSE_USED_TV is newer and technically sufficient to check. - // We also check for ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION for clarity. - // Future data types added here should only require the TV when they were created, - // because it will be after RESOLVE_FIELDS_RESPONSE_USED_TV. - if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false + // 9.2.0 nodes have ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION and support this type + // when they are data nodes, but not as coordinators! + // (Unless the query uses functions that depend on this type, which is a workaround + // for missing version-awareness in 9.2.0, and not considered here.) + // RESOLVE_FIELDS_RESPONSE_USED_TV is newer and marks the point when coordinators + // started to be able to plan for this data type, and will consider it supported if + // all nodes are on ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION or newer. + if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false || minVersion().supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { yield equalTo("unsupported"); } yield equalTo("aggregate_metric_double"); } case DENSE_VECTOR -> { - if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false + // Same dance as for AGGREGATE_METRIC_DOUBLE + if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false || minVersion().supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { yield equalTo("unsupported"); } From ea39c5296914d1a0d15dd351b40abdf067c85197 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Tue, 4 Nov 2025 18:32:17 +0000 Subject: [PATCH 12/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index bdc633680e172..5d819256f0b7a 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9213000 +9213000,9185007 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 24c87f7fbf43a..06614a588ad6d 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -ilm_searchable_snapshot_opt_out_clone,9185006 +esql_use_minimum_version_for_enrich_resolution,9185007 From 11de8ec7d503e71d2ec561ceb26f6bb787efd9b6 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Wed, 5 Nov 2025 14:17:23 +0100 Subject: [PATCH 13/50] Add test for ROW --- .../qa/single_node/AllSupportedFieldsIT.java | 6 ++- .../qa/rest/AllSupportedFieldsTestCase.java | 47 ++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java index 38afdfb5356d8..2b119c4bcdea3 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java @@ -10,6 +10,7 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import org.elasticsearch.TransportVersion; +import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; import org.elasticsearch.test.TestClustersThreadFilter; @@ -39,7 +40,10 @@ protected String getTestRestCluster() { @Override @SuppressWarnings("unchecked") - protected void assertMinimumVersion(TransportVersion coordinatorVersion, Map responseMap) throws IOException { + protected void assertMinimumVersionFromAllQueries(Tuple, TransportVersion> responseAndCoordinatorVersion) + throws IOException { + var responseMap = responseAndCoordinatorVersion.v1(); + Map profile = (Map) responseMap.get("profile"); Integer minimumVersion = (Integer) profile.get("minimumVersion"); assertNotNull(minimumVersion); diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 756d2425c76cf..6b493c6a10996 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -237,7 +237,7 @@ public void skipSnapshots() throws IOException { // TODO: Also add a test for _tsid once we can determine the minimum transport version of all nodes. public final void testFetchAll() throws IOException { - var responseAndCoordinatorVersion = esql(""" + var responseAndCoordinatorVersion = runFromAllQuery(""" , _id, _ignored, _index_mode, _score, _source, _version | LIMIT 1000 """); @@ -304,7 +304,7 @@ public final void testFetchDenseVector() throws IOException { | EVAL k = v_l2_norm(f_dense_vector, [1]) // workaround to enable fetching dense_vector """ + request; } - response = esql(request).v1(); + response = runFromAllQuery(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -371,7 +371,7 @@ public final void testFetchAggregateMetricDouble() throws IOException { | EVAL junk = TO_AGGREGATE_METRIC_DOUBLE(1) // workaround to enable fetching aggregate_metric_double """ + request; } - response = esql(request).v1(); + response = runFromAllQuery(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -424,14 +424,44 @@ public final void testFetchAggregateMetricDouble() throws IOException { assertMap(indexToRow(columns, values), expectedAllValues); } + private Tuple, TransportVersion> runFromAllQuery(String restOfQuery) throws IOException { + var responseAndCoordinatorVersion = runQuery( + "FROM *:%mode%*,%mode%* METADATA _index".replace("%mode%", indexMode.toString()) + restOfQuery + ); + assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + return responseAndCoordinatorVersion; + } + + public void testRow() throws IOException { + assumeTrue( + "Test has to run only once, skip on other configurations", + extractPreference == MappedFieldType.FieldExtractPreference.NONE && indexMode == IndexMode.STANDARD + ); + String query = "ROW x = 1 | LIMIT 1"; + var responseAndCoordinatorVersion = runQuery(query); + var responseMap = responseAndCoordinatorVersion.v1(); + var coordinatorVersion = responseAndCoordinatorVersion.v2(); + + if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + @SuppressWarnings("unchecked") + Map profile = (Map) responseMap.get("profile"); + Integer minimumVersion = (Integer) profile.get("minimumVersion"); + assertNotNull(minimumVersion); + // For ROW commands without commands that reach out to other nodes, the minimum version is given by the coordinator. + assertEquals(coordinatorVersion.id(), minimumVersion.intValue()); + } + } + /** * Run the query and return the response and the version of the coordinator. + *

+ * Fails if the response contains any warnings. */ @SuppressWarnings("unchecked") - private Tuple, TransportVersion> esql(String query) throws IOException { + private Tuple, TransportVersion> runQuery(String query) throws IOException { Request request = new Request("POST", "_query"); XContentBuilder body = JsonXContent.contentBuilder().startObject(); - body.field("query", "FROM *:%mode%*,%mode%* METADATA _index".replace("%mode%", indexMode.toString()) + query); + body.field("query", query); { body.startObject("pragma"); if (extractPreference != null) { @@ -450,14 +480,17 @@ private Tuple, TransportVersion> esql(String query) throws I HttpHost coordinatorHost = response.getHost(); NodeInfo coordinator = allNodeToInfo().values().stream().filter(n -> n.boundAddress().contains(coordinatorHost)).findFirst().get(); TransportVersion coordinatorVersion = coordinator.version(); - assertMinimumVersion(coordinatorVersion, responseMap); profileLogger.extractProfile(responseMap, true); return new Tuple<>(responseMap, coordinatorVersion); } @SuppressWarnings("unchecked") - protected void assertMinimumVersion(TransportVersion coordinatorVersion, Map responseMap) throws IOException { + protected void assertMinimumVersionFromAllQueries(Tuple, TransportVersion> responseAndCoordinatorVersion) + throws IOException { + var responseMap = responseAndCoordinatorVersion.v1(); + var coordinatorVersion = responseAndCoordinatorVersion.v2(); + if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { Map profile = (Map) responseMap.get("profile"); Integer minimumVersion = (Integer) profile.get("minimumVersion"); From 27d56f80464ca54db8afef3cea43655bca935f14 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Wed, 5 Nov 2025 15:20:32 +0100 Subject: [PATCH 14/50] Remove obsolete expectations DENSE_VECTOR is completely unsupported on 9.1, no need to check for this version and adjust expectations. --- .../esql/qa/rest/AllSupportedFieldsTestCase.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 6b493c6a10996..7ba898889ebef 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -77,7 +77,6 @@ * load constant field values and have simple mappings. */ public class AllSupportedFieldsTestCase extends ESRestTestCase { - private static final Logger logger = LogManager.getLogger(FieldExtractorTestCase.class); @Rule(order = Integer.MIN_VALUE) public ProfileLogger profileLogger = new ProfileLogger(); @@ -223,7 +222,7 @@ public void createIndices() throws IOException { * Make sure the test doesn't run on snapshot builds. Release builds only. *

* {@link Build#isSnapshot()} checks if the version under test is a snapshot. - * But! This run test runs against many versions and if *any* are snapshots + * But! This test runs against many versions and if *any* are snapshots * then this will fail. So we check the versions of each node in the cluster too. *

*/ @@ -347,7 +346,7 @@ public final void testFetchDenseVector() throws IOException { String indexName = e.getKey(); NodeInfo nodeInfo = e.getValue(); MapMatcher expectedValues = matchesMap(); - expectedValues = expectedValues.entry("f_dense_vector", expectedDenseVector(nodeInfo.version)); + expectedValues = expectedValues.entry("f_dense_vector", matchesList().item(0.5).item(10.0).item(5.9999995)); expectedValues = expectedValues.entry("_index", indexName); expectedAllValues = expectedAllValues.entry(indexName, expectedValues); } @@ -412,7 +411,6 @@ public final void testFetchAggregateMetricDouble() throws IOException { MapMatcher expectedAllValues = matchesMap(); for (Map.Entry e : expectedIndices().entrySet()) { String indexName = e.getKey(); - NodeInfo nodeInfo = e.getValue(); MapMatcher expectedValues = matchesMap(); expectedValues = expectedValues.entry( "f_aggregate_metric_double", @@ -651,12 +649,6 @@ private Matcher expectedValue(DataType type, TransportVersion coordinatorVers }; } - private Matcher> expectedDenseVector(TransportVersion version) { - return version.supports(INDEX_SOURCE) // *after* 9.1 - ? matchesList().item(0.5).item(10.0).item(5.9999995) - : matchesList().item(0.04283529).item(0.85670584).item(0.5140235); - } - /** * Is the type supported in indices? */ From 22b7aa52bcc31269893f13c139866dbf2924798f Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Wed, 5 Nov 2025 16:24:33 +0100 Subject: [PATCH 15/50] WIP: LOOKUP JOIN tests --- .../xpack/esql/ccq/AllSupportedFieldsIT.java | 8 +- .../qa/rest/AllSupportedFieldsTestCase.java | 93 ++++++++++++++----- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java index fc0763f7d0777..460b864ab354c 100644 --- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java @@ -31,6 +31,8 @@ /** * Fetch all field types via cross cluster search, possible on a different version. */ +// TODO: ROW + remote ENRICH +// TODO: remote lookups, requires `FROM remote:*` @ThreadLeakFilters(filters = TestClustersThreadFilter.class) public class AllSupportedFieldsIT extends AllSupportedFieldsTestCase { static ElasticsearchCluster remoteCluster = Clusters.remoteCluster(); @@ -50,10 +52,12 @@ public AllSupportedFieldsIT(MappedFieldType.FieldExtractPreference extractPrefer public void createRemoteIndices() throws IOException { if (supportsNodeAssignment()) { for (Map.Entry e : remoteNodeToInfo().entrySet()) { - createIndexForNode(remoteClient(), e.getKey(), e.getValue().id()); + createIndexForNode(remoteClient(), e.getKey(), e.getValue().id(), indexMode()); + createIndexForNode(remoteClient(), e.getKey(), e.getValue().id(), IndexMode.LOOKUP); } } else { - createIndexForNode(remoteClient(), null, null); + createIndexForNode(remoteClient(), null, null, indexMode()); + createIndexForNode(remoteClient(), null, null, IndexMode.LOOKUP); } } diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 7ba898889ebef..9824a5639b280 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -21,8 +21,6 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; -import org.elasticsearch.logging.LogManager; -import org.elasticsearch.logging.Logger; import org.elasticsearch.test.MapMatcher; import org.elasticsearch.test.rest.ESRestTestCase; import org.elasticsearch.xcontent.XContentBuilder; @@ -51,7 +49,6 @@ import static org.elasticsearch.xpack.esql.action.EsqlResolveFieldsResponse.RESOLVE_FIELDS_RESPONSE_USED_TV; import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION; import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.ESQL_DENSE_VECTOR_CREATED_VERSION; -import static org.elasticsearch.xpack.esql.core.type.DataType.DataTypesTransportVersions.INDEX_SOURCE; import static org.elasticsearch.xpack.esql.enrich.EnrichPolicyResolver.ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION; import static org.hamcrest.Matchers.any; import static org.hamcrest.Matchers.anyOf; @@ -104,6 +101,10 @@ protected AllSupportedFieldsTestCase(MappedFieldType.FieldExtractPreference extr this.indexMode = indexMode; } + protected IndexMode indexMode() { + return indexMode; + } + protected record NodeInfo( String cluster, String id, @@ -211,10 +212,13 @@ protected static Map fetchNodeToInfo(RestClient client, String public void createIndices() throws IOException { if (supportsNodeAssignment()) { for (Map.Entry e : nodeToInfo().entrySet()) { - createIndexForNode(client(), e.getKey(), e.getValue().id()); + createIndexForNode(client(), e.getKey(), e.getValue().id(), indexMode); + // Always create the lookup index so we can test LOOKUP JOIN queries with it + createIndexForNode(client(), e.getKey(), e.getValue().id(), IndexMode.LOOKUP); } } else { - createIndexForNode(client(), null, null); + createIndexForNode(client(), null, null, indexMode); + createIndexForNode(client(), null, null, IndexMode.LOOKUP); } } @@ -261,13 +265,15 @@ public final void testFetchAll() throws IOException { .entry("_index_mode", "keyword") .entry("_score", "double") .entry("_source", "_source") - .entry("_version", "long"); + .entry("_version", "long") + .entry(LOOKUP_ID_FIELD, "integer"); assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); - for (Map.Entry e : expectedIndices().entrySet()) { + for (Map.Entry e : expectedIndices(indexMode).entrySet()) { String indexName = e.getKey(); MapMatcher expectedValues = matchesMap(); + expectedValues = expectedValues.entry(LOOKUP_ID_FIELD, equalTo(123)); for (DataType type : DataType.values()) { if (supportedInIndex(type) == false) { continue; @@ -342,7 +348,7 @@ public final void testFetchDenseVector() throws IOException { assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); - for (Map.Entry e : expectedIndices().entrySet()) { + for (Map.Entry e : expectedIndices(indexMode).entrySet()) { String indexName = e.getKey(); NodeInfo nodeInfo = e.getValue(); MapMatcher expectedValues = matchesMap(); @@ -409,7 +415,7 @@ public final void testFetchAggregateMetricDouble() throws IOException { assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); - for (Map.Entry e : expectedIndices().entrySet()) { + for (Map.Entry e : expectedIndices(indexMode).entrySet()) { String indexName = e.getKey(); MapMatcher expectedValues = matchesMap(); expectedValues = expectedValues.entry( @@ -450,6 +456,28 @@ public void testRow() throws IOException { } } + // TODO: ROW + local ENRICH + public void testRowLookupJoin() throws IOException { + assumeTrue( + "Test has to run only once, skip on other configurations", + extractPreference == MappedFieldType.FieldExtractPreference.NONE && indexMode == IndexMode.STANDARD + ); + // TODO: For ccq, we most likely need to strip the remote lookup indices. And also use the local minimum transport version, only. + Map expectedIndices = expectedIndices(IndexMode.LOOKUP); + for (Map.Entry e : expectedIndices.entrySet()) { + String indexName = e.getKey(); + String query = "ROW " + LOOKUP_ID_FIELD + " = 1234 | LOOKUP JOIN " + indexName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; + var responseAndCoordinatorVersion = runQuery(query); + var responseMap = responseAndCoordinatorVersion.v1(); + var coordinatorVersion = responseAndCoordinatorVersion.v2(); + + // HERE next, this actually needs fixing in production code. + assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + + // TODO: same assert as in testfetchall, I think. + } + } + /** * Run the query and return the response and the version of the coordinator. *

@@ -508,24 +536,31 @@ protected void assertMinimumVersionFromAllQueries(Tuple, Tra } } - protected void createIndexForNode(RestClient client, String nodeName, String nodeId) throws IOException { - String indexName = indexMode.toString(); - if (nodeName != null) { - indexName += "_" + nodeName.toLowerCase(Locale.ROOT); - } + protected static void createIndexForNode(RestClient client, String nodeName, String nodeId, IndexMode mode) throws IOException { + String indexName = indexName(mode, nodeName); if (false == indexExists(client, indexName)) { - createAllTypesIndex(client, indexName, nodeId); + createAllTypesIndex(client, indexName, nodeId, mode); createAllTypesDoc(client, indexName); } } - private void createAllTypesIndex(RestClient client, String indexName, String nodeId) throws IOException { + protected static String indexName(IndexMode mode, String nodeName) { + String indexName = mode.toString(); + if (nodeName != null) { + indexName += "_" + nodeName.toLowerCase(Locale.ROOT); + } + return indexName; + } + + private static final String LOOKUP_ID_FIELD = "lookup_id"; + + private static void createAllTypesIndex(RestClient client, String indexName, String nodeId, IndexMode mode) throws IOException { XContentBuilder config = JsonXContent.contentBuilder().startObject(); { config.startObject("settings"); config.startObject("index"); - config.field("mode", indexMode); - if (indexMode == IndexMode.TIME_SERIES) { + config.field("mode", mode); + if (mode == IndexMode.TIME_SERIES) { config.field("routing_path", "f_keyword"); } if (nodeId != null) { @@ -536,14 +571,20 @@ private void createAllTypesIndex(RestClient client, String indexName, String nod } { config.startObject("mappings").startObject("properties"); + + config.startObject(LOOKUP_ID_FIELD); + config.field("type", "integer"); + config.endObject(); + for (DataType type : DataType.values()) { if (supportedInIndex(type) == false) { continue; } config.startObject(fieldName(type)); - typeMapping(indexMode, config, type); + typeMapping(mode, config, type); config.endObject(); } + config.endObject().endObject().endObject(); } Request request = new Request("PUT", indexName); @@ -551,11 +592,11 @@ private void createAllTypesIndex(RestClient client, String indexName, String nod client.performRequest(request); } - private String fieldName(DataType type) { + private static String fieldName(DataType type) { return type == DataType.DATETIME ? "@timestamp" : "f_" + type.esType(); } - private void typeMapping(IndexMode indexMode, XContentBuilder config, DataType type) throws IOException { + private static void typeMapping(IndexMode indexMode, XContentBuilder config, DataType type) throws IOException { switch (type) { case COUNTER_DOUBLE, COUNTER_INTEGER, COUNTER_LONG -> config.field("type", type.esType().replace("counter_", "")) .field("time_series_metric", "counter"); @@ -574,8 +615,10 @@ private void typeMapping(IndexMode indexMode, XContentBuilder config, DataType t } } - private void createAllTypesDoc(RestClient client, String indexName) throws IOException { + private static void createAllTypesDoc(RestClient client, String indexName) throws IOException { XContentBuilder doc = JsonXContent.contentBuilder().startObject(); + doc.field(LOOKUP_ID_FIELD); + doc.value(123); for (DataType type : DataType.values()) { if (supportedInIndex(type) == false) { continue; @@ -760,11 +803,11 @@ private boolean syntheticSourceByDefault() { }; } - private Map expectedIndices() throws IOException { + private Map expectedIndices(IndexMode indexMode) throws IOException { Map result = new TreeMap<>(); if (supportsNodeAssignment()) { for (Map.Entry e : allNodeToInfo().entrySet()) { - String name = indexMode + "_" + e.getKey(); + String name = indexName(indexMode, e.getKey()); if (e.getValue().cluster != null) { name = e.getValue().cluster + ":" + name; } @@ -772,7 +815,7 @@ private Map expectedIndices() throws IOException { } } else { for (Map.Entry e : allNodeToInfo().entrySet()) { - String name = indexMode.toString(); + String name = indexName(indexMode, null); if (e.getValue().cluster != null) { name = e.getValue().cluster + ":" + name; } From 6cf55ee0bede81401cae1468528bf62f2170684c Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Wed, 5 Nov 2025 18:27:49 +0100 Subject: [PATCH 16/50] Consider min version from LOOKUP JOIN resolution Important for ROW queries. --- .../esql/enrich/EnrichPolicyResolver.java | 6 +- .../xpack/esql/session/EsqlSession.java | 10 ++- .../xpack/esql/session/IndexResolver.java | 73 +++++-------------- 3 files changed, 30 insertions(+), 59 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 7c57622b852b1..c57793437ac95 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -44,6 +44,7 @@ import org.elasticsearch.xpack.esql.core.type.EsField; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.index.EsIndex; +import org.elasticsearch.xpack.esql.index.IndexResolution; import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; import org.elasticsearch.xpack.esql.io.stream.PlanStreamOutput; import org.elasticsearch.xpack.esql.plan.logical.Enrich; @@ -474,7 +475,7 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas } try (ThreadContext.StoredContext ignored = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) { String indexName = EnrichPolicy.getBaseName(policyName); - indexResolver.resolveAsMergedMappingForVersion( + indexResolver.resolveAsMergedMapping( indexName, IndexResolver.ALL_FIELDS, null, @@ -483,7 +484,8 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas false, false, request.minimumVersion, - refs.acquire(indexResult -> { + refs.acquire(versionedIndexResult -> { + IndexResolution indexResult = versionedIndexResult.inner(); if (indexResult.isValid() && indexResult.get().concreteIndices().size() == 1) { EsIndex esIndex = indexResult.get(); var concreteIndices = Map.of(request.clusterAlias, Iterables.get(esIndex.concreteIndices(), 0)); 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 b8151d3db45ef..11d28fb450dea 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 @@ -608,7 +608,7 @@ private void preAnalyzeLookupIndex( ThreadPool.Names.SEARCH_COORDINATION, ThreadPool.Names.SYSTEM_READ ); - indexResolver.resolveAsMergedMappingForVersion( + indexResolver.resolveAsMergedMapping( EsqlCCSUtils.createQualifiedLookupIndexExpressionFromAvailableClusters(executionInfo, localPattern), result.wildcardJoinIndices().contains(localPattern) ? IndexResolver.ALL_FIELDS : result.fieldNames, null, @@ -642,8 +642,9 @@ private PreAnalysisResult receiveLookupIndexResolution( PreAnalysisResult result, String index, EsqlExecutionInfo executionInfo, - IndexResolution lookupIndexResolution + Versioned versionedLookupIndexResolution ) { + IndexResolution lookupIndexResolution = versionedLookupIndexResolution.inner(); EsqlCCSUtils.updateExecutionInfoWithUnavailableClusters(executionInfo, lookupIndexResolution.failures()); if (lookupIndexResolution.isValid() == false) { // If the index resolution is invalid, don't bother with the rest of the analysis @@ -673,7 +674,9 @@ private PreAnalysisResult receiveLookupIndexResolution( + "] mode" ); } - return result.addLookupIndexResolution(index, lookupIndexResolution); + + return result.addLookupIndexResolution(index, lookupIndexResolution) + .withMinimumTransportVersion(versionedLookupIndexResolution.minimumVersion()); } if (lookupIndexResolution.get().indexNameWithModes().isEmpty() && lookupIndexResolution.resolvedIndices().isEmpty() == false) { @@ -860,6 +863,7 @@ private void preAnalyzeMainIndices( indexMode == IndexMode.TIME_SERIES, preAnalysis.useAggregateMetricDoubleWhenNotSupported(), preAnalysis.useDenseVectorWhenNotSupported(), + null, listener.delegateFailureAndWrap((l, indexResolution) -> { EsqlCCSUtils.updateExecutionInfoWithUnavailableClusters(executionInfo, indexResolution.inner().failures()); l.onResponse( 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 b1af0f899391e..5246ef8a4157e 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 @@ -83,67 +83,26 @@ public IndexResolver(Client client) { this.client = client; } - /** - * Like {@link #resolveAsMergedMapping(String, Set, QueryBuilder, boolean, boolean, boolean, ActionListener)}, - * but uses a pre-determined minimum transport version. - */ - public void resolveAsMergedMappingForVersion( - String indexPattern, - Set fieldNames, - QueryBuilder requestFilter, - boolean includeAllDimensions, - boolean useAggregateMetricDoubleWhenNotSupported, - boolean useDenseVectorWhenNotSupported, - TransportVersion minimumVersion, - ActionListener listener - ) { - ActionListener> versionIgnoringListener = listener.delegateFailureAndWrap( - (l, versionedResolution) -> l.onResponse(versionedResolution.inner()) - ); - - resolveAsMergedMapping( - indexPattern, - fieldNames, - requestFilter, - includeAllDimensions, - useAggregateMetricDoubleWhenNotSupported, - useDenseVectorWhenNotSupported, - minimumVersion, - versionIgnoringListener - ); - } - /** * Perform a field caps request to resolve a pattern to one mapping (potentially compound, meaning it spans multiple indices). * The field caps response contains the minimum transport version of all clusters that apply to the pattern, * and it is used to deal with previously unsupported data types during resolution. *

- * The retrieved minimum version is passed on to the listener. - *

* If a field's type is not supported on the minimum version, it will be {@link DataType#UNSUPPORTED}. + *

+ * If the nodes are too old to include their minimum transport version in the field caps response, we'll assume + * {@link TransportVersion#minimumCompatible()}. + *

+ * Optionally, a {@code minimumVersion} can be passed in that will be used instead if it is lower than the transport + * version from the field caps response. It's meant to be the minimum version determined when resolving {@code FROM} + * and is required to correctly resolve {@code ENRICH} queries in CCS (enrich policies are resolved locally and thus + * might have a higher transport version in their field caps response than when resolving the main indices in {@code FROM}). + * In case of {@code ROW}, it's also okay to pass in the version from the main index resolution; that will be the coordinator + * version, which cannot be higher than the minimum version from the field caps response. + *

+ * The overall minimum version which is used to determine data type support is passed on to the listener. */ public void resolveAsMergedMapping( - String indexPattern, - Set fieldNames, - QueryBuilder requestFilter, - boolean includeAllDimensions, - boolean useAggregateMetricDoubleWhenNotSupported, - boolean useDenseVectorWhenNotSupported, - ActionListener> listener - ) { - resolveAsMergedMapping( - indexPattern, - fieldNames, - requestFilter, - includeAllDimensions, - useAggregateMetricDoubleWhenNotSupported, - useDenseVectorWhenNotSupported, - null, - listener - ); - } - - private void resolveAsMergedMapping( String indexPattern, Set fieldNames, QueryBuilder requestFilter, @@ -157,9 +116,15 @@ private void resolveAsMergedMapping( EsqlResolveFieldsAction.TYPE, createFieldCapsRequest(indexPattern, fieldNames, requestFilter, includeAllDimensions), listener.delegateFailureAndWrap((l, response) -> { + TransportVersion responseMinimumVersion = response.caps().minTransportVersion(); + // If we don't know the overall minimum version, it stays null to avoid faking knowledge we don't have. + TransportVersion overallMinimumVersion = responseMinimumVersion == null ? null + : minimumVersion == null ? responseMinimumVersion + : TransportVersion.min(minimumVersion, responseMinimumVersion); + FieldsInfo info = new FieldsInfo( response.caps(), - minimumVersion == null ? response.caps().minTransportVersion() : minimumVersion, + overallMinimumVersion, Build.current().isSnapshot(), useAggregateMetricDoubleWhenNotSupported, useDenseVectorWhenNotSupported From f7939c0f57b65bfb321314a02d46c36af7a5efe6 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 14:17:05 +0100 Subject: [PATCH 17/50] Fix serialization exception --- .../org/elasticsearch/xpack/esql/core/type/DataType.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/type/DataType.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/type/DataType.java index 8270605b97ca6..8055bcdee0bc5 100644 --- a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/type/DataType.java +++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/type/DataType.java @@ -15,6 +15,7 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper; +import org.elasticsearch.xpack.esql.core.QlIllegalArgumentException; import org.elasticsearch.xpack.esql.core.util.PlanStreamInput; import org.elasticsearch.xpack.esql.core.util.PlanStreamOutput; @@ -759,13 +760,9 @@ public DataType counter() { public void writeTo(StreamOutput out) throws IOException { if (supportedVersion.supportedOn(out.getTransportVersion(), Build.current().isSnapshot()) == false) { /* - * TODO when we implement version aware planning flip this to an IllegalStateException - * so we throw a 500 error. It'll be our bug then. Right now it's a sign that the user - * tried to do something like `KNN(dense_vector_field, [1, 2])` against an old node. - * Like, during the rolling upgrade that enables KNN or to a remote cluster that has - * not yet been upgraded. + * Throw a 500 error - this is a bug, we failed to account for an old node during planning. */ - throw new IllegalArgumentException( + throw new QlIllegalArgumentException( "remote node at version [" + out.getTransportVersion() + "] doesn't understand data type [" + this + "]" ); } From bb1d7a18aefe8104342be754d9609d5e5967f989 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 14:17:58 +0100 Subject: [PATCH 18/50] Finish test for cluster-local LOOKUP JOIN --- .../xpack/esql/ccq/AllSupportedFieldsIT.java | 2 - .../qa/single_node/AllSupportedFieldsIT.java | 19 -- .../qa/rest/AllSupportedFieldsTestCase.java | 293 +++++++++++++----- 3 files changed, 216 insertions(+), 98 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java index 460b864ab354c..a8ade7d2077dc 100644 --- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java @@ -31,8 +31,6 @@ /** * Fetch all field types via cross cluster search, possible on a different version. */ -// TODO: ROW + remote ENRICH -// TODO: remote lookups, requires `FROM remote:*` @ThreadLeakFilters(filters = TestClustersThreadFilter.class) public class AllSupportedFieldsIT extends AllSupportedFieldsTestCase { static ElasticsearchCluster remoteCluster = Clusters.remoteCluster(); diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java index 2b119c4bcdea3..aaa2be1750be4 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/AllSupportedFieldsIT.java @@ -9,8 +9,6 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; -import org.elasticsearch.TransportVersion; -import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; import org.elasticsearch.test.TestClustersThreadFilter; @@ -18,9 +16,6 @@ import org.elasticsearch.xpack.esql.qa.rest.AllSupportedFieldsTestCase; import org.junit.ClassRule; -import java.io.IOException; -import java.util.Map; - /** * Simple test for fetching all supported field types. */ @@ -37,18 +32,4 @@ public AllSupportedFieldsIT(MappedFieldType.FieldExtractPreference extractPrefer protected String getTestRestCluster() { return cluster.getHttpAddresses(); } - - @Override - @SuppressWarnings("unchecked") - protected void assertMinimumVersionFromAllQueries(Tuple, TransportVersion> responseAndCoordinatorVersion) - throws IOException { - var responseMap = responseAndCoordinatorVersion.v1(); - - Map profile = (Map) responseMap.get("profile"); - Integer minimumVersion = (Integer) profile.get("minimumVersion"); - assertNotNull(minimumVersion); - int minVersionInt = minimumVersion; - assertEquals(minVersion().id(), minVersionInt); - assertEquals(TransportVersion.current().id(), minVersionInt); - } } diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 9824a5639b280..5cfd068e08402 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -116,7 +116,7 @@ protected record NodeInfo( private static Map nodeToInfo; - private Map nodeToInfo() throws IOException { + private Map localNodeToInfo() throws IOException { if (nodeToInfo == null) { nodeToInfo = fetchNodeToInfo(client(), null); } @@ -164,7 +164,7 @@ protected boolean supportsNodeAssignment() throws IOException { * Map from node name to information about the node. */ protected Map allNodeToInfo() throws IOException { - return nodeToInfo(); + return localNodeToInfo(); } protected static Map fetchNodeToInfo(RestClient client, String cluster) throws IOException { @@ -211,14 +211,11 @@ protected static Map fetchNodeToInfo(RestClient client, String @Before public void createIndices() throws IOException { if (supportsNodeAssignment()) { - for (Map.Entry e : nodeToInfo().entrySet()) { + for (Map.Entry e : localNodeToInfo().entrySet()) { createIndexForNode(client(), e.getKey(), e.getValue().id(), indexMode); - // Always create the lookup index so we can test LOOKUP JOIN queries with it - createIndexForNode(client(), e.getKey(), e.getValue().id(), IndexMode.LOOKUP); } } else { createIndexForNode(client(), null, null, indexMode); - createIndexForNode(client(), null, null, IndexMode.LOOKUP); } } @@ -238,48 +235,95 @@ public void skipSnapshots() throws IOException { } } - // TODO: Also add a test for _tsid once we can determine the minimum transport version of all nodes. public final void testFetchAll() throws IOException { var responseAndCoordinatorVersion = runFromAllQuery(""" , _id, _ignored, _index_mode, _score, _source, _version | LIMIT 1000 """); + Map response = responseAndCoordinatorVersion.v1(); TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - if ((Boolean) response.get("is_partial")) { - throw new AssertionError("partial results: " + response); - } + + assertNoPartialResponse(response); + List columns = (List) response.get("columns"); List values = (List) response.get("values"); - MapMatcher expectedColumns = matchesMap(); - for (DataType type : DataType.values()) { - if (supportedInIndex(type) == false) { - continue; - } - expectedColumns = expectedColumns.entry(fieldName(type), expectedType(type, coordinatorVersion)); - } - expectedColumns = expectedColumns.entry("_id", "keyword") - .entry("_ignored", "keyword") - .entry("_index", "keyword") - .entry("_index_mode", "keyword") - .entry("_score", "double") - .entry("_source", "_source") - .entry("_version", "long") - .entry(LOOKUP_ID_FIELD, "integer"); + MapMatcher expectedColumns = allTypesColumnsMatcher(coordinatorVersion, minVersion(), indexMode, extractPreference, true); assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); for (Map.Entry e : expectedIndices(indexMode).entrySet()) { String indexName = e.getKey(); - MapMatcher expectedValues = matchesMap(); - expectedValues = expectedValues.entry(LOOKUP_ID_FIELD, equalTo(123)); - for (DataType type : DataType.values()) { - if (supportedInIndex(type) == false) { - continue; - } - expectedValues = expectedValues.entry(fieldName(type), expectedValue(type, coordinatorVersion)); + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + minVersion(), + indexMode, + extractPreference, + true, + indexName + ); + expectedAllValues = expectedAllValues.entry(indexName, expectedValues); + } + assertMap(indexToRow(columns, values), expectedAllValues); + + assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + + profileLogger.clearProfile(); + } + + protected static void assertNoPartialResponse(Map response) { + if ((Boolean) response.get("is_partial")) { + throw new AssertionError("partial results: " + response); + } + } + + protected static MapMatcher allTypesColumnsMatcher( + TransportVersion coordinatorVersion, + TransportVersion minimumVersion, + IndexMode indexMode, + MappedFieldType.FieldExtractPreference extractPreference, + boolean expectMetadataFields + ) { + MapMatcher expectedColumns = matchesMap().entry(LOOKUP_ID_FIELD, "integer"); + for (DataType type : DataType.values()) { + if (supportedInIndex(type) == false) { + continue; + } + expectedColumns = expectedColumns.entry(fieldName(type), expectedType(type, coordinatorVersion, minimumVersion, indexMode)); + } + if (expectMetadataFields) { + expectedColumns = expectedColumns.entry("_id", "keyword") + .entry("_ignored", "keyword") + .entry("_index", "keyword") + .entry("_index_mode", "keyword") + .entry("_score", "double") + .entry("_source", "_source") + .entry("_version", "long"); + } + return expectedColumns; + } + + protected static MapMatcher allTypesValuesMatcher( + TransportVersion coordinatorVersion, + TransportVersion minimumVersion, + IndexMode indexMode, + MappedFieldType.FieldExtractPreference extractPreference, + boolean expectMetadataFields, + String indexName + ) { + MapMatcher expectedValues = matchesMap(); + expectedValues = expectedValues.entry(LOOKUP_ID_FIELD, equalTo(123)); + for (DataType type : DataType.values()) { + if (supportedInIndex(type) == false) { + continue; } + expectedValues = expectedValues.entry( + fieldName(type), + expectedValue(type, coordinatorVersion, minimumVersion, indexMode, extractPreference) + ); + } + if (expectMetadataFields) { expectedValues = expectedValues.entry("_id", any(String.class)) .entry("_ignored", nullValue()) .entry("_index", indexName) @@ -287,10 +331,50 @@ public final void testFetchAll() throws IOException { .entry("_score", 0.0) .entry("_source", matchesMap().extraOk()) .entry("_version", 1); + } + + return expectedValues; + } + + protected static void assertFetchAllResponse( + Tuple, TransportVersion> responseAndCoordinatorVersion, + Map expectedIndices, + TransportVersion minimumVersion, + IndexMode indexMode, + MappedFieldType.FieldExtractPreference extractPreference, + boolean expectMetadataFields + ) { + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); + + assertNoPartialResponse(response); + + List columns = (List) response.get("columns"); + List values = (List) response.get("values"); + + MapMatcher expectedColumns = allTypesColumnsMatcher( + coordinatorVersion, + minimumVersion, + indexMode, + extractPreference, + expectMetadataFields + ); + assertMap(nameToType(columns), expectedColumns); + + MapMatcher expectedAllValues = matchesMap(); + for (Map.Entry e : expectedIndices.entrySet()) { + String indexName = e.getKey(); + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + minimumVersion, + indexMode, + extractPreference, + expectMetadataFields, + indexName + ); expectedAllValues = expectedAllValues.entry(indexName, expectedValues); } assertMap(indexToRow(columns, values), expectedAllValues); - profileLogger.clearProfile(); } /** @@ -309,6 +393,9 @@ public final void testFetchDenseVector() throws IOException { | EVAL k = v_l2_norm(f_dense_vector, [1]) // workaround to enable fetching dense_vector """ + request; } + var responseAndCoordinatorVersion = runFromAllQuery(request); + assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + response = runFromAllQuery(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); @@ -376,6 +463,9 @@ public final void testFetchAggregateMetricDouble() throws IOException { | EVAL junk = TO_AGGREGATE_METRIC_DOUBLE(1) // workaround to enable fetching aggregate_metric_double """ + request; } + var responseAndCoordinatorVersion = runFromAllQuery(request); + assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + response = runFromAllQuery(request).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); @@ -432,7 +522,6 @@ private Tuple, TransportVersion> runFromAllQuery(String rest var responseAndCoordinatorVersion = runQuery( "FROM *:%mode%*,%mode%* METADATA _index".replace("%mode%", indexMode.toString()) + restOfQuery ); - assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); return responseAndCoordinatorVersion; } @@ -443,38 +532,50 @@ public void testRow() throws IOException { ); String query = "ROW x = 1 | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - var responseMap = responseAndCoordinatorVersion.v1(); var coordinatorVersion = responseAndCoordinatorVersion.v2(); - if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { - @SuppressWarnings("unchecked") - Map profile = (Map) responseMap.get("profile"); - Integer minimumVersion = (Integer) profile.get("minimumVersion"); - assertNotNull(minimumVersion); - // For ROW commands without commands that reach out to other nodes, the minimum version is given by the coordinator. - assertEquals(coordinatorVersion.id(), minimumVersion.intValue()); - } + assertMinimumVersion(coordinatorVersion, responseAndCoordinatorVersion); } // TODO: ROW + local ENRICH + @SuppressWarnings("unchecked") public void testRowLookupJoin() throws IOException { - assumeTrue( - "Test has to run only once, skip on other configurations", - extractPreference == MappedFieldType.FieldExtractPreference.NONE && indexMode == IndexMode.STANDARD - ); - // TODO: For ccq, we most likely need to strip the remote lookup indices. And also use the local minimum transport version, only. - Map expectedIndices = expectedIndices(IndexMode.LOOKUP); + assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); + Map expectedIndices = expectedIndices(IndexMode.LOOKUP, true); for (Map.Entry e : expectedIndices.entrySet()) { String indexName = e.getKey(); - String query = "ROW " + LOOKUP_ID_FIELD + " = 1234 | LOOKUP JOIN " + indexName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; + String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | LOOKUP JOIN " + indexName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - var responseMap = responseAndCoordinatorVersion.v1(); - var coordinatorVersion = responseAndCoordinatorVersion.v2(); + TransportVersion expectedMinimumVersion = minVersion(true); - // HERE next, this actually needs fixing in production code. - assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); + + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - // TODO: same assert as in testfetchall, I think. + assertNoPartialResponse(response); + + List columns = (List) response.get("columns"); + List values = (List) response.get("values"); + + MapMatcher expectedColumns = allTypesColumnsMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false + ); + assertMap(nameToType(columns), expectedColumns); + + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + null + ); + assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); } } @@ -511,20 +612,31 @@ private Tuple, TransportVersion> runQuery(String query) thro return new Tuple<>(responseMap, coordinatorVersion); } - @SuppressWarnings("unchecked") protected void assertMinimumVersionFromAllQueries(Tuple, TransportVersion> responseAndCoordinatorVersion) throws IOException { + assertMinimumVersion(minVersion(), responseAndCoordinatorVersion); + } + + /** + * @param expectedMinimumVersion the minimum version of all clusters that participate in the query, or the version of the coordinator + * if the query runs only on the coordinator + */ + @SuppressWarnings("unchecked") + protected void assertMinimumVersion( + TransportVersion expectedMinimumVersion, + Tuple, TransportVersion> responseAndCoordinatorVersion + ) { var responseMap = responseAndCoordinatorVersion.v1(); var coordinatorVersion = responseAndCoordinatorVersion.v2(); if (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { Map profile = (Map) responseMap.get("profile"); - Integer minimumVersion = (Integer) profile.get("minimumVersion"); + Integer minimumVersion = (Integer) profile.get("minimumTransportVersion"); assertNotNull(minimumVersion); - if (minVersion().supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV)) { + if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV)) { // All nodes are new enough that their field caps responses should contain the minimum transport version // of matching clusters. - assertEquals(minVersion().id(), minimumVersion.intValue()); + assertEquals(expectedMinimumVersion.id(), minimumVersion.intValue()); } else { // One node is old enough that it doesn't provide version information in the field caps response. We must assume // the oldest compatible version. @@ -653,8 +765,17 @@ private static void createAllTypesDoc(RestClient client, String indexName) throw client.performRequest(request); } - // This will become dependent on the minimum transport version of all nodes once we can determine that. private Matcher expectedValue(DataType type, TransportVersion coordinatorVersion) throws IOException { + return expectedValue(type, coordinatorVersion, minVersion(), indexMode, extractPreference); + } + + private static Matcher expectedValue( + DataType type, + TransportVersion coordinatorVersion, + TransportVersion minimumVersion, + IndexMode indexMode, + MappedFieldType.FieldExtractPreference extractPreference + ) { return switch (type) { case BOOLEAN -> equalTo(true); case COUNTER_LONG, LONG, COUNTER_INTEGER, INTEGER, UNSIGNED_LONG, SHORT, BYTE -> equalTo(1); @@ -667,7 +788,7 @@ private Matcher expectedValue(DataType type, TransportVersion coordinatorVers case DATETIME, DATE_NANOS -> equalTo("2025-01-01T01:00:00.000Z"); case IP -> equalTo("192.168.0.1"); case VERSION -> equalTo("1.0.0-SNAPSHOT"); - case GEO_POINT -> extractPreference == MappedFieldType.FieldExtractPreference.DOC_VALUES || syntheticSourceByDefault() + case GEO_POINT -> extractPreference == MappedFieldType.FieldExtractPreference.DOC_VALUES || syntheticSourceByDefault(indexMode) ? equalTo("POINT (-71.34000004269183 41.1199999647215)") : equalTo("POINT (-71.34 41.12)"); case GEO_SHAPE -> equalTo("POINT (-71.34 41.12)"); @@ -675,7 +796,7 @@ private Matcher expectedValue(DataType type, TransportVersion coordinatorVers case AGGREGATE_METRIC_DOUBLE -> { // See expectedType for an explanation if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false - || minVersion().supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { + || minimumVersion.supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { yield nullValue(); } yield equalTo("{\"min\":-302.5,\"max\":702.3,\"sum\":200.0,\"value_count\":25}"); @@ -683,7 +804,7 @@ private Matcher expectedValue(DataType type, TransportVersion coordinatorVers case DENSE_VECTOR -> { // See expectedType for an explanation if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false - || minVersion().supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { + || minimumVersion.supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { yield nullValue(); } yield equalTo(List.of(0.5, 10.0, 5.9999995)); @@ -713,7 +834,7 @@ private static boolean supportedInIndex(DataType t) { }; } - private Map nameToType(List columns) { + private static Map nameToType(List columns) { Map result = new TreeMap<>(); for (Object c : columns) { Map map = (Map) c; @@ -722,7 +843,7 @@ private Map nameToType(List columns) { return result; } - private List names(List columns) { + private static List names(List columns) { List result = new ArrayList<>(); for (Object c : columns) { Map map = (Map) c; @@ -731,21 +852,21 @@ private List names(List columns) { return result; } - private Map> indexToRow(List columns, List values) { + private static Map> indexToRow(List columns, List values) { List names = names(columns); - int timestampIdx = names.indexOf("_index"); - if (timestampIdx < 0) { + int indexNameIdx = names.indexOf("_index"); + if (indexNameIdx < 0) { throw new IllegalStateException("query didn't return _index"); } Map> result = new TreeMap<>(); for (Object r : values) { List row = (List) r; - result.put(row.get(timestampIdx).toString(), nameToValue(names, row)); + result.put(row.get(indexNameIdx).toString(), nameToValue(names, row)); } return result; } - private Map nameToValue(List names, List values) { + private static Map nameToValue(List names, List values) { Map result = new TreeMap<>(); for (int i = 0; i < values.size(); i++) { result.put(names.get(i), values.get(i)); @@ -753,8 +874,16 @@ private Map nameToValue(List names, List values) { return result; } - // This will become dependent on the minimum transport version of all nodes once we can determine that. private Matcher expectedType(DataType type, TransportVersion coordinatorVersion) throws IOException { + return expectedType(type, coordinatorVersion, minVersion(), indexMode); + } + + private static Matcher expectedType( + DataType type, + TransportVersion coordinatorVersion, + TransportVersion minimumVersion, + IndexMode indexMode + ) { return switch (type) { case COUNTER_DOUBLE, COUNTER_LONG, COUNTER_INTEGER -> { if (indexMode == IndexMode.TIME_SERIES) { @@ -774,7 +903,7 @@ private Matcher expectedType(DataType type, TransportVersion coordinator // started to be able to plan for this data type, and will consider it supported if // all nodes are on ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION or newer. if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false - || minVersion().supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { + || minimumVersion.supports(ESQL_AGGREGATE_METRIC_DOUBLE_CREATED_VERSION) == false) { yield equalTo("unsupported"); } yield equalTo("aggregate_metric_double"); @@ -782,7 +911,7 @@ private Matcher expectedType(DataType type, TransportVersion coordinator case DENSE_VECTOR -> { // Same dance as for AGGREGATE_METRIC_DOUBLE if (coordinatorVersion.supports(RESOLVE_FIELDS_RESPONSE_USED_TV) == false - || minVersion().supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { + || minimumVersion.supports(ESQL_DENSE_VECTOR_CREATED_VERSION) == false) { yield equalTo("unsupported"); } yield equalTo("dense_vector"); @@ -796,7 +925,7 @@ protected boolean preserveClusterUponCompletion() { return true; } - private boolean syntheticSourceByDefault() { + private static boolean syntheticSourceByDefault(IndexMode indexMode) { return switch (indexMode) { case TIME_SERIES, LOGSDB -> true; case STANDARD, LOOKUP -> false; @@ -804,9 +933,14 @@ private boolean syntheticSourceByDefault() { } private Map expectedIndices(IndexMode indexMode) throws IOException { + return expectedIndices(indexMode, false); + } + + private Map expectedIndices(IndexMode indexMode, boolean onlyLocalCluster) throws IOException { + Map nodeToInfo = onlyLocalCluster ? localNodeToInfo() : allNodeToInfo(); Map result = new TreeMap<>(); if (supportsNodeAssignment()) { - for (Map.Entry e : allNodeToInfo().entrySet()) { + for (Map.Entry e : nodeToInfo.entrySet()) { String name = indexName(indexMode, e.getKey()); if (e.getValue().cluster != null) { name = e.getValue().cluster + ":" + name; @@ -814,7 +948,7 @@ private Map expectedIndices(IndexMode indexMode) throws IOExce result.put(name, e.getValue()); } } else { - for (Map.Entry e : allNodeToInfo().entrySet()) { + for (Map.Entry e : nodeToInfo.entrySet()) { String name = indexName(indexMode, null); if (e.getValue().cluster != null) { name = e.getValue().cluster + ":" + name; @@ -837,6 +971,11 @@ private Map expectedIndices(IndexMode indexMode) throws IOExce } protected TransportVersion minVersion() throws IOException { - return allNodeToInfo().values().stream().map(NodeInfo::version).min(Comparator.naturalOrder()).get(); + return minVersion(false); + } + + protected TransportVersion minVersion(boolean onlyLocalCluster) throws IOException { + Map nodeToInfo = onlyLocalCluster ? localNodeToInfo() : allNodeToInfo(); + return nodeToInfo.values().stream().map(NodeInfo::version).min(Comparator.naturalOrder()).get(); } } From c78dbd9c4bc7b705239394031b474131097419c3 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 14:18:14 +0100 Subject: [PATCH 19/50] Rename minimumVersion->minimumTransportVersion in profile --- .../org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java index 7c0ffd65fa7bb..a233430a08d64 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java @@ -278,7 +278,7 @@ public Iterator toXContentChunked(ToXContent.Params params content.add(ChunkedToXContentHelper.array("drivers", profile.drivers.iterator(), params)); content.add(ChunkedToXContentHelper.array("plans", profile.plans.iterator())); content.add(ChunkedToXContentHelper.chunk((b, p) -> { - b.field("minimumVersion", profile.minimumVersion().id()); + b.field("minimumTransportVersion", profile.minimumVersion().id()); return b; })); content.add(ChunkedToXContentHelper.endObject()); From 73b05d97540c6d2f240d163e02a38c4f6f787085 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 18:21:27 +0100 Subject: [PATCH 20/50] Add test with ROW | ENRICH --- .../qa/rest/AllSupportedFieldsTestCase.java | 151 ++++++++++++------ 1 file changed, 105 insertions(+), 46 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 5cfd068e08402..ceb1f7a64c9aa 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -249,7 +249,7 @@ public final void testFetchAll() throws IOException { List columns = (List) response.get("columns"); List values = (List) response.get("values"); - MapMatcher expectedColumns = allTypesColumnsMatcher(coordinatorVersion, minVersion(), indexMode, extractPreference, true); + MapMatcher expectedColumns = allTypesColumnsMatcher(coordinatorVersion, minVersion(), indexMode, extractPreference, true, true); assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); @@ -261,6 +261,7 @@ public final void testFetchAll() throws IOException { indexMode, extractPreference, true, + true, indexName ); expectedAllValues = expectedAllValues.entry(indexName, expectedValues); @@ -283,13 +284,17 @@ protected static MapMatcher allTypesColumnsMatcher( TransportVersion minimumVersion, IndexMode indexMode, MappedFieldType.FieldExtractPreference extractPreference, - boolean expectMetadataFields + boolean expectMetadataFields, + boolean expectNonEnrichableFields ) { MapMatcher expectedColumns = matchesMap().entry(LOOKUP_ID_FIELD, "integer"); for (DataType type : DataType.values()) { if (supportedInIndex(type) == false) { continue; } + if (expectNonEnrichableFields == false && supportedInEnrich(type) == false) { + continue; + } expectedColumns = expectedColumns.entry(fieldName(type), expectedType(type, coordinatorVersion, minimumVersion, indexMode)); } if (expectMetadataFields) { @@ -310,6 +315,7 @@ protected static MapMatcher allTypesValuesMatcher( IndexMode indexMode, MappedFieldType.FieldExtractPreference extractPreference, boolean expectMetadataFields, + boolean expectNonEnrichableFields, String indexName ) { MapMatcher expectedValues = matchesMap(); @@ -318,6 +324,9 @@ protected static MapMatcher allTypesValuesMatcher( if (supportedInIndex(type) == false) { continue; } + if (expectNonEnrichableFields == false && supportedInEnrich(type) == false) { + continue; + } expectedValues = expectedValues.entry( fieldName(type), expectedValue(type, coordinatorVersion, minimumVersion, indexMode, extractPreference) @@ -336,47 +345,6 @@ protected static MapMatcher allTypesValuesMatcher( return expectedValues; } - protected static void assertFetchAllResponse( - Tuple, TransportVersion> responseAndCoordinatorVersion, - Map expectedIndices, - TransportVersion minimumVersion, - IndexMode indexMode, - MappedFieldType.FieldExtractPreference extractPreference, - boolean expectMetadataFields - ) { - Map response = responseAndCoordinatorVersion.v1(); - TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - - assertNoPartialResponse(response); - - List columns = (List) response.get("columns"); - List values = (List) response.get("values"); - - MapMatcher expectedColumns = allTypesColumnsMatcher( - coordinatorVersion, - minimumVersion, - indexMode, - extractPreference, - expectMetadataFields - ); - assertMap(nameToType(columns), expectedColumns); - - MapMatcher expectedAllValues = matchesMap(); - for (Map.Entry e : expectedIndices.entrySet()) { - String indexName = e.getKey(); - MapMatcher expectedValues = allTypesValuesMatcher( - coordinatorVersion, - minimumVersion, - indexMode, - extractPreference, - expectMetadataFields, - indexName - ); - expectedAllValues = expectedAllValues.entry(indexName, expectedValues); - } - assertMap(indexToRow(columns, values), expectedAllValues); - } - /** * Tests fetching {@code dense_vector} if possible. Uses the {@code dense_vector_agg_metric_double_if_fns} * work around if required. @@ -537,7 +505,6 @@ public void testRow() throws IOException { assertMinimumVersion(coordinatorVersion, responseAndCoordinatorVersion); } - // TODO: ROW + local ENRICH @SuppressWarnings("unchecked") public void testRowLookupJoin() throws IOException { assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); @@ -563,6 +530,50 @@ public void testRowLookupJoin() throws IOException { expectedMinimumVersion, indexMode, extractPreference, + false, + true + ); + assertMap(nameToType(columns), expectedColumns); + + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + true, + null + ); + assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); + } + } + + @SuppressWarnings("unchecked") + public void testRowEnrich() throws IOException { + assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); + Map expectedIndices = expectedIndices(IndexMode.LOOKUP, true); + for (Map.Entry e : expectedIndices.entrySet()) { + String policyName = e.getKey() + "_policy"; + String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | ENRICH " + policyName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; + var responseAndCoordinatorVersion = runQuery(query); + TransportVersion expectedMinimumVersion = minVersion(true); + + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); + + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); + + assertNoPartialResponse(response); + + List columns = (List) response.get("columns"); + List values = (List) response.get("values"); + + MapMatcher expectedColumns = allTypesColumnsMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, false ); assertMap(nameToType(columns), expectedColumns); @@ -573,6 +584,7 @@ public void testRowLookupJoin() throws IOException { indexMode, extractPreference, false, + false, null ); assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); @@ -653,6 +665,12 @@ protected static void createIndexForNode(RestClient client, String nodeName, Str if (false == indexExists(client, indexName)) { createAllTypesIndex(client, indexName, nodeId, mode); createAllTypesDoc(client, indexName); + // We create an enrich policy for each lookup index. That's a bit of an arbitrary choice, but it's probably a good idea to + // create 1 enrich policy per node, so we potentially detect misbehavior that stems from enrich policies being based on indices + // that live on newer or older nodes. + if (mode == IndexMode.LOOKUP) { + createEnrichPolicy(client, indexName); + } } } @@ -765,8 +783,35 @@ private static void createAllTypesDoc(RestClient client, String indexName) throw client.performRequest(request); } - private Matcher expectedValue(DataType type, TransportVersion coordinatorVersion) throws IOException { - return expectedValue(type, coordinatorVersion, minVersion(), indexMode, extractPreference); + private static void createEnrichPolicy(RestClient client, String indexName) throws IOException { + String policyName = indexName + "_policy"; + + XContentBuilder policyConfig = JsonXContent.contentBuilder().startObject(); + { + policyConfig.startObject("match"); + + policyConfig.field("indices", indexName); + policyConfig.field("match_field", LOOKUP_ID_FIELD); + List enrichFields = new ArrayList<>(); + for (DataType type : DataType.values()) { + if (supportedInIndex(type) == false || supportedInEnrich(type) == false) { + continue; + } + enrichFields.add(fieldName(type)); + } + policyConfig.field("enrich_fields", enrichFields); + + policyConfig.endObject(); + } + policyConfig.endObject(); + + Request request = new Request("PUT", "_enrich/policy/" + policyName); + request.setJsonEntity(Strings.toString(policyConfig)); + client.performRequest(request); + + Request execute = new Request("PUT", "_enrich/policy/" + policyName + "/_execute"); + request.addParameter("wait_for_completion", "true"); + client.performRequest(execute); } private static Matcher expectedValue( @@ -834,6 +879,20 @@ private static boolean supportedInIndex(DataType t) { }; } + /** + * Is the type supported in enrich policies? + */ + private static boolean supportedInEnrich(DataType t) { + return switch (t) { + // Enrich policies don't work with types that have mandatory fields in the mapping. + // https://github.com/elastic/elasticsearch/issues/127350 + case AGGREGATE_METRIC_DOUBLE, SCALED_FLOAT, + // https://github.com/elastic/elasticsearch/issues/137699 + DENSE_VECTOR -> false; + default -> true; + }; + } + private static Map nameToType(List columns) { Map result = new TreeMap<>(); for (Object c : columns) { From 3afd809753b71c2ecd8894775e7e2b1a6dd21a86 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 21:43:43 +0100 Subject: [PATCH 21/50] Fix + test ROW | ENRICH --- .../qa/rest/AllSupportedFieldsTestCase.java | 20 +++++-- .../esql/enrich/EnrichPolicyResolver.java | 59 ++++++++++++++++--- .../xpack/esql/session/EsqlSession.java | 9 ++- .../xpack/esql/session/IndexResolver.java | 8 ++- .../enrich/EnrichPolicyResolverTests.java | 6 +- .../telemetry/PlanExecutorMetricsTests.java | 6 +- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index ceb1f7a64c9aa..b6547a4ca6b0d 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -556,12 +556,19 @@ public void testRowEnrich() throws IOException { String policyName = e.getKey() + "_policy"; String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | ENRICH " + policyName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - TransportVersion expectedMinimumVersion = minVersion(true); - - assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); - Map response = responseAndCoordinatorVersion.v1(); TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); + TransportVersion expectedMinimumVersion = minVersion(true); + + Map profile = (Map) response.get("profile"); + Integer actualMinimumVersion = (Integer) profile.get("minimumTransportVersion"); + if (minVersion(true).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) + // Some nodes don't send back the minimum transport version because they're too old to do that. + // In this case, the determined minimum version will be that of the coordinator. + || (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) + && actualMinimumVersion != coordinatorVersion.id())) { + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); + } assertNoPartialResponse(response); @@ -645,17 +652,18 @@ protected void assertMinimumVersion( Map profile = (Map) responseMap.get("profile"); Integer minimumVersion = (Integer) profile.get("minimumTransportVersion"); assertNotNull(minimumVersion); + int minimumVersionInt = minimumVersion; if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV)) { // All nodes are new enough that their field caps responses should contain the minimum transport version // of matching clusters. - assertEquals(expectedMinimumVersion.id(), minimumVersion.intValue()); + assertEquals(expectedMinimumVersion.id(), minimumVersionInt); } else { // One node is old enough that it doesn't provide version information in the field caps response. We must assume // the oldest compatible version. // Since we got minimumVersion in the profile, the coordinator is on a new version. // Thus, it's on the same version as this code (bwc tests only use 2 different versions) and the oldest compatible version // to the coordinator is given by TransportVersion.minimumCompatible(). - assertEquals(TransportVersion.minimumCompatible().id(), minimumVersion.intValue()); + assertEquals(TransportVersion.minimumCompatible().id(), minimumVersionInt); } } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index c57793437ac95..5afe2ac9f40fc 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -42,6 +42,7 @@ import org.elasticsearch.xpack.esql.analysis.EnrichResolution; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.EsField; +import org.elasticsearch.xpack.esql.core.util.Holder; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.index.EsIndex; import org.elasticsearch.xpack.esql.index.IndexResolution; @@ -49,6 +50,7 @@ import org.elasticsearch.xpack.esql.io.stream.PlanStreamOutput; import org.elasticsearch.xpack.esql.plan.logical.Enrich; import org.elasticsearch.xpack.esql.session.IndexResolver; +import org.elasticsearch.xpack.esql.session.Versioned; import java.io.IOException; import java.util.ArrayList; @@ -77,7 +79,6 @@ public class EnrichPolicyResolver { private static final String RESOLVE_ACTION_NAME = "cluster:monitor/xpack/enrich/esql/resolve_policy"; - // NOCOMMIT: rename to something that represents the overall change public static final TransportVersion ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION = TransportVersion.fromName( "esql_use_minimum_version_for_enrich_resolution" ); @@ -128,10 +129,10 @@ public void resolvePolicies( List enriches, EsqlExecutionInfo executionInfo, TransportVersion minimumVersion, - ActionListener listener + ActionListener> listener ) { if (enriches.isEmpty()) { - listener.onResponse(new EnrichResolution()); + listener.onResponse(new Versioned<>(new EnrichResolution(), minimumVersion)); return; } @@ -149,10 +150,10 @@ protected void doResolvePolicies( Collection unresolvedPolicies, EsqlExecutionInfo executionInfo, TransportVersion minimumVersion, - ActionListener listener + ActionListener> listener ) { if (unresolvedPolicies.isEmpty()) { - listener.onResponse(new EnrichResolution()); + listener.onResponse(new Versioned<>(new EnrichResolution(), minimumVersion)); return; } @@ -178,6 +179,14 @@ protected void doResolvePolicies( } } + // Propagate the minimum version observed during policy resolution back to the planning pipeline. + // This is only really required for `ROW | ENRICH` queries, where the main index resolution doesn't + // provide the minimum version of the local cluster. + TransportVersion updatedMinimumVersion = minimumVersion; + for (LookupResponse response : lookupResponsesToProcess.values()) { + updatedMinimumVersion = TransportVersion.min(updatedMinimumVersion, response.minimumVersion); + } + for (UnresolvedPolicy unresolved : unresolvedPolicies) { Tuple resolved = mergeLookupResults( unresolved, @@ -192,7 +201,7 @@ protected void doResolvePolicies( enrichResolution.addError(unresolved.name, unresolved.mode, resolved.v2()); } } - return enrichResolution; + return new Versioned<>(enrichResolution, updatedMinimumVersion); })); } @@ -404,6 +413,10 @@ private static class LookupRequest extends AbstractTransportRequest { if (in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { this.minimumVersion = TransportVersion.readVersion(in); } else { + // An older coordinator contacted us. Let's assume an old version, otherwise we might send back + // types that it can't deserialize. + // (The only version that knows some new types but doesn't send its transport version here is 9.2.0; + // these types are dense_vector and aggregate_metric_double, and both don't work with ENRICH in 9.2.0, anyway.) this.minimumVersion = TransportVersion.minimumCompatible(); } } @@ -421,12 +434,15 @@ public void writeTo(StreamOutput out) throws IOException { private static class LookupResponse extends TransportResponse { final Map policies; final Map failures; + // The minimum transport version observed when running field caps requests to resolve the policies + final TransportVersion minimumVersion; // does not need to be Writable since this indicates a failure to contact a remote cluster, so only set on querying cluster final transient Exception connectionError; - LookupResponse(Map policies, Map failures) { + LookupResponse(Map policies, Map failures, TransportVersion minimumVersion) { this.policies = policies; this.failures = failures; + this.minimumVersion = minimumVersion; this.connectionError = null; } @@ -438,6 +454,7 @@ private static class LookupResponse extends TransportResponse { LookupResponse(Exception connectionError) { this.policies = Collections.emptyMap(); this.failures = Collections.emptyMap(); + this.minimumVersion = TransportVersion.current(); this.connectionError = connectionError; } @@ -445,6 +462,19 @@ private static class LookupResponse extends TransportResponse { PlanStreamInput planIn = new PlanStreamInput(in, in.namedWriteableRegistry(), null); this.policies = planIn.readMap(StreamInput::readString, ResolvedEnrichPolicy::new); this.failures = planIn.readMap(StreamInput::readString, StreamInput::readString); + if (in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + this.minimumVersion = TransportVersion.readVersion(in); + } else { + // A pre-9.2.1 node resolved the enrich policy for us, but doesn't say which version its cluster is on. + // We can safely assume this node's current version, even though that's technically wrong. + // Assuming a version that's too old can disable aggregate_metric_double and dense_vector + // data types in the query, that'd be very bad. + // But assuming these types are supported is fine because in 9.2.0, + // they're not supported in enrich policies, anyway, due to bugs. + // https://github.com/elastic/elasticsearch/issues/127350 + // https://github.com/elastic/elasticsearch/issues/137699 + this.minimumVersion = TransportVersion.minimumCompatible(); + } this.connectionError = null; } @@ -453,6 +483,9 @@ public void writeTo(StreamOutput out) throws IOException { PlanStreamOutput pso = new PlanStreamOutput(out, null); pso.writeMap(policies, StreamOutput::writeWriteable); pso.writeMap(failures, StreamOutput::writeString); + if (out.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { + TransportVersion.writeVersion(minimumVersion, out); + } } } @@ -462,12 +495,17 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas final Map availablePolicies = availablePolicies(); final Map failures = ConcurrentCollections.newConcurrentMap(); final Map resolvedPolices = ConcurrentCollections.newConcurrentMap(); + // We use the coordinator's minimum version as base line. + final Holder minimumVersion = new Holder<>(request.minimumVersion); ThreadContext threadContext = threadPool.getThreadContext(); ActionListener listener = ContextPreservingActionListener.wrapPreservingContext( new ChannelActionListener<>(channel), threadContext ); - try (var refs = new RefCountingListener(listener.map(unused -> new LookupResponse(resolvedPolices, failures)))) { + try (var refs = new RefCountingListener(listener.map(unused -> { + TransportVersion finalMinimumVersion = minimumVersion.get(); + return new LookupResponse(resolvedPolices, failures, finalMinimumVersion); + }))) { for (String policyName : request.policyNames) { EnrichPolicy p = availablePolicies.get(policyName); if (p == null) { @@ -497,6 +535,11 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas esIndex.mapping() ); resolvedPolices.put(policyName, resolved); + synchronized (minimumVersion) { + minimumVersion.set( + TransportVersion.min(minimumVersion.get(), versionedIndexResult.minimumVersion()) + ); + } } else { failures.put(policyName, indexResult.toString()); } 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 11d28fb450dea..0d2b68e98d541 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 @@ -570,7 +570,10 @@ private void resolveIndicesAndAnalyze( preAnalysis.enriches(), executionInfo, r.minimumTransportVersion(), - l.map(r::withEnrichResolution) + l.map( + versionedResolution -> r.withEnrichResolution(versionedResolution.inner()) + .withMinimumTransportVersion(versionedResolution.minimumVersion()) + ) ); }) .andThen((l, r) -> { @@ -583,7 +586,9 @@ private void resolveIndicesAndAnalyze( } /** - * Perform a field caps request for each lookup index. Does not update the minimum transport version. + * Perform a field caps request for each lookup index. + *

+ * Updates the minimum transport version to deal with ROW queries, where the main index resolution does not make a field caps request. */ private void preAnalyzeLookupIndices( Iterator lookupIndices, 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 5246ef8a4157e..709b3569e224b 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 @@ -129,8 +129,12 @@ public void resolveAsMergedMapping( useAggregateMetricDoubleWhenNotSupported, useDenseVectorWhenNotSupported ); - // NOCOMMIT: Update logging here as well - LOGGER.debug("minimum transport version {} {}", response.caps().minTransportVersion(), info.effectiveMinTransportVersion()); + LOGGER.debug( + "updated minimum transport version from {} to effective version {} using version {} from field caps response", + minimumVersion, + info.effectiveMinTransportVersion(), + response.caps().minTransportVersion() + ); l.onResponse(new Versioned<>(mergedMappings(indexPattern, info), info.effectiveMinTransportVersion())); }) ); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java index 5b779681623b9..730b78bd2f9ca 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java @@ -46,6 +46,7 @@ import org.elasticsearch.xpack.esql.analysis.EnrichResolution; import org.elasticsearch.xpack.esql.plan.logical.Enrich; import org.elasticsearch.xpack.esql.session.IndexResolver; +import org.elasticsearch.xpack.esql.session.Versioned; import org.junit.After; import org.junit.Before; @@ -453,10 +454,9 @@ EnrichResolution resolvePolicies(Collection clusters, Collection future = new PlainActionFuture<>(); - // NOCOMMIT: We should pass in a sensible transport version in general and also test this with older ones. + PlainActionFuture> future = new PlainActionFuture<>(); super.doResolvePolicies(new HashSet<>(clusters), unresolvedPolicies, esqlExecutionInfo, TransportVersion.current(), future); - return future.actionGet(30, TimeUnit.SECONDS); + return future.actionGet(30, TimeUnit.SECONDS).inner(); } @Override diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java index 4727a77b49b40..a452995b343c9 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.esql.telemetry; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.fieldcaps.FieldCapabilities; @@ -82,8 +83,9 @@ EnrichPolicyResolver mockEnrichResolver() { EnrichPolicyResolver enrichResolver = mock(EnrichPolicyResolver.class); doAnswer(invocation -> { Object[] arguments = invocation.getArguments(); - ActionListener listener = (ActionListener) arguments[arguments.length - 1]; - listener.onResponse(new EnrichResolution()); + ActionListener> listener = (ActionListener>) arguments[arguments.length + - 1]; + listener.onResponse(new Versioned<>(new EnrichResolution(), TransportVersion.current())); return null; }).when(enrichResolver).resolvePolicies(any(), any(), any(), any()); return enrichResolver; From d08f4814cdd2b5f4ef93033c25eb19550fb147b7 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 21:48:38 +0100 Subject: [PATCH 22/50] Delete docs/changelog/137431.yaml --- docs/changelog/137431.yaml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 docs/changelog/137431.yaml diff --git a/docs/changelog/137431.yaml b/docs/changelog/137431.yaml deleted file mode 100644 index 451fc5eabc1a7..0000000000000 --- a/docs/changelog/137431.yaml +++ /dev/null @@ -1,5 +0,0 @@ -pr: 137431 -summary: Fix enrich and lookup join resolution based on min transport version -area: ES|QL -type: bug -issues: [] From 7402ff09c02287e7939302093c26f7609752718f Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 21:49:28 +0100 Subject: [PATCH 23/50] Update docs/changelog/137431.yaml --- docs/changelog/137431.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/137431.yaml diff --git a/docs/changelog/137431.yaml b/docs/changelog/137431.yaml new file mode 100644 index 0000000000000..451fc5eabc1a7 --- /dev/null +++ b/docs/changelog/137431.yaml @@ -0,0 +1,5 @@ +pr: 137431 +summary: Fix enrich and lookup join resolution based on min transport version +area: ES|QL +type: bug +issues: [] From 482404e78c7bcedab0039ce5631d9fa792371da7 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 21:51:36 +0100 Subject: [PATCH 24/50] Delete docs/changelog/137431.yaml --- docs/changelog/137431.yaml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 docs/changelog/137431.yaml diff --git a/docs/changelog/137431.yaml b/docs/changelog/137431.yaml deleted file mode 100644 index 451fc5eabc1a7..0000000000000 --- a/docs/changelog/137431.yaml +++ /dev/null @@ -1,5 +0,0 @@ -pr: 137431 -summary: Fix enrich and lookup join resolution based on min transport version -area: ES|QL -type: bug -issues: [] From 855fd381fa314e02c4181a0c180241a191932852 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 21:56:17 +0100 Subject: [PATCH 25/50] Update docs/changelog/137431.yaml --- docs/changelog/137431.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/137431.yaml diff --git a/docs/changelog/137431.yaml b/docs/changelog/137431.yaml new file mode 100644 index 0000000000000..451fc5eabc1a7 --- /dev/null +++ b/docs/changelog/137431.yaml @@ -0,0 +1,5 @@ +pr: 137431 +summary: Fix enrich and lookup join resolution based on min transport version +area: ES|QL +type: bug +issues: [] From 2b84f68b7fac06a4c71b3ae1026b6d228f12c165 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 22:08:04 +0100 Subject: [PATCH 26/50] Fix bug --- .../elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 5afe2ac9f40fc..573d55696ab9b 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -473,7 +473,7 @@ private static class LookupResponse extends TransportResponse { // they're not supported in enrich policies, anyway, due to bugs. // https://github.com/elastic/elasticsearch/issues/127350 // https://github.com/elastic/elasticsearch/issues/137699 - this.minimumVersion = TransportVersion.minimumCompatible(); + this.minimumVersion = TransportVersion.current(); } this.connectionError = null; } From cd8cb6578254acdff2f9cd5ddb8a061fae97ae92 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 22:08:07 +0100 Subject: [PATCH 27/50] Add comment --- .../java/org/elasticsearch/xpack/esql/session/EsqlSession.java | 3 +++ 1 file changed, 3 insertions(+) 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 0d2b68e98d541..34ca729c9dc29 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 @@ -621,6 +621,9 @@ private void preAnalyzeLookupIndex( // Disable aggregate_metric_double and dense_vector until we get version checks in planning false, false, + // We're just being consistent with the enrich policy resolution here; technically, we don't need to pass the main index + // resolution's minimum version, as the coordinator's just going to make another field caps request for the lookup resolution + // that comes with the minimum version in the response. result.minimumTransportVersion(), listener.map(indexResolution -> receiveLookupIndexResolution(result, localPattern, executionInfo, indexResolution)) ); From 15b607bb772d109ef4769cb8bb73f3ccc9ebf6e6 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Thu, 6 Nov 2025 21:21:32 +0000 Subject: [PATCH 28/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index f81f2fd8c49c7..1cf30580d225c 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9214000 +9214000,9185008 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index b0c31e59ae1b2..350a0ec38e9bd 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -batched_response_might_include_reduction_failure,9185007 +esql_use_minimum_version_for_enrich_resolution,9185008 From 8db7bd9ded412b0c196c5851e028244d84ae5437 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 22:28:20 +0100 Subject: [PATCH 29/50] Remove leftovers --- .../org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java index a8ade7d2077dc..235ec50f429b5 100644 --- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java @@ -51,11 +51,9 @@ public void createRemoteIndices() throws IOException { if (supportsNodeAssignment()) { for (Map.Entry e : remoteNodeToInfo().entrySet()) { createIndexForNode(remoteClient(), e.getKey(), e.getValue().id(), indexMode()); - createIndexForNode(remoteClient(), e.getKey(), e.getValue().id(), IndexMode.LOOKUP); } } else { createIndexForNode(remoteClient(), null, null, indexMode()); - createIndexForNode(remoteClient(), null, null, IndexMode.LOOKUP); } } From edf69b35d1c411e7aaf24825c0b54f156c2a0673 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 6 Nov 2025 22:41:57 +0100 Subject: [PATCH 30/50] Clarify comment --- .../org/elasticsearch/xpack/esql/session/IndexResolver.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 709b3569e224b..1b20cc6993c86 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 @@ -97,8 +97,8 @@ public IndexResolver(Client client) { * version from the field caps response. It's meant to be the minimum version determined when resolving {@code FROM} * and is required to correctly resolve {@code ENRICH} queries in CCS (enrich policies are resolved locally and thus * might have a higher transport version in their field caps response than when resolving the main indices in {@code FROM}). - * In case of {@code ROW}, it's also okay to pass in the version from the main index resolution; that will be the coordinator - * version, which cannot be higher than the minimum version from the field caps response. + * When resolving {@code ENRICH} after {@code ROW}, it's also okay to pass in the version from the main index resolution; + * that will be the coordinator version, which cannot be higher than the minimum version from the field caps response. *

* The overall minimum version which is used to determine data type support is passed on to the listener. */ From 1a3ddc86b9dc43022d10c74d52acea18ec6f4d4b Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 7 Nov 2025 11:27:10 +0100 Subject: [PATCH 31/50] Improve log message --- .../org/elasticsearch/xpack/esql/session/IndexResolver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1b20cc6993c86..defef005c3ef0 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 @@ -130,7 +130,7 @@ public void resolveAsMergedMapping( useDenseVectorWhenNotSupported ); LOGGER.debug( - "updated minimum transport version from {} to effective version {} using version {} from field caps response", + "updated minimum transport version from [{}] to effective version [{}] using version [{}] from field caps response", minimumVersion, info.effectiveMinTransportVersion(), response.caps().minTransportVersion() From 1a8cbe4ae82bcb6d6f2a54117ec78e2bbd62a700 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 7 Nov 2025 11:33:12 +0100 Subject: [PATCH 32/50] Rename resolveAsMergedMapping->resolveIndexPattern --- .../xpack/esql/enrich/EnrichPolicyResolver.java | 2 +- .../org/elasticsearch/xpack/esql/session/EsqlSession.java | 4 ++-- .../org/elasticsearch/xpack/esql/session/IndexResolver.java | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 573d55696ab9b..3dd035289594a 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -513,7 +513,7 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas } try (ThreadContext.StoredContext ignored = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) { String indexName = EnrichPolicy.getBaseName(policyName); - indexResolver.resolveAsMergedMapping( + indexResolver.resolveIndexPattern( indexName, IndexResolver.ALL_FIELDS, null, 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 34ca729c9dc29..c03048dec99ce 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 @@ -613,7 +613,7 @@ private void preAnalyzeLookupIndex( ThreadPool.Names.SEARCH_COORDINATION, ThreadPool.Names.SYSTEM_READ ); - indexResolver.resolveAsMergedMapping( + indexResolver.resolveIndexPattern( EsqlCCSUtils.createQualifiedLookupIndexExpressionFromAvailableClusters(executionInfo, localPattern), result.wildcardJoinIndices().contains(localPattern) ? IndexResolver.ALL_FIELDS : result.fieldNames, null, @@ -855,7 +855,7 @@ private void preAnalyzeMainIndices( // return empty resolution if the expression is pure CCS and resolved no remote clusters (like no-such-cluster*:index) listener.onResponse(result.withIndices(indexPattern, IndexResolution.empty(indexPattern.indexPattern()))); } else { - indexResolver.resolveAsMergedMapping( + indexResolver.resolveIndexPattern( indexPattern.indexPattern(), result.fieldNames, // Maybe if no indices are returned, retry without index mode and provide a clearer error message. 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 defef005c3ef0..2e8f898ef08ef 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 @@ -102,12 +102,16 @@ public IndexResolver(Client client) { *

* The overall minimum version which is used to determine data type support is passed on to the listener. */ - public void resolveAsMergedMapping( + public void resolveIndexPattern( String indexPattern, Set fieldNames, QueryBuilder requestFilter, boolean includeAllDimensions, + // Used for bwc with 9.2.0, which supports aggregate_metric_double but doesn't provide its version in the field + // caps response. We'll just assume the type is supported based on usage in the query to not break compatibility + // with 9.2.0. boolean useAggregateMetricDoubleWhenNotSupported, + // Same as above boolean useDenseVectorWhenNotSupported, @Nullable TransportVersion minimumVersion, ActionListener> listener From ced04e144490f80c3d0443a3b7d94b784a575b84 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 27 Nov 2025 17:44:10 +0100 Subject: [PATCH 33/50] Obtain local cluster version from ClusterState Do not propagate the minimum transport version determined during ENRICH and LOOKUP JOIN resolution back to the resolution pipeline in EsqlSEssion. That's overkill just for ROW queries. We can get the minimum version of the cluster simply from the ClusterState. --- .../qa/rest/AllSupportedFieldsTestCase.java | 22 +++-- .../esql/enrich/EnrichPolicyResolver.java | 54 ++---------- .../xpack/esql/execution/PlanExecutor.java | 3 + .../esql/plugin/TransportEsqlQueryAction.java | 3 + .../xpack/esql/session/EsqlSession.java | 49 +++++++---- .../xpack/esql/session/IndexResolver.java | 83 ++++++++----------- .../elasticsearch/xpack/esql/CsvTests.java | 1 + .../enrich/EnrichPolicyResolverTests.java | 5 +- .../telemetry/PlanExecutorMetricsTests.java | 2 + 9 files changed, 97 insertions(+), 125 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index b6547a4ca6b0d..9707a07a605ec 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -500,9 +500,8 @@ public void testRow() throws IOException { ); String query = "ROW x = 1 | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - var coordinatorVersion = responseAndCoordinatorVersion.v2(); - assertMinimumVersion(coordinatorVersion, responseAndCoordinatorVersion); + assertMinimumVersion(minVersion(true), responseAndCoordinatorVersion, false); } @SuppressWarnings("unchecked") @@ -636,14 +635,23 @@ protected void assertMinimumVersionFromAllQueries(Tuple, Tra assertMinimumVersion(minVersion(), responseAndCoordinatorVersion); } + protected void assertMinimumVersion( + TransportVersion expectedMinimumVersion, + Tuple, TransportVersion> responseAndCoordinatorVersion + ) { + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, true); + } + /** - * @param expectedMinimumVersion the minimum version of all clusters that participate in the query, or the version of the coordinator - * if the query runs only on the coordinator + * @param expectedMinimumVersion the minimum version of all clusters that participate in the query + * @param performsAnyFieldCapsRequest {@code true} for queries that have any {@code FROM, ENRICH} or {@code LOOKUP JOIN} commands. + * Simple {@code ROW} queries are executed only on the coordinator and don't perform field caps requests. */ @SuppressWarnings("unchecked") protected void assertMinimumVersion( TransportVersion expectedMinimumVersion, - Tuple, TransportVersion> responseAndCoordinatorVersion + Tuple, TransportVersion> responseAndCoordinatorVersion, + boolean performsAnyFieldCapsRequest ) { var responseMap = responseAndCoordinatorVersion.v1(); var coordinatorVersion = responseAndCoordinatorVersion.v2(); @@ -653,9 +661,7 @@ protected void assertMinimumVersion( Integer minimumVersion = (Integer) profile.get("minimumTransportVersion"); assertNotNull(minimumVersion); int minimumVersionInt = minimumVersion; - if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV)) { - // All nodes are new enough that their field caps responses should contain the minimum transport version - // of matching clusters. + if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV) || (performsAnyFieldCapsRequest == false)) { assertEquals(expectedMinimumVersion.id(), minimumVersionInt); } else { // One node is old enough that it doesn't provide version information in the field caps response. We must assume diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 3dd035289594a..b3382fa9840a4 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -42,7 +42,6 @@ import org.elasticsearch.xpack.esql.analysis.EnrichResolution; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.EsField; -import org.elasticsearch.xpack.esql.core.util.Holder; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.index.EsIndex; import org.elasticsearch.xpack.esql.index.IndexResolution; @@ -50,7 +49,6 @@ import org.elasticsearch.xpack.esql.io.stream.PlanStreamOutput; import org.elasticsearch.xpack.esql.plan.logical.Enrich; import org.elasticsearch.xpack.esql.session.IndexResolver; -import org.elasticsearch.xpack.esql.session.Versioned; import java.io.IOException; import java.util.ArrayList; @@ -129,10 +127,10 @@ public void resolvePolicies( List enriches, EsqlExecutionInfo executionInfo, TransportVersion minimumVersion, - ActionListener> listener + ActionListener listener ) { if (enriches.isEmpty()) { - listener.onResponse(new Versioned<>(new EnrichResolution(), minimumVersion)); + listener.onResponse(new EnrichResolution()); return; } @@ -150,10 +148,10 @@ protected void doResolvePolicies( Collection unresolvedPolicies, EsqlExecutionInfo executionInfo, TransportVersion minimumVersion, - ActionListener> listener + ActionListener listener ) { if (unresolvedPolicies.isEmpty()) { - listener.onResponse(new Versioned<>(new EnrichResolution(), minimumVersion)); + listener.onResponse(new EnrichResolution()); return; } @@ -179,14 +177,6 @@ protected void doResolvePolicies( } } - // Propagate the minimum version observed during policy resolution back to the planning pipeline. - // This is only really required for `ROW | ENRICH` queries, where the main index resolution doesn't - // provide the minimum version of the local cluster. - TransportVersion updatedMinimumVersion = minimumVersion; - for (LookupResponse response : lookupResponsesToProcess.values()) { - updatedMinimumVersion = TransportVersion.min(updatedMinimumVersion, response.minimumVersion); - } - for (UnresolvedPolicy unresolved : unresolvedPolicies) { Tuple resolved = mergeLookupResults( unresolved, @@ -201,7 +191,7 @@ protected void doResolvePolicies( enrichResolution.addError(unresolved.name, unresolved.mode, resolved.v2()); } } - return new Versioned<>(enrichResolution, updatedMinimumVersion); + return enrichResolution; })); } @@ -434,15 +424,12 @@ public void writeTo(StreamOutput out) throws IOException { private static class LookupResponse extends TransportResponse { final Map policies; final Map failures; - // The minimum transport version observed when running field caps requests to resolve the policies - final TransportVersion minimumVersion; // does not need to be Writable since this indicates a failure to contact a remote cluster, so only set on querying cluster final transient Exception connectionError; - LookupResponse(Map policies, Map failures, TransportVersion minimumVersion) { + LookupResponse(Map policies, Map failures) { this.policies = policies; this.failures = failures; - this.minimumVersion = minimumVersion; this.connectionError = null; } @@ -454,7 +441,6 @@ private static class LookupResponse extends TransportResponse { LookupResponse(Exception connectionError) { this.policies = Collections.emptyMap(); this.failures = Collections.emptyMap(); - this.minimumVersion = TransportVersion.current(); this.connectionError = connectionError; } @@ -462,19 +448,6 @@ private static class LookupResponse extends TransportResponse { PlanStreamInput planIn = new PlanStreamInput(in, in.namedWriteableRegistry(), null); this.policies = planIn.readMap(StreamInput::readString, ResolvedEnrichPolicy::new); this.failures = planIn.readMap(StreamInput::readString, StreamInput::readString); - if (in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { - this.minimumVersion = TransportVersion.readVersion(in); - } else { - // A pre-9.2.1 node resolved the enrich policy for us, but doesn't say which version its cluster is on. - // We can safely assume this node's current version, even though that's technically wrong. - // Assuming a version that's too old can disable aggregate_metric_double and dense_vector - // data types in the query, that'd be very bad. - // But assuming these types are supported is fine because in 9.2.0, - // they're not supported in enrich policies, anyway, due to bugs. - // https://github.com/elastic/elasticsearch/issues/127350 - // https://github.com/elastic/elasticsearch/issues/137699 - this.minimumVersion = TransportVersion.current(); - } this.connectionError = null; } @@ -483,9 +456,6 @@ public void writeTo(StreamOutput out) throws IOException { PlanStreamOutput pso = new PlanStreamOutput(out, null); pso.writeMap(policies, StreamOutput::writeWriteable); pso.writeMap(failures, StreamOutput::writeString); - if (out.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { - TransportVersion.writeVersion(minimumVersion, out); - } } } @@ -495,17 +465,12 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas final Map availablePolicies = availablePolicies(); final Map failures = ConcurrentCollections.newConcurrentMap(); final Map resolvedPolices = ConcurrentCollections.newConcurrentMap(); - // We use the coordinator's minimum version as base line. - final Holder minimumVersion = new Holder<>(request.minimumVersion); ThreadContext threadContext = threadPool.getThreadContext(); ActionListener listener = ContextPreservingActionListener.wrapPreservingContext( new ChannelActionListener<>(channel), threadContext ); - try (var refs = new RefCountingListener(listener.map(unused -> { - TransportVersion finalMinimumVersion = minimumVersion.get(); - return new LookupResponse(resolvedPolices, failures, finalMinimumVersion); - }))) { + try (var refs = new RefCountingListener(listener.map(unused -> { return new LookupResponse(resolvedPolices, failures); }))) { for (String policyName : request.policyNames) { EnrichPolicy p = availablePolicies.get(policyName); if (p == null) { @@ -535,11 +500,6 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas esIndex.mapping() ); resolvedPolices.put(policyName, resolved); - synchronized (minimumVersion) { - minimumVersion.set( - TransportVersion.min(minimumVersion.get(), versionedIndexResult.minimumVersion()) - ); - } } else { failures.put(policyName, indexResult.toString()); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java index cb1a7a77d3c80..ecdccf6aecebe 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/execution/PlanExecutor.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.esql.execution; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.indices.IndicesExpressionGrouper; import org.elasticsearch.license.XPackLicenseState; @@ -68,6 +69,7 @@ public PlanExecutor( public void esql( EsqlQueryRequest request, String sessionId, + TransportVersion localClusterMinimumVersion, AnalyzerSettings analyzerSettings, EnrichPolicyResolver enrichPolicyResolver, EsqlExecutionInfo executionInfo, @@ -79,6 +81,7 @@ public void esql( final PlanTelemetry planTelemetry = new PlanTelemetry(functionRegistry); final var session = new EsqlSession( sessionId, + localClusterMinimumVersion, analyzerSettings, indexResolver, enrichPolicyResolver, diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java index ce39eede9e097..b78fe8320a9af 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.esql.plugin; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRunnable; import org.elasticsearch.action.admin.cluster.stats.CCSUsage; @@ -240,6 +241,7 @@ private void innerExecute(Task task, EsqlQueryRequest request, ActionListener r.withEnrichResolution(versionedResolution.inner()) - .withMinimumTransportVersion(versionedResolution.minimumVersion()) - ) + // Do not update PreAnalysisResult.minimumTransportVersion, that's already been determined during main index resolution. + l.map(r::withEnrichResolution) ); }) .andThen((l, r) -> { @@ -625,7 +630,9 @@ private void preAnalyzeLookupIndex( // resolution's minimum version, as the coordinator's just going to make another field caps request for the lookup resolution // that comes with the minimum version in the response. result.minimumTransportVersion(), - listener.map(indexResolution -> receiveLookupIndexResolution(result, localPattern, executionInfo, indexResolution)) + // No need to update the minimum transport version in the PreAnalysisResult, + // it should already have been determined during the main index resolution. + listener.map(indexResolution -> receiveLookupIndexResolution(result, localPattern, executionInfo, indexResolution.inner())) ); } @@ -650,9 +657,8 @@ private PreAnalysisResult receiveLookupIndexResolution( PreAnalysisResult result, String index, EsqlExecutionInfo executionInfo, - Versioned versionedLookupIndexResolution + IndexResolution lookupIndexResolution ) { - IndexResolution lookupIndexResolution = versionedLookupIndexResolution.inner(); EsqlCCSUtils.updateExecutionInfoWithUnavailableClusters(executionInfo, lookupIndexResolution.failures()); if (lookupIndexResolution.isValid() == false) { // If the index resolution is invalid, don't bother with the rest of the analysis @@ -683,8 +689,7 @@ private PreAnalysisResult receiveLookupIndexResolution( ); } - return result.addLookupIndexResolution(index, lookupIndexResolution) - .withMinimumTransportVersion(versionedLookupIndexResolution.minimumVersion()); + return result.addLookupIndexResolution(index, lookupIndexResolution); } if (lookupIndexResolution.get().indexNameWithModes().isEmpty() && lookupIndexResolution.resolvedIndices().isEmpty() == false) { @@ -871,7 +876,15 @@ private void preAnalyzeMainIndices( indexMode == IndexMode.TIME_SERIES, preAnalysis.useAggregateMetricDoubleWhenNotSupported(), preAnalysis.useDenseVectorWhenNotSupported(), - null, + // TODO: in case of subqueries, the different main index resolutions don't know about each other's minimum version. + // This is bad because `FROM (FROM remote1:*) (FROM remote2:*)` can have different minimum versions + // while resolving each subquery's main index pattern; we'll determine the correct overall minimum transport version + // in the end because we keep updating the PreAnalysisResult after each resolution, but the EsIndex objects may be + // inconsistent with this version (by e.g. containing data types that aren't supported on the overall minimum version). + // Passing `result.minimumTransportVersion()` here instead of `localClusterMinimumVersion` doesn't fix this problem, + // as the main index pattern from a subquery that we resolve first may have a higher min version than an index pattern + // that we resolve later. + localClusterMinimumVersion, listener.delegateFailureAndWrap((l, indexResolution) -> { EsqlCCSUtils.updateExecutionInfoWithUnavailableClusters(executionInfo, indexResolution.inner().failures()); l.onResponse( 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 2e8f898ef08ef..0f8c72632f6cf 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 @@ -25,7 +25,6 @@ import org.elasticsearch.logging.Logger; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.esql.action.EsqlResolveFieldsAction; -import org.elasticsearch.xpack.esql.action.EsqlResolveFieldsResponse; import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.DateEsField; @@ -85,6 +84,7 @@ public IndexResolver(Client client) { /** * Perform a field caps request to resolve a pattern to one mapping (potentially compound, meaning it spans multiple indices). + *

* The field caps response contains the minimum transport version of all clusters that apply to the pattern, * and it is used to deal with previously unsupported data types during resolution. *

@@ -93,14 +93,15 @@ public IndexResolver(Client client) { * If the nodes are too old to include their minimum transport version in the field caps response, we'll assume * {@link TransportVersion#minimumCompatible()}. *

- * Optionally, a {@code minimumVersion} can be passed in that will be used instead if it is lower than the transport - * version from the field caps response. It's meant to be the minimum version determined when resolving {@code FROM} - * and is required to correctly resolve {@code ENRICH} queries in CCS (enrich policies are resolved locally and thus - * might have a higher transport version in their field caps response than when resolving the main indices in {@code FROM}). - * When resolving {@code ENRICH} after {@code ROW}, it's also okay to pass in the version from the main index resolution; - * that will be the coordinator version, which cannot be higher than the minimum version from the field caps response. + * The {@code minimumVersion} already known so far must be passed in and will be used instead of the minimum version + * from the field caps response if it is lower. During main index resolution, this is the local cluster's minimum version. + * This safeguards against using too new a version in case of {@code FROM remote_only:* | ...} queries that don't have any indices + * on the local cluster. + *

+ * But it's also important for remote {@code ENRICH} resolution, because in CCS enrich policies are resolved on remote clusters, + * so the overall minimum transport version that the coordinating cluster observed must be passed in here to avoid inconsistencies. *

- * The overall minimum version which is used to determine data type support is passed on to the listener. + * The overall minimum version is updated using the field caps response and is passed on to the listener. */ public void resolveIndexPattern( String indexPattern, @@ -113,7 +114,7 @@ public void resolveIndexPattern( boolean useAggregateMetricDoubleWhenNotSupported, // Same as above boolean useDenseVectorWhenNotSupported, - @Nullable TransportVersion minimumVersion, + TransportVersion minimumVersion, ActionListener> listener ) { client.execute( @@ -121,9 +122,10 @@ public void resolveIndexPattern( createFieldCapsRequest(indexPattern, fieldNames, requestFilter, includeAllDimensions), listener.delegateFailureAndWrap((l, response) -> { TransportVersion responseMinimumVersion = response.caps().minTransportVersion(); - // If we don't know the overall minimum version, it stays null to avoid faking knowledge we don't have. - TransportVersion overallMinimumVersion = responseMinimumVersion == null ? null - : minimumVersion == null ? responseMinimumVersion + // Note: Once {@link EsqlResolveFieldsResponse}'s CREATED version is live everywhere + // we can remove this and make sure responseMinimumVersion is non-null. That'll be 10.0-ish. + TransportVersion overallMinimumVersion = responseMinimumVersion == null + ? TransportVersion.minimumCompatible() : TransportVersion.min(minimumVersion, responseMinimumVersion); FieldsInfo info = new FieldsInfo( @@ -136,10 +138,10 @@ public void resolveIndexPattern( LOGGER.debug( "updated minimum transport version from [{}] to effective version [{}] using version [{}] from field caps response", minimumVersion, - info.effectiveMinTransportVersion(), - response.caps().minTransportVersion() + info.minTransportVersion(), + responseMinimumVersion ); - l.onResponse(new Versioned<>(mergedMappings(indexPattern, info), info.effectiveMinTransportVersion())); + l.onResponse(new Versioned<>(mergedMappings(indexPattern, info), info.minTransportVersion())); }) ); } @@ -153,10 +155,21 @@ public void resolveIndexPattern( * version counts. BUT if the query doesn't dispatch to that cluster AT ALL, we don't count the versions * of any nodes in that cluster. *

- * If this is {@code null} then one of the nodes is before - * {@link EsqlResolveFieldsResponse#RESOLVE_FIELDS_RESPONSE_CREATED_TV} but we have no idea how early - * it is. Could be back in {@code 8.19.0}. - *

+ * If any remote didn't tell us the version we assume + * that it's very, very old. This effectively disables any fields that were created "recently". + * Which is appropriate because those fields are not supported on *almost* all versions that + * don't return the transport version in the response. + *

+ * "Very, very old" above means that there are versions of Elasticsearch that we're wire + * compatible that with that don't support sending the version back. That's anything + * from {@code 8.19.FIRST} to {@code 9.2.0}. "Recently" means any field types we + * added support for after the initial release of ESQL. These fields use + * {@link SupportedVersion#supportedOn} rather than {@link SupportedVersion#SUPPORTED_ON_ALL_NODES}. + * Except for DATE_NANOS. For DATE_NANOS we got lucky/made a mistake. It wasn't widely + * used before ESQL added support for it and we weren't careful about enabling it. So + * queries on mixed version clusters that touch DATE_NANOS will fail. All the types + * added after that, like DENSE_VECTOR, will gracefully disable themselves when talking + * to older nodes. * @param currentBuildIsSnapshot is the current build a snapshot? Note: This is always {@code Build.current().isSnapshot()} in * production but tests need more control * @param useAggregateMetricDoubleWhenNotSupported does the query itself force us to use {@code aggregate_metric_double} fields @@ -175,34 +188,7 @@ public record FieldsInfo( boolean currentBuildIsSnapshot, boolean useAggregateMetricDoubleWhenNotSupported, boolean useDenseVectorWhenNotSupported - ) { - /** - * The {@link #minTransportVersion}, but if any remote didn't tell us the version we assume - * that it's very, very old. This effectively disables any fields that were created "recently". - * Which is appropriate because those fields are not supported on *almost* all versions that - * don't return the transport version in the response. - *

- * "Very, very old" above means that there are versions of Elasticsearch that we're wire - * compatible that with that don't support sending the version back. That's anything - * from {@code 8.19.FIRST} to {@code 9.2.0}. "Recently" means any field types we - * added support for after the initial release of ESQL. These fields use - * {@link SupportedVersion#supportedOn} rather than {@link SupportedVersion#SUPPORTED_ON_ALL_NODES}. - * Except for DATE_NANOS. For DATE_NANOS we got lucky/made a mistake. It wasn't widely - * used before ESQL added support for it and we weren't careful about enabling it. So - * queries on mixed version clusters that touch DATE_NANOS will fail. All the types - * added after that, like DENSE_VECTOR, will gracefully disable themselves when talking - * to older nodes. - *

- *

- * Note: Once {@link EsqlResolveFieldsResponse}'s CREATED version is live everywhere - * we can remove this and make sure {@link #minTransportVersion} is non-null. That'll - * be 10.0-ish. - *

- */ - TransportVersion effectiveMinTransportVersion() { - return minTransportVersion != null ? minTransportVersion : TransportVersion.minimumCompatible(); - } - } + ) {} // public for testing only public static IndexResolution mergedMappings(String indexPattern, FieldsInfo fieldsInfo) { @@ -337,8 +323,7 @@ private static EsField createField( IndexFieldCapabilities first = fcs.get(0); List rest = fcs.subList(1, fcs.size()); DataType type = EsqlDataTypeRegistry.INSTANCE.fromEs(first.type(), first.metricType()); - boolean typeSupported = type.supportedVersion() - .supportedOn(fieldsInfo.effectiveMinTransportVersion(), fieldsInfo.currentBuildIsSnapshot) + boolean typeSupported = type.supportedVersion().supportedOn(fieldsInfo.minTransportVersion(), fieldsInfo.currentBuildIsSnapshot) || switch (type) { case AGGREGATE_METRIC_DOUBLE -> fieldsInfo.useAggregateMetricDoubleWhenNotSupported; case DENSE_VECTOR -> fieldsInfo.useDenseVectorWhenNotSupported; diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java index fd7cbfb6fa723..810ada5aa011b 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java @@ -662,6 +662,7 @@ private ActualResults executePlan(BigArrays bigArrays) throws Exception { FoldContext foldCtx = FoldContext.small(); EsqlSession session = new EsqlSession( getTestName(), + TransportVersion.current(), queryClusterSettings(), null, null, diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java index 730b78bd2f9ca..41ff72c2cd66f 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolverTests.java @@ -46,7 +46,6 @@ import org.elasticsearch.xpack.esql.analysis.EnrichResolution; import org.elasticsearch.xpack.esql.plan.logical.Enrich; import org.elasticsearch.xpack.esql.session.IndexResolver; -import org.elasticsearch.xpack.esql.session.Versioned; import org.junit.After; import org.junit.Before; @@ -454,9 +453,9 @@ EnrichResolution resolvePolicies(Collection clusters, Collection> future = new PlainActionFuture<>(); + PlainActionFuture future = new PlainActionFuture<>(); super.doResolvePolicies(new HashSet<>(clusters), unresolvedPolicies, esqlExecutionInfo, TransportVersion.current(), future); - return future.actionGet(30, TimeUnit.SECONDS).inner(); + return future.actionGet(30, TimeUnit.SECONDS); } @Override diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java index a452995b343c9..9f544820991ad 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java @@ -170,6 +170,7 @@ public void testFailedMetric() { planExecutor.esql( request, randomAlphaOfLength(10), + TransportVersion.current(), queryClusterSettings(), enrichResolver, new EsqlExecutionInfo(randomBoolean()), @@ -200,6 +201,7 @@ public void onFailure(Exception e) { planExecutor.esql( request, randomAlphaOfLength(10), + TransportVersion.current(), queryClusterSettings(), enrichResolver, new EsqlExecutionInfo(randomBoolean()), From c68bc07561fecfd90891e0e65a947dd48bcd6c4a Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 27 Nov 2025 17:50:10 +0100 Subject: [PATCH 34/50] Update comment --- .../elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index b3382fa9840a4..6437696ec1fa6 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -405,8 +405,8 @@ private static class LookupRequest extends AbstractTransportRequest { } else { // An older coordinator contacted us. Let's assume an old version, otherwise we might send back // types that it can't deserialize. - // (The only version that knows some new types but doesn't send its transport version here is 9.2.0; - // these types are dense_vector and aggregate_metric_double, and both don't work with ENRICH in 9.2.0, anyway.) + // (The only versions that know some new types but don't send their transport version here are 9.2.0 and 9.2.1; + // these types are dense_vector and aggregate_metric_double, and both don't work with ENRICH in these versions, anyway.) this.minimumVersion = TransportVersion.minimumCompatible(); } } From 31f8a1a9f7bd197d23b65e7c2415958cada9ec1a Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 27 Nov 2025 17:54:43 +0100 Subject: [PATCH 35/50] Fix comment --- .../java/org/elasticsearch/xpack/esql/session/EsqlSession.java | 2 -- 1 file changed, 2 deletions(-) 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 acb0364515646..aef681c381fb8 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 @@ -592,8 +592,6 @@ private void resolveIndicesAndAnalyze( /** * Perform a field caps request for each lookup index. - *

- * Updates the minimum transport version to deal with ROW queries, where the main index resolution does not make a field caps request. */ private void preAnalyzeLookupIndices( Iterator lookupIndices, From b3b1fe6980433757ee2714a237362b43e0f0c9ab Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Thu, 27 Nov 2025 18:04:21 +0100 Subject: [PATCH 36/50] Fix some more comments --- .../xpack/esql/enrich/EnrichPolicyResolver.java | 5 +++-- .../org/elasticsearch/xpack/esql/session/EsqlSession.java | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 6437696ec1fa6..4db8d57378b27 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -119,8 +119,9 @@ public static UnresolvedPolicy from(Enrich e) { * * @param enriches the unresolved policies * @param executionInfo the execution info - * @param minimumVersion the minimum transport version of all clusters involved in the query, used for making the resolved mapping - * compatible with all possible nodes + * @param minimumVersion the minimum transport version of all clusters involved in the query; used for making the resolved mapping + * compatible with all involved clusters in case of CCS. + * (Enrich policy resolution happens separately on each remote cluster.) * @param listener notified with the enrich resolution */ public void resolvePolicies( 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 aef681c381fb8..471e93868dd6e 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 @@ -624,9 +624,10 @@ private void preAnalyzeLookupIndex( // Disable aggregate_metric_double and dense_vector until we get version checks in planning false, false, - // We're just being consistent with the enrich policy resolution here; technically, we don't need to pass the main index - // resolution's minimum version, as the coordinator's just going to make another field caps request for the lookup resolution - // that comes with the minimum version in the response. + // We use the minimum version determined in the main index resolution, because for remote LOOKUP JOIN, we're only considering + // remote lookup indices in the field caps request - but the coordinating cluster must be considered, too! + // The main index resolution should already have taken the version of the coordinating cluster into account and this should + // be reflected in result.minimumTransportVersion(). result.minimumTransportVersion(), // No need to update the minimum transport version in the PreAnalysisResult, // it should already have been determined during the main index resolution. From 8a6fb3c583e47263e8ff759a1ca65e586a67a1dd Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 10:49:24 +0100 Subject: [PATCH 37/50] Minor simplification --- .../esql/enrich/EnrichPolicyResolver.java | 41 +++++++++++-------- .../xpack/esql/session/EsqlSession.java | 20 +++++---- .../xpack/esql/session/IndexResolver.java | 10 ++++- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 364dda215ca38..4175ea09d3ed3 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -44,7 +44,6 @@ import org.elasticsearch.xpack.esql.core.type.EsField; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.index.EsIndex; -import org.elasticsearch.xpack.esql.index.IndexResolution; import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; import org.elasticsearch.xpack.esql.io.stream.PlanStreamOutput; import org.elasticsearch.xpack.esql.plan.logical.Enrich; @@ -479,22 +478,30 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas } try (ThreadContext.StoredContext ignored = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) { String indexName = EnrichPolicy.getBaseName(policyName); - indexResolver.resolveIndices(indexName, IndexResolver.ALL_FIELDS, request.minimumVersion, refs.acquire(indexResult -> { - if (indexResult.isValid() && indexResult.get().concreteQualifiedIndices().size() == 1) { - EsIndex esIndex = indexResult.get(); - var concreteIndices = Map.of(request.clusterAlias, Iterables.get(esIndex.concreteQualifiedIndices(), 0)); - var resolved = new ResolvedEnrichPolicy( - p.getMatchField(), - p.getType(), - p.getEnrichFields(), - concreteIndices, - esIndex.mapping() - ); - resolvedPolices.put(policyName, resolved); - } else { - failures.put(policyName, indexResult.toString()); - } - })); + indexResolver.resolveIndices( + indexName, + IndexResolver.ALL_FIELDS, + request.minimumVersion, + refs.acquire(indexResult -> { + if (indexResult.isValid() && indexResult.get().concreteQualifiedIndices().size() == 1) { + EsIndex esIndex = indexResult.get(); + var concreteIndices = Map.of( + request.clusterAlias, + Iterables.get(esIndex.concreteQualifiedIndices(), 0) + ); + var resolved = new ResolvedEnrichPolicy( + p.getMatchField(), + p.getType(), + p.getEnrichFields(), + concreteIndices, + esIndex.mapping() + ); + resolvedPolices.put(policyName, resolved); + } else { + failures.put(policyName, indexResult.toString()); + } + }) + ); } } } 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 d68b5db7a786d..e07bf0ace4598 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 @@ -893,15 +893,16 @@ private void preAnalyzeMainIndices( result.fieldNames, createQueryFilter(indexMode, requestFilter), indexMode == IndexMode.TIME_SERIES, - // TODO: in case of subqueries, the different main index resolutions don't know about each other's minimum version. + // TODO: In case of subqueries, the different main index resolutions don't know about each other's minimum version. // This is bad because `FROM (FROM remote1:*) (FROM remote2:*)` can have different minimum versions - // while resolving each subquery's main index pattern; we'll determine the correct overall minimum transport version - // in the end because we keep updating the PreAnalysisResult after each resolution, but the EsIndex objects may be - // inconsistent with this version (by e.g. containing data types that aren't supported on the overall minimum version). - // Passing `result.minimumTransportVersion()` here instead of `localClusterMinimumVersion` doesn't fix this problem, - // as the main index pattern from a subquery that we resolve first may have a higher min version than an index pattern - // that we resolve later. - localClusterMinimumVersion, + // while resolving each subquery's main index pattern. We'll determine the correct overall minimum transport version + // in the end because we keep updating the PreAnalysisResult after each resolution; but the EsIndex objects may be + // inconsistent with this version: + // The main index pattern from a subquery that we resolve first may have a higher min version in the field caps response + // than an index pattern that we resolve later. + // Thus, the EsIndex for `FROM remote1:*` may contain data types that aren't supported on the overall minimum version + // if we only find out that the overall version is actually lower when resolving `FROM remote2:*`. + result.minimumTransportVersion(), preAnalysis.useAggregateMetricDoubleWhenNotSupported(), preAnalysis.useDenseVectorWhenNotSupported(), indicesExpressionGrouper, @@ -930,7 +931,8 @@ private void preAnalyzeFlatMainIndices( result.fieldNames, createQueryFilter(indexMode, requestFilter), indexMode == IndexMode.TIME_SERIES, - localClusterMinimumVersion, + // TODO: Same problem with subqueries as preAnalyzeMainIndices, see above. + result.minimumTransportVersion(), preAnalysis.useAggregateMetricDoubleWhenNotSupported(), preAnalysis.useDenseVectorWhenNotSupported(), listener.delegateFailureAndWrap((l, indexResolution) -> { 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 8e8010caa5aa1..6f18733617da2 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 @@ -100,7 +100,12 @@ public IndexResolver(Client client) { * Like {@link IndexResolver#resolveIndicesVersioned(String, Set, QueryBuilder, boolean, TransportVersion, boolean, boolean, IndicesExpressionGrouper, ActionListener)} * but simplified and does not pass on the determined minimum transport version to the listener. */ - public void resolveIndices(String indexPattern, Set fieldNames, TransportVersion minimumVersion, ActionListener listener) { + public void resolveIndices( + String indexPattern, + Set fieldNames, + TransportVersion minimumVersion, + ActionListener listener + ) { doResolveIndices( createFieldCapsRequest(DEFAULT_OPTIONS, indexPattern, fieldNames, null, false, false), indexPattern, @@ -215,7 +220,8 @@ private void doResolveIndices( useDenseVectorWhenNotSupported ); LOGGER.debug( - "previously assumed minimum transport version [{}] updated to effective version [{}]" + " using field caps response version [{}] for index pattern [{}]", + "previously assumed minimum transport version [{}] updated to effective version [{}]" + + " using field caps response version [{}] for index pattern [{}]", minimumVersion, info.minTransportVersion(), responseMinimumVersion, From 853d867292d67dd0e40cc65c71274d06af90be53 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 11:28:15 +0100 Subject: [PATCH 38/50] Fix tests --- .../java/org/elasticsearch/test/rest/ESRestTestCase.java | 3 ++- .../xpack/esql/qa/single_node/PushQueriesIT.java | 1 + .../xpack/esql/qa/single_node/StoredFieldsSequentialIT.java | 1 + .../xpack/esql/action/EsqlQueryResponseTests.java | 2 +- .../xpack/esql/telemetry/PlanExecutorMetricsTests.java | 5 ++--- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java index c5df1fab9c917..5ac56efe32ccb 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java @@ -2764,7 +2764,8 @@ protected static MapMatcher getProfileMatcher() { .entry("query", instanceOf(Map.class)) .entry("planning", instanceOf(Map.class)) .entry("drivers", instanceOf(List.class)) - .entry("plans", instanceOf(List.class)); + .entry("plans", instanceOf(List.class)) + .entry("minimumTransportVersion", instanceOf(Integer.class)); } protected static MapMatcher getResultMatcher(boolean includePartial, boolean includeDocumentsFound, boolean includeTimestamps) { diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java index 0076ee4d9fb43..eeee9babcade8 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java @@ -366,6 +366,7 @@ private void testPushQuery( .entry("plans", instanceOf(List.class)) .entry("planning", matchesMap().extraOk()) .entry("query", matchesMap().extraOk()) + .entry("minimumTransportVersion", instanceOf(Integer.class)) ), matchesList().item(matchesMap().entry("name", "test").entry("type", anyOf(equalTo("text"), equalTo("keyword")))), equalTo(found ? List.of(List.of(value)) : List.of()) diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/StoredFieldsSequentialIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/StoredFieldsSequentialIT.java index 73cc14c195269..5b1adfdbd6e8c 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/StoredFieldsSequentialIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/StoredFieldsSequentialIT.java @@ -122,6 +122,7 @@ private void testQuery(Double percent, String query, int documentsFound, boolean .entry("plans", instanceOf(List.class)) .entry("planning", matchesMap().extraOk()) .entry("query", matchesMap().extraOk()) + .entry("minimumTransportVersion", instanceOf(Integer.class)) ) .extraOk() ); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java index 56c41cb8627e4..0fd347bdc4742 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java @@ -1101,7 +1101,7 @@ public void testProfileXContent() { "plan" : "plan tree" } ], - "minimumVersion" : 1234 + "minimumTransportVersion" : 1234 } }""")); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java index 9f544820991ad..59337e345aed0 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/telemetry/PlanExecutorMetricsTests.java @@ -83,9 +83,8 @@ EnrichPolicyResolver mockEnrichResolver() { EnrichPolicyResolver enrichResolver = mock(EnrichPolicyResolver.class); doAnswer(invocation -> { Object[] arguments = invocation.getArguments(); - ActionListener> listener = (ActionListener>) arguments[arguments.length - - 1]; - listener.onResponse(new Versioned<>(new EnrichResolution(), TransportVersion.current())); + ActionListener listener = (ActionListener) arguments[arguments.length - 1]; + listener.onResponse(new EnrichResolution()); return null; }).when(enrichResolver).resolvePolicies(any(), any(), any(), any()); return enrichResolver; From 8e4852b514e0f6ef6c00a74befb02dba5e616871 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 11:31:43 +0100 Subject: [PATCH 39/50] Fix comment --- .../elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 4175ea09d3ed3..581a68d4821d9 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -405,8 +405,8 @@ private static class LookupRequest extends AbstractTransportRequest { } else { // An older coordinator contacted us. Let's assume an old version, otherwise we might send back // types that it can't deserialize. - // (The only versions that know some new types but don't send their transport version here are 9.2.0 and 9.2.1; - // these types are dense_vector and aggregate_metric_double, and both don't work with ENRICH in these versions, anyway.) + // (The only versions that know some new types but don't send their transport version here are 9.2.0-9.2.2. + // These types are dense_vector and aggregate_metric_double, and both don't work with ENRICH in these versions, anyway.) this.minimumVersion = TransportVersion.minimumCompatible(); } } From 8d62430e8f73a5d71e6324e0093d269b49fb154e Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 28 Nov 2025 10:40:20 +0000 Subject: [PATCH 40/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index 06fd7469c5ace..a51d2a5a3ff02 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9230000 +9230000,9185011 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 5b4cb63943755..6f76f928f4bb0 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -aggregate_metric_double_typed_block,9185010 +esql_use_minimum_version_for_enrich_resolution,9185011 From e48a1fab4577c80c8cab15a8bb805b1613e1f7da Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 11:33:31 +0100 Subject: [PATCH 41/50] Simplify leftovers --- .../elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java | 2 +- .../java/org/elasticsearch/xpack/esql/session/EsqlSession.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java index 581a68d4821d9..15738151ca2f3 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichPolicyResolver.java @@ -470,7 +470,7 @@ public void messageReceived(LookupRequest request, TransportChannel channel, Tas new ChannelActionListener<>(channel), threadContext ); - try (var refs = new RefCountingListener(listener.map(unused -> { return new LookupResponse(resolvedPolices, failures); }))) { + try (var refs = new RefCountingListener(listener.map(unused -> new LookupResponse(resolvedPolices, failures)))) { for (String policyName : request.policyNames) { EnrichPolicy p = availablePolicies.get(policyName); if (p == null) { 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 e07bf0ace4598..470976706b6cc 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 @@ -609,7 +609,7 @@ private void resolveIndicesAndAnalyze( } /** - * Perform a field caps request for each lookup index. + * Perform a field caps request for each lookup index. Does not update the minimum transport version. */ private void preAnalyzeLookupIndices( Iterator lookupIndices, From 3dd02618f015ad6a651574664d3f006307cb8dc4 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 11:56:08 +0100 Subject: [PATCH 42/50] Fix checkstyle violations --- .../org/elasticsearch/xpack/esql/session/IndexResolver.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6f18733617da2..ed925d0cfb920 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 @@ -97,7 +97,7 @@ public IndexResolver(Client client) { } /** - * Like {@link IndexResolver#resolveIndicesVersioned(String, Set, QueryBuilder, boolean, TransportVersion, boolean, boolean, IndicesExpressionGrouper, ActionListener)} + * Like {@code IndexResolver#resolveIndicesVersioned} * but simplified and does not pass on the determined minimum transport version to the listener. */ public void resolveIndices( @@ -164,7 +164,7 @@ public void resolveIndicesVersioned( } /** - * Like {@link IndexResolver#resolveIndicesVersioned(String, Set, QueryBuilder, boolean, TransportVersion, boolean, boolean, IndicesExpressionGrouper, ActionListener)} + * Like {@code IndexResolver#resolveIndicesVersioned} * but for flat world queries. */ public void resolveFlatWorldIndicesVersioned( From ae90dd8fbb260f9946561f964cf523db88959233 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 14:37:04 +0100 Subject: [PATCH 43/50] Handle null values in profile's minimum version --- .../xpack/esql/action/EsqlQueryResponse.java | 25 ++++++++++++++++--- .../action/EsqlQueryResponseProfileTests.java | 21 +++++++++------- .../esql/action/EsqlQueryResponseTests.java | 11 +++++--- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java index bc8e9aa982ffd..bfde83b5ffd35 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponse.java @@ -332,7 +332,8 @@ public Iterator toXContentChunked(ToXContent.Params params content.add(ChunkedToXContentHelper.array("drivers", profile.drivers.iterator(), params)); content.add(ChunkedToXContentHelper.array("plans", profile.plans.iterator())); content.add(ChunkedToXContentHelper.chunk((b, p) -> { - b.field("minimumTransportVersion", profile.minimumVersion().id()); + TransportVersion minimumVersion = profile.minimumVersion(); + b.field("minimumTransportVersion", minimumVersion == null ? null : minimumVersion.id()); return b; })); content.add(ChunkedToXContentHelper.endObject()); @@ -451,7 +452,7 @@ public static Profile readFrom(StreamInput in) throws IOException { in.getTransportVersion().supports(ESQL_PROFILE_INCLUDE_PLAN) ? in.readCollectionAsImmutableList(PlanProfile::readFrom) : List.of(), - in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) ? TransportVersion.readVersion(in) : null + in.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) ? readOptionalTransportVersion(in) : null ); } @@ -462,7 +463,25 @@ public void writeTo(StreamOutput out) throws IOException { out.writeCollection(plans); } if (out.getTransportVersion().supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION)) { - TransportVersion.writeVersion(minimumVersion, out); + // When retrieving the profile from an older node, there might be no minimum version attached. + // When writing the profile somewhere else, we need to handle the case that the minimum version is null. + writeOptionalTransportVersion(minimumVersion, out); + } + } + + private static TransportVersion readOptionalTransportVersion(StreamInput in) throws IOException { + if (in.readBoolean()) { + return TransportVersion.readVersion(in); + } + return null; + } + + private static void writeOptionalTransportVersion(@Nullable TransportVersion version, StreamOutput out) throws IOException { + if (version == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + TransportVersion.writeVersion(version, out); } } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java index 09d0ce262a54b..3983679a5291f 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseProfileTests.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.esql.action; +import org.elasticsearch.TransportVersion; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.compute.operator.AbstractPageMappingOperator; @@ -19,8 +20,6 @@ import java.util.List; -import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomMinimumVersion; - public class EsqlQueryResponseProfileTests extends AbstractWireSerializingTestCase { @Override protected Writeable.Reader instanceReader() { @@ -39,9 +38,9 @@ protected EsqlQueryResponse.Profile mutateInstance(EsqlQueryResponse.Profile ins var minimumVersion = instance.minimumVersion(); switch (between(0, 2)) { - case 0 -> drivers = randomValueOtherThan(drivers, this::randomDriverProfiles); - case 1 -> plans = randomValueOtherThan(plans, this::randomPlanProfiles); - case 2 -> minimumVersion = randomValueOtherThan(minimumVersion, EsqlTestUtils::randomMinimumVersion); + case 0 -> drivers = randomValueOtherThan(drivers, EsqlQueryResponseProfileTests::randomDriverProfiles); + case 1 -> plans = randomValueOtherThan(plans, EsqlQueryResponseProfileTests::randomPlanProfiles); + case 2 -> minimumVersion = randomValueOtherThan(minimumVersion, EsqlQueryResponseProfileTests::randomMinimumVersion); } return new EsqlQueryResponse.Profile(drivers, plans, minimumVersion); } @@ -51,7 +50,7 @@ protected NamedWriteableRegistry getNamedWriteableRegistry() { return new NamedWriteableRegistry(List.of(AbstractPageMappingOperator.Status.ENTRY)); } - private List randomDriverProfiles() { + private static List randomDriverProfiles() { return randomList( 10, () -> new DriverProfile( @@ -63,20 +62,20 @@ private List randomDriverProfiles() { randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong(), - randomList(10, this::randomOperatorStatus), + randomList(10, EsqlQueryResponseProfileTests::randomOperatorStatus), DriverSleeps.empty() ) ); } - private List randomPlanProfiles() { + private static List randomPlanProfiles() { return randomList( 10, () -> new PlanProfile(randomIdentifier(), randomIdentifier(), randomIdentifier(), randomAlphanumericOfLength(1024)) ); } - private OperatorStatus randomOperatorStatus() { + private static OperatorStatus randomOperatorStatus() { return new OperatorStatus( randomAlphaOfLength(4), randomBoolean() @@ -89,4 +88,8 @@ private OperatorStatus randomOperatorStatus() { : null ); } + + public static TransportVersion randomMinimumVersion() { + return randomBoolean() ? null : EsqlTestUtils.randomMinimumVersion(); + } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java index 0fd347bdc4742..cf85b6a5d45bd 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/action/EsqlQueryResponseTests.java @@ -15,6 +15,7 @@ import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.logging.LoggerMessageFormat; import org.elasticsearch.common.unit.ByteSizeValue; import org.elasticsearch.common.util.BigArrays; import org.elasticsearch.common.util.MockBigArrays; @@ -1013,6 +1014,8 @@ private EsqlQueryResponse simple(boolean columnar, boolean async) { } public void testProfileXContent() { + TransportVersion minimumVersion = EsqlQueryResponseProfileTests.randomMinimumVersion(); + try ( EsqlQueryResponse response = new EsqlQueryResponse( List.of(new ColumnInfoImpl("foo", "integer", null)), @@ -1035,7 +1038,7 @@ public void testProfileXContent() { ) ), List.of(new PlanProfile("test", "elasticsearch", "node-1", "plan tree")), - new TransportVersion(1234) + minimumVersion ), false, false, @@ -1044,7 +1047,7 @@ public void testProfileXContent() { null ); ) { - assertThat(Strings.toString(wrapAsToXContent(response), true, false), equalTo(""" + assertThat(Strings.toString(wrapAsToXContent(response), true, false), equalTo(LoggerMessageFormat.format(""" { "documents_found" : 10, "values_loaded" : 100, @@ -1101,9 +1104,9 @@ public void testProfileXContent() { "plan" : "plan tree" } ], - "minimumTransportVersion" : 1234 + "minimumTransportVersion" : {} } - }""")); + }""", minimumVersion == null ? "null" : minimumVersion.id()))); } } From aa2fb92201ee2897b27c11fcdcf0533c1f8e2e35 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 14:42:14 +0100 Subject: [PATCH 44/50] Fix another test --- .../xpack/esql/qa/single_node/PushExpressionToLoadIT.java | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushExpressionToLoadIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushExpressionToLoadIT.java index 39a90f42c5785..dcea3125656c7 100644 --- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushExpressionToLoadIT.java +++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushExpressionToLoadIT.java @@ -668,6 +668,7 @@ private void test( .entry("plans", instanceOf(List.class)) .entry("planning", matchesMap().extraOk()) .entry("query", matchesMap().extraOk()) + .entry("minimumTransportVersion", instanceOf(Integer.class)) ), matchesList().item(matchesMap().entry("name", "test").entry("type", any(String.class))), matchesList().item(expectedValue) From 7e2bf04c4e8d575c7a986a750d6734c58c0fcc63 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 16:24:11 +0100 Subject: [PATCH 45/50] Add test for FROM *:* (only remote indices) --- .../xpack/esql/ccq/AllSupportedFieldsIT.java | 4 ++ .../qa/rest/AllSupportedFieldsTestCase.java | 46 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java index 235ec50f429b5..71e1199903f96 100644 --- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java @@ -101,4 +101,8 @@ && clusterHasCapability(remoteClient(), "GET", "/_query", List.of(), List.of("DE false ); } + + public final void testFetchAllOnlyFromRemotes() throws IOException { + doTestFetchAll("*:%mode%*", remoteNodeToInfo(), allNodeToInfo()); + } } diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 9707a07a605ec..6336319dbd99c 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -236,7 +236,15 @@ public void skipSnapshots() throws IOException { } public final void testFetchAll() throws IOException { - var responseAndCoordinatorVersion = runFromAllQuery(""" + doTestFetchAll("*:%mode%*,%mode%*", allNodeToInfo(), allNodeToInfo()); + } + + protected final void doTestFetchAll( + String indexPattern, + Map nodesContributingIndices, + Map nodesInvolvedInExecution + ) throws IOException { + var responseAndCoordinatorVersion = runFromAllQuery(indexPattern, """ , _id, _ignored, _index_mode, _score, _source, _version | LIMIT 1000 """); @@ -253,7 +261,7 @@ public final void testFetchAll() throws IOException { assertMap(nameToType(columns), expectedColumns); MapMatcher expectedAllValues = matchesMap(); - for (Map.Entry e : expectedIndices(indexMode).entrySet()) { + for (Map.Entry e : expectedIndices(indexMode, nodesContributingIndices).entrySet()) { String indexName = e.getKey(); MapMatcher expectedValues = allTypesValuesMatcher( coordinatorVersion, @@ -268,7 +276,7 @@ public final void testFetchAll() throws IOException { } assertMap(indexToRow(columns, values), expectedAllValues); - assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); + assertMinimumVersion(minVersion(nodesInvolvedInExecution), responseAndCoordinatorVersion); profileLogger.clearProfile(); } @@ -405,7 +413,6 @@ public final void testFetchDenseVector() throws IOException { MapMatcher expectedAllValues = matchesMap(); for (Map.Entry e : expectedIndices(indexMode).entrySet()) { String indexName = e.getKey(); - NodeInfo nodeInfo = e.getValue(); MapMatcher expectedValues = matchesMap(); expectedValues = expectedValues.entry("f_dense_vector", matchesList().item(0.5).item(10.0).item(5.9999995)); expectedValues = expectedValues.entry("_index", indexName); @@ -487,10 +494,11 @@ public final void testFetchAggregateMetricDouble() throws IOException { } private Tuple, TransportVersion> runFromAllQuery(String restOfQuery) throws IOException { - var responseAndCoordinatorVersion = runQuery( - "FROM *:%mode%*,%mode%* METADATA _index".replace("%mode%", indexMode.toString()) + restOfQuery - ); - return responseAndCoordinatorVersion; + return runFromAllQuery("*:%mode%*,%mode%*", restOfQuery); + } + + private Tuple, TransportVersion> runFromAllQuery(String indexPattern, String restOfQuery) throws IOException { + return runQuery(("FROM " + indexPattern + " METADATA _index").replace("%mode%", indexMode.toString()) + restOfQuery); } public void testRow() throws IOException { @@ -501,18 +509,18 @@ public void testRow() throws IOException { String query = "ROW x = 1 | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - assertMinimumVersion(minVersion(true), responseAndCoordinatorVersion, false); + assertMinimumVersion(minVersion(localNodeToInfo()), responseAndCoordinatorVersion, false); } @SuppressWarnings("unchecked") public void testRowLookupJoin() throws IOException { assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); - Map expectedIndices = expectedIndices(IndexMode.LOOKUP, true); + Map expectedIndices = expectedIndices(IndexMode.LOOKUP, localNodeToInfo()); for (Map.Entry e : expectedIndices.entrySet()) { String indexName = e.getKey(); String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | LOOKUP JOIN " + indexName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - TransportVersion expectedMinimumVersion = minVersion(true); + TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); @@ -550,18 +558,18 @@ public void testRowLookupJoin() throws IOException { @SuppressWarnings("unchecked") public void testRowEnrich() throws IOException { assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); - Map expectedIndices = expectedIndices(IndexMode.LOOKUP, true); + Map expectedIndices = expectedIndices(IndexMode.LOOKUP, localNodeToInfo()); for (Map.Entry e : expectedIndices.entrySet()) { String policyName = e.getKey() + "_policy"; String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | ENRICH " + policyName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); Map response = responseAndCoordinatorVersion.v1(); TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - TransportVersion expectedMinimumVersion = minVersion(true); + TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); Map profile = (Map) response.get("profile"); Integer actualMinimumVersion = (Integer) profile.get("minimumTransportVersion"); - if (minVersion(true).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) + if (minVersion(localNodeToInfo()).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) // Some nodes don't send back the minimum transport version because they're too old to do that. // In this case, the determined minimum version will be that of the coordinator. || (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) @@ -1006,11 +1014,10 @@ private static boolean syntheticSourceByDefault(IndexMode indexMode) { } private Map expectedIndices(IndexMode indexMode) throws IOException { - return expectedIndices(indexMode, false); + return expectedIndices(indexMode, allNodeToInfo()); } - private Map expectedIndices(IndexMode indexMode, boolean onlyLocalCluster) throws IOException { - Map nodeToInfo = onlyLocalCluster ? localNodeToInfo() : allNodeToInfo(); + protected Map expectedIndices(IndexMode indexMode, Map nodeToInfo) throws IOException { Map result = new TreeMap<>(); if (supportsNodeAssignment()) { for (Map.Entry e : nodeToInfo.entrySet()) { @@ -1044,11 +1051,10 @@ private Map expectedIndices(IndexMode indexMode, boolean onlyL } protected TransportVersion minVersion() throws IOException { - return minVersion(false); + return minVersion(allNodeToInfo()); } - protected TransportVersion minVersion(boolean onlyLocalCluster) throws IOException { - Map nodeToInfo = onlyLocalCluster ? localNodeToInfo() : allNodeToInfo(); + protected TransportVersion minVersion(Map nodeToInfo) throws IOException { return nodeToInfo.values().stream().map(NodeInfo::version).min(Comparator.naturalOrder()).get(); } } From aa0693ce8a03f05ed32be559a0a0407b4e7a81ed Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 18:53:34 +0100 Subject: [PATCH 46/50] Add test for FROM | ENRICH And fix expectations for the determined min cluster version in non-CCS tests. --- .../xpack/esql/ccq/AllSupportedFieldsIT.java | 18 +- .../qa/rest/AllSupportedFieldsTestCase.java | 248 +++++++++--------- 2 files changed, 146 insertions(+), 120 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java index 71e1199903f96..43ba02c166304 100644 --- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java +++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/AllSupportedFieldsIT.java @@ -55,6 +55,14 @@ public void createRemoteIndices() throws IOException { } else { createIndexForNode(remoteClient(), null, null, indexMode()); } + + // We need a single lookup index that has the same name across all clusters, as well as a single enrich policy per cluster. + // We create both only when we're testing LOOKUP mode. + if (indexExists(remoteClient(), LOOKUP_INDEX_NAME) == false && indexMode() == IndexMode.LOOKUP) { + createAllTypesIndex(remoteClient(), LOOKUP_INDEX_NAME, null, indexMode()); + createAllTypesDoc(remoteClient(), LOOKUP_INDEX_NAME); + createEnrichPolicy(remoteClient(), LOOKUP_INDEX_NAME, ENRICH_POLICY_NAME); + } } private Map remoteNodeToInfo() throws IOException { @@ -102,7 +110,15 @@ && clusterHasCapability(remoteClient(), "GET", "/_query", List.of(), List.of("DE ); } + @Override + protected boolean fetchAllIsCrossCluster() { + return true; + } + public final void testFetchAllOnlyFromRemotes() throws IOException { - doTestFetchAll("*:%mode%*", remoteNodeToInfo(), allNodeToInfo()); + doTestFetchAll(fromAllQuery("*:%mode%*", """ + , _id, _ignored, _index_mode, _score, _source, _version + | LIMIT 1000 + """), remoteNodeToInfo(), allNodeToInfo()); } } diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 6336319dbd99c..29b00a380dfb2 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -18,6 +18,7 @@ import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; import org.elasticsearch.common.Strings; +import org.elasticsearch.common.logging.LoggerMessageFormat; import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.mapper.MappedFieldType; @@ -208,6 +209,9 @@ protected static Map fetchNodeToInfo(RestClient client, String return nodeToInfo; } + protected static final String ENRICH_POLICY_NAME = "all_fields_policy"; + protected static final String LOOKUP_INDEX_NAME = "all_fields_lookup_index"; + @Before public void createIndices() throws IOException { if (supportsNodeAssignment()) { @@ -217,6 +221,14 @@ public void createIndices() throws IOException { } else { createIndexForNode(client(), null, null, indexMode); } + + // We need a single lookup index that has the same name across all clusters, as well as a single enrich policy per cluster. + // We create both only when we're testing LOOKUP mode. + if (indexExists(LOOKUP_INDEX_NAME) == false && indexMode == IndexMode.LOOKUP) { + createAllTypesIndex(client(), LOOKUP_INDEX_NAME, null, indexMode); + createAllTypesDoc(client(), LOOKUP_INDEX_NAME); + createEnrichPolicy(client(), LOOKUP_INDEX_NAME, ENRICH_POLICY_NAME); + } } /** @@ -236,18 +248,32 @@ public void skipSnapshots() throws IOException { } public final void testFetchAll() throws IOException { - doTestFetchAll("*:%mode%*,%mode%*", allNodeToInfo(), allNodeToInfo()); + doTestFetchAll(fromAllQuery(""" + , _id, _ignored, _index_mode, _score, _source, _version + | LIMIT 1000 + """), allNodeToInfo(), allNodeToInfo()); + } + + public final void testFetchAllEnrich() throws IOException { + assumeTrue("Test only requires the enrich policy (made from a lookup index)", indexMode == IndexMode.LOOKUP); + // The ENRICH is a no-op because it overwrites columns with the same identical data (except that it messes with + // the order of the columns, but we don't assert that). + doTestFetchAll(fromAllQuery(LoggerMessageFormat.format(null, """ + , _id, _ignored, _index_mode, _score, _source, _version + | ENRICH _remote:{} ON {} + | LIMIT 1000 + """, ENRICH_POLICY_NAME, LOOKUP_ID_FIELD)), allNodeToInfo(), allNodeToInfo()); } + /** + * Runs the query and expects 1 document per index on the contributing nodes as well as all the columns. + */ protected final void doTestFetchAll( - String indexPattern, + String query, Map nodesContributingIndices, Map nodesInvolvedInExecution ) throws IOException { - var responseAndCoordinatorVersion = runFromAllQuery(indexPattern, """ - , _id, _ignored, _index_mode, _score, _source, _version - | LIMIT 1000 - """); + var responseAndCoordinatorVersion = runQuery(query); Map response = responseAndCoordinatorVersion.v1(); TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); @@ -276,11 +302,15 @@ protected final void doTestFetchAll( } assertMap(indexToRow(columns, values), expectedAllValues); - assertMinimumVersion(minVersion(nodesInvolvedInExecution), responseAndCoordinatorVersion); + assertMinimumVersion(minVersion(nodesInvolvedInExecution), responseAndCoordinatorVersion, true, fetchAllIsCrossCluster()); profileLogger.clearProfile(); } + protected boolean fetchAllIsCrossCluster() { + return false; + } + protected static void assertNoPartialResponse(Map response) { if ((Boolean) response.get("is_partial")) { throw new AssertionError("partial results: " + response); @@ -369,10 +399,10 @@ public final void testFetchDenseVector() throws IOException { | EVAL k = v_l2_norm(f_dense_vector, [1]) // workaround to enable fetching dense_vector """ + request; } - var responseAndCoordinatorVersion = runFromAllQuery(request); + var responseAndCoordinatorVersion = runQuery(fromAllQuery(request)); assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); - response = runFromAllQuery(request).v1(); + response = runQuery(fromAllQuery(request)).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -438,10 +468,10 @@ public final void testFetchAggregateMetricDouble() throws IOException { | EVAL junk = TO_AGGREGATE_METRIC_DOUBLE(1) // workaround to enable fetching aggregate_metric_double """ + request; } - var responseAndCoordinatorVersion = runFromAllQuery(request); + var responseAndCoordinatorVersion = runQuery(fromAllQuery(request)); assertMinimumVersionFromAllQueries(responseAndCoordinatorVersion); - response = runFromAllQuery(request).v1(); + response = runQuery(fromAllQuery(request)).v1(); if ((Boolean) response.get("is_partial")) { Map clusters = (Map) response.get("_clusters"); Map details = (Map) clusters.get("details"); @@ -493,12 +523,12 @@ public final void testFetchAggregateMetricDouble() throws IOException { assertMap(indexToRow(columns, values), expectedAllValues); } - private Tuple, TransportVersion> runFromAllQuery(String restOfQuery) throws IOException { - return runFromAllQuery("*:%mode%*,%mode%*", restOfQuery); + protected String fromAllQuery(String indexPattern, String restOfQuery) { + return ("FROM " + indexPattern + " METADATA _index").replace("%mode%", indexMode.toString()) + restOfQuery; } - private Tuple, TransportVersion> runFromAllQuery(String indexPattern, String restOfQuery) throws IOException { - return runQuery(("FROM " + indexPattern + " METADATA _index").replace("%mode%", indexMode.toString()) + restOfQuery); + protected String fromAllQuery(String restOfQuery) { + return fromAllQuery("*:%mode%*,%mode%*", restOfQuery); } public void testRow() throws IOException { @@ -509,100 +539,92 @@ public void testRow() throws IOException { String query = "ROW x = 1 | LIMIT 1"; var responseAndCoordinatorVersion = runQuery(query); - assertMinimumVersion(minVersion(localNodeToInfo()), responseAndCoordinatorVersion, false); + assertMinimumVersion(minVersion(localNodeToInfo()), responseAndCoordinatorVersion, false, false); } @SuppressWarnings("unchecked") public void testRowLookupJoin() throws IOException { - assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); - Map expectedIndices = expectedIndices(IndexMode.LOOKUP, localNodeToInfo()); - for (Map.Entry e : expectedIndices.entrySet()) { - String indexName = e.getKey(); - String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | LOOKUP JOIN " + indexName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; - var responseAndCoordinatorVersion = runQuery(query); - TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); + assumeTrue("Test only requires the lookup index", indexMode == IndexMode.LOOKUP); + String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | LOOKUP JOIN " + LOOKUP_INDEX_NAME + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; + var responseAndCoordinatorVersion = runQuery(query); + TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); - assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, false, false); - Map response = responseAndCoordinatorVersion.v1(); - TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - assertNoPartialResponse(response); + assertNoPartialResponse(response); - List columns = (List) response.get("columns"); - List values = (List) response.get("values"); + List columns = (List) response.get("columns"); + List values = (List) response.get("values"); - MapMatcher expectedColumns = allTypesColumnsMatcher( - coordinatorVersion, - expectedMinimumVersion, - indexMode, - extractPreference, - false, - true - ); - assertMap(nameToType(columns), expectedColumns); + MapMatcher expectedColumns = allTypesColumnsMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + true + ); + assertMap(nameToType(columns), expectedColumns); - MapMatcher expectedValues = allTypesValuesMatcher( - coordinatorVersion, - expectedMinimumVersion, - indexMode, - extractPreference, - false, - true, - null - ); - assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); - } + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + true, + null + ); + assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); } @SuppressWarnings("unchecked") public void testRowEnrich() throws IOException { - assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); - Map expectedIndices = expectedIndices(IndexMode.LOOKUP, localNodeToInfo()); - for (Map.Entry e : expectedIndices.entrySet()) { - String policyName = e.getKey() + "_policy"; - String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | ENRICH " + policyName + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; - var responseAndCoordinatorVersion = runQuery(query); - Map response = responseAndCoordinatorVersion.v1(); - TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); - TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); - - Map profile = (Map) response.get("profile"); - Integer actualMinimumVersion = (Integer) profile.get("minimumTransportVersion"); - if (minVersion(localNodeToInfo()).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) - // Some nodes don't send back the minimum transport version because they're too old to do that. - // In this case, the determined minimum version will be that of the coordinator. - || (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) - && actualMinimumVersion != coordinatorVersion.id())) { - assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion); - } + assumeTrue("Test only requires the enrich policy (made from a lookup index)", indexMode == IndexMode.LOOKUP); + String query = "ROW " + LOOKUP_ID_FIELD + " = 123 | ENRICH " + ENRICH_POLICY_NAME + " ON " + LOOKUP_ID_FIELD + " | LIMIT 1"; + var responseAndCoordinatorVersion = runQuery(query); + Map response = responseAndCoordinatorVersion.v1(); + TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); + TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); + + Map profile = (Map) response.get("profile"); + Integer actualMinimumVersion = (Integer) profile.get("minimumTransportVersion"); + if (minVersion(localNodeToInfo()).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) + // Some nodes don't send back the minimum transport version because they're too old to do that. + // In this case, the determined minimum version will be that of the coordinator. + || (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) + && actualMinimumVersion != coordinatorVersion.id())) { + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, false, false); + } - assertNoPartialResponse(response); + assertNoPartialResponse(response); - List columns = (List) response.get("columns"); - List values = (List) response.get("values"); + List columns = (List) response.get("columns"); + List values = (List) response.get("values"); - MapMatcher expectedColumns = allTypesColumnsMatcher( - coordinatorVersion, - expectedMinimumVersion, - indexMode, - extractPreference, - false, - false - ); - assertMap(nameToType(columns), expectedColumns); + MapMatcher expectedColumns = allTypesColumnsMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + false + ); + assertMap(nameToType(columns), expectedColumns); - MapMatcher expectedValues = allTypesValuesMatcher( - coordinatorVersion, - expectedMinimumVersion, - indexMode, - extractPreference, - false, - false, - null - ); - assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); - } + MapMatcher expectedValues = allTypesValuesMatcher( + coordinatorVersion, + expectedMinimumVersion, + indexMode, + extractPreference, + false, + false, + null + ); + assertMap(nameToValue(names(columns), (List) values.getFirst()), expectedValues); } /** @@ -640,26 +662,20 @@ private Tuple, TransportVersion> runQuery(String query) thro protected void assertMinimumVersionFromAllQueries(Tuple, TransportVersion> responseAndCoordinatorVersion) throws IOException { - assertMinimumVersion(minVersion(), responseAndCoordinatorVersion); - } - - protected void assertMinimumVersion( - TransportVersion expectedMinimumVersion, - Tuple, TransportVersion> responseAndCoordinatorVersion - ) { - assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, true); + assertMinimumVersion(minVersion(), responseAndCoordinatorVersion, true, fetchAllIsCrossCluster()); } /** * @param expectedMinimumVersion the minimum version of all clusters that participate in the query - * @param performsAnyFieldCapsRequest {@code true} for queries that have any {@code FROM, ENRICH} or {@code LOOKUP JOIN} commands. - * Simple {@code ROW} queries are executed only on the coordinator and don't perform field caps requests. + * @param performsMainFieldCapsRequest {@code true} for queries that have a {@code FROM} command, so we don't retrieve the minimum + * version from the main field caps response. */ @SuppressWarnings("unchecked") protected void assertMinimumVersion( TransportVersion expectedMinimumVersion, Tuple, TransportVersion> responseAndCoordinatorVersion, - boolean performsAnyFieldCapsRequest + boolean performsMainFieldCapsRequest, + boolean isCrossCluster ) { var responseMap = responseAndCoordinatorVersion.v1(); var coordinatorVersion = responseAndCoordinatorVersion.v2(); @@ -669,14 +685,16 @@ protected void assertMinimumVersion( Integer minimumVersion = (Integer) profile.get("minimumTransportVersion"); assertNotNull(minimumVersion); int minimumVersionInt = minimumVersion; - if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV) || (performsAnyFieldCapsRequest == false)) { + if (expectedMinimumVersion.supports(RESOLVE_FIELDS_RESPONSE_CREATED_TV) + || (performsMainFieldCapsRequest == false) + || (isCrossCluster == false)) { assertEquals(expectedMinimumVersion.id(), minimumVersionInt); } else { - // One node is old enough that it doesn't provide version information in the field caps response. We must assume - // the oldest compatible version. - // Since we got minimumVersion in the profile, the coordinator is on a new version. - // Thus, it's on the same version as this code (bwc tests only use 2 different versions) and the oldest compatible version - // to the coordinator is given by TransportVersion.minimumCompatible(). + // If a remote cluster is old enough that it doesn't provide version information in the field caps response, the coordinator + // HAS to assume the oldest compatible version. + // This only applies to multi-cluster tests; if we're looking at a mixed cluster, the coordinator is new enough + // that it's field caps response will include the min cluster version. (Apparently the field caps request is performed + // directly on the coordinator.) assertEquals(TransportVersion.minimumCompatible().id(), minimumVersionInt); } } @@ -687,12 +705,6 @@ protected static void createIndexForNode(RestClient client, String nodeName, Str if (false == indexExists(client, indexName)) { createAllTypesIndex(client, indexName, nodeId, mode); createAllTypesDoc(client, indexName); - // We create an enrich policy for each lookup index. That's a bit of an arbitrary choice, but it's probably a good idea to - // create 1 enrich policy per node, so we potentially detect misbehavior that stems from enrich policies being based on indices - // that live on newer or older nodes. - if (mode == IndexMode.LOOKUP) { - createEnrichPolicy(client, indexName); - } } } @@ -706,7 +718,7 @@ protected static String indexName(IndexMode mode, String nodeName) { private static final String LOOKUP_ID_FIELD = "lookup_id"; - private static void createAllTypesIndex(RestClient client, String indexName, String nodeId, IndexMode mode) throws IOException { + protected static void createAllTypesIndex(RestClient client, String indexName, String nodeId, IndexMode mode) throws IOException { XContentBuilder config = JsonXContent.contentBuilder().startObject(); { config.startObject("settings"); @@ -767,7 +779,7 @@ private static void typeMapping(IndexMode indexMode, XContentBuilder config, Dat } } - private static void createAllTypesDoc(RestClient client, String indexName) throws IOException { + protected static void createAllTypesDoc(RestClient client, String indexName) throws IOException { XContentBuilder doc = JsonXContent.contentBuilder().startObject(); doc.field(LOOKUP_ID_FIELD); doc.value(123); @@ -805,9 +817,7 @@ private static void createAllTypesDoc(RestClient client, String indexName) throw client.performRequest(request); } - private static void createEnrichPolicy(RestClient client, String indexName) throws IOException { - String policyName = indexName + "_policy"; - + protected static void createEnrichPolicy(RestClient client, String indexName, String policyName) throws IOException { XContentBuilder policyConfig = JsonXContent.contentBuilder().startObject(); { policyConfig.startObject("match"); From 4dfbb65ee73d619cdc3960951c2a7e12be82a863 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 18:57:37 +0100 Subject: [PATCH 47/50] Add test for FROM | LOOKUP JOIN --- .../esql/qa/rest/AllSupportedFieldsTestCase.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 29b00a380dfb2..8811ea1f7349e 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -265,6 +265,17 @@ public final void testFetchAllEnrich() throws IOException { """, ENRICH_POLICY_NAME, LOOKUP_ID_FIELD)), allNodeToInfo(), allNodeToInfo()); } + public final void testFetchAllLookupJoin() throws IOException { + assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); + // The LOOKUP JOIN is a no-op because it overwrites columns with the same identical data (except that it messes with + // the order of the columns, but we don't assert that). + doTestFetchAll(fromAllQuery(LoggerMessageFormat.format(null, """ + , _id, _ignored, _index_mode, _score, _source, _version + | LOOKUP JOIN {} ON {} + | LIMIT 1000 + """, LOOKUP_INDEX_NAME, LOOKUP_ID_FIELD)), allNodeToInfo(), allNodeToInfo()); + } + /** * Runs the query and expects 1 document per index on the contributing nodes as well as all the columns. */ From 59e0ef70c4e918dc93b3b56fe2faaf7840298fa8 Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 19:00:55 +0100 Subject: [PATCH 48/50] Force lookup joins onto remotes for good measure --- .../xpack/esql/qa/rest/AllSupportedFieldsTestCase.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 8811ea1f7349e..932e877705171 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -269,9 +269,11 @@ public final void testFetchAllLookupJoin() throws IOException { assumeTrue("Test only requires lookup indices", indexMode == IndexMode.LOOKUP); // The LOOKUP JOIN is a no-op because it overwrites columns with the same identical data (except that it messes with // the order of the columns, but we don't assert that). + // We force the lookup join on to the remotes by having a SORT after it. doTestFetchAll(fromAllQuery(LoggerMessageFormat.format(null, """ , _id, _ignored, _index_mode, _score, _source, _version | LOOKUP JOIN {} ON {} + | SORT _id | LIMIT 1000 """, LOOKUP_INDEX_NAME, LOOKUP_ID_FIELD)), allNodeToInfo(), allNodeToInfo()); } From 87f4fa08e80eb8eec8af6504944369fd583e156f Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 28 Nov 2025 19:12:58 +0100 Subject: [PATCH 49/50] Make assertion stricter in test --- .../xpack/esql/qa/rest/AllSupportedFieldsTestCase.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java index 932e877705171..93174773998ee 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/AllSupportedFieldsTestCase.java @@ -603,15 +603,7 @@ public void testRowEnrich() throws IOException { TransportVersion coordinatorVersion = responseAndCoordinatorVersion.v2(); TransportVersion expectedMinimumVersion = minVersion(localNodeToInfo()); - Map profile = (Map) response.get("profile"); - Integer actualMinimumVersion = (Integer) profile.get("minimumTransportVersion"); - if (minVersion(localNodeToInfo()).supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) - // Some nodes don't send back the minimum transport version because they're too old to do that. - // In this case, the determined minimum version will be that of the coordinator. - || (coordinatorVersion.supports(ESQL_USE_MINIMUM_VERSION_FOR_ENRICH_RESOLUTION) - && actualMinimumVersion != coordinatorVersion.id())) { - assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, false, false); - } + assertMinimumVersion(expectedMinimumVersion, responseAndCoordinatorVersion, false, false); assertNoPartialResponse(response); From f3559dbab9a1598c8ec73fd23896f0882866b6c6 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Tue, 2 Dec 2025 11:42:22 +0000 Subject: [PATCH 50/50] [CI] Update transport version definitions --- .../esql_use_minimum_version_for_enrich_resolution.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv index f0acd98436f8e..58fae920e0a63 100644 --- a/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv +++ b/server/src/main/resources/transport/definitions/referable/esql_use_minimum_version_for_enrich_resolution.csv @@ -1 +1 @@ -9231000 +9231000,9185011 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 5b4cb63943755..6f76f928f4bb0 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -aggregate_metric_double_typed_block,9185010 +esql_use_minimum_version_for_enrich_resolution,9185011