From 5c928a431671fd2789c9d58fd26a0e48cb7d6f92 Mon Sep 17 00:00:00 2001
From: Nhat Nguyen
Date: Wed, 27 Nov 2024 07:27:21 -0800
Subject: [PATCH 01/39] Emit deprecation warnings only for new index or
template (#117529)
Currently, we emit a deprecation warning in the parser of the source
field when source mode is used in mappings. However, this behavior
causes warnings to be emitted for every mapping update. In tests with
assertions enabled, warnings are also triggered for every change to
index metadata. As a result, deprecation warnings are inadvertently
emitted for index or update requests.
This change relocates the deprecation check to the mapper, limiting it
to cases where a new index is created or a template is created/updated.
Relates to #117524
---
.../index/mapper/MappingParser.java | 9 +++++++++
.../index/mapper/SourceFieldMapper.java | 14 +-------------
.../mapper/DocumentParserContextTests.java | 1 -
.../index/mapper/SourceFieldMapperTests.java | 17 +----------------
.../index/shard/ShardGetServiceTests.java | 2 --
5 files changed, 11 insertions(+), 32 deletions(-)
diff --git a/server/src/main/java/org/elasticsearch/index/mapper/MappingParser.java b/server/src/main/java/org/elasticsearch/index/mapper/MappingParser.java
index f30a0089e4eff..2ca14473c8385 100644
--- a/server/src/main/java/org/elasticsearch/index/mapper/MappingParser.java
+++ b/server/src/main/java/org/elasticsearch/index/mapper/MappingParser.java
@@ -10,6 +10,8 @@
package org.elasticsearch.index.mapper;
import org.elasticsearch.common.compress.CompressedXContent;
+import org.elasticsearch.common.logging.DeprecationCategory;
+import org.elasticsearch.common.logging.DeprecationLogger;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.index.mapper.MapperService.MergeReason;
@@ -31,6 +33,7 @@ public final class MappingParser {
private final Supplier, MetadataFieldMapper>> metadataMappersSupplier;
private final Map metadataMapperParsers;
private final Function documentTypeResolver;
+ private static final DeprecationLogger deprecationLogger = DeprecationLogger.getLogger(MappingParser.class);
MappingParser(
Supplier mappingParserContextSupplier,
@@ -144,6 +147,12 @@ Mapping parse(@Nullable String type, MergeReason reason, Map map
}
@SuppressWarnings("unchecked")
Map fieldNodeMap = (Map) fieldNode;
+ if (reason == MergeReason.INDEX_TEMPLATE
+ && SourceFieldMapper.NAME.equals(fieldName)
+ && fieldNodeMap.containsKey("mode")
+ && SourceFieldMapper.onOrAfterDeprecateModeVersion(mappingParserContext.indexVersionCreated())) {
+ deprecationLogger.critical(DeprecationCategory.MAPPINGS, "mapping_source_mode", SourceFieldMapper.DEPRECATION_WARNING);
+ }
MetadataFieldMapper metadataFieldMapper = typeParser.parse(fieldName, fieldNodeMap, mappingParserContext).build();
metadataMappers.put(metadataFieldMapper.getClass(), metadataFieldMapper);
assert fieldNodeMap.isEmpty();
diff --git a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java
index e7c7ec3535b91..b97e04fcddb5d 100644
--- a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java
+++ b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java
@@ -18,7 +18,6 @@
import org.elasticsearch.common.Explicit;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.bytes.BytesReference;
-import org.elasticsearch.common.logging.DeprecationCategory;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.CollectionUtils;
@@ -40,7 +39,6 @@
import java.util.Collections;
import java.util.List;
import java.util.Locale;
-import java.util.Map;
public class SourceFieldMapper extends MetadataFieldMapper {
public static final NodeFeature SYNTHETIC_SOURCE_FALLBACK = new NodeFeature("mapper.source.synthetic_source_fallback");
@@ -310,17 +308,7 @@ private static SourceFieldMapper resolveStaticInstance(final Mode sourceMode) {
c.indexVersionCreated().onOrAfter(IndexVersions.SOURCE_MAPPER_LOSSY_PARAMS_CHECK),
onOrAfterDeprecateModeVersion(c.indexVersionCreated()) == false
)
- ) {
- @Override
- public MetadataFieldMapper.Builder parse(String name, Map node, MappingParserContext parserContext)
- throws MapperParsingException {
- assert name.equals(SourceFieldMapper.NAME) : name;
- if (onOrAfterDeprecateModeVersion(parserContext.indexVersionCreated()) && node.containsKey("mode")) {
- deprecationLogger.critical(DeprecationCategory.MAPPINGS, "mapping_source_mode", SourceFieldMapper.DEPRECATION_WARNING);
- }
- return super.parse(name, node, parserContext);
- }
- };
+ );
static final class SourceFieldType extends MappedFieldType {
private final boolean enabled;
diff --git a/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java b/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java
index a4108caaf4fc3..be36ab9d6eac1 100644
--- a/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java
+++ b/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java
@@ -133,6 +133,5 @@ public void testCreateDynamicMapperBuilderContext() throws IOException {
assertEquals(ObjectMapper.Defaults.DYNAMIC, resultFromParserContext.getDynamic());
assertEquals(MapperService.MergeReason.MAPPING_UPDATE, resultFromParserContext.getMergeReason());
assertFalse(resultFromParserContext.isInNestedContext());
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
}
diff --git a/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java b/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java
index fa173bc64518e..4d6a30849e263 100644
--- a/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java
+++ b/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java
@@ -65,7 +65,6 @@ protected void registerParameters(ParameterChecker checker) throws IOException {
topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()),
dm -> {
assertTrue(dm.metadataMapper(SourceFieldMapper.class).isSynthetic());
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
);
checker.registerConflictCheck("includes", b -> b.array("includes", "foo*"));
@@ -74,7 +73,7 @@ protected void registerParameters(ParameterChecker checker) throws IOException {
"mode",
topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()),
topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject()),
- dm -> assertWarnings(SourceFieldMapper.DEPRECATION_WARNING)
+ d -> {}
);
}
@@ -211,14 +210,12 @@ public void testSyntheticDisabledNotSupported() {
)
);
assertThat(e.getMessage(), containsString("Cannot set both [mode] and [enabled] parameters"));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
public void testSyntheticUpdates() throws Exception {
MapperService mapperService = createMapperService("""
{ "_doc" : { "_source" : { "mode" : "synthetic" } } }
""");
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
SourceFieldMapper mapper = mapperService.documentMapper().sourceMapper();
assertTrue(mapper.enabled());
assertTrue(mapper.isSynthetic());
@@ -226,7 +223,6 @@ public void testSyntheticUpdates() throws Exception {
merge(mapperService, """
{ "_doc" : { "_source" : { "mode" : "synthetic" } } }
""");
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
mapper = mapperService.documentMapper().sourceMapper();
assertTrue(mapper.enabled());
assertTrue(mapper.isSynthetic());
@@ -239,12 +235,10 @@ public void testSyntheticUpdates() throws Exception {
"""));
assertThat(e.getMessage(), containsString("Cannot update parameter [mode] from [synthetic] to [stored]"));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
merge(mapperService, """
{ "_doc" : { "_source" : { "mode" : "disabled" } } }
""");
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
mapper = mapperService.documentMapper().sourceMapper();
assertFalse(mapper.enabled());
@@ -281,7 +275,6 @@ public void testSupportsNonDefaultParameterValues() throws IOException {
topMapping(b -> b.startObject("_source").field("mode", randomBoolean() ? "synthetic" : "stored").endObject())
).documentMapper().sourceMapper();
assertThat(sourceFieldMapper, notNullValue());
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
Exception e = expectThrows(
MapperParsingException.class,
@@ -313,8 +306,6 @@ public void testSupportsNonDefaultParameterValues() throws IOException {
.documentMapper()
.sourceMapper()
);
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
-
assertThat(e.getMessage(), containsString("Parameter [mode=disabled] is not allowed in source"));
e = expectThrows(
@@ -423,7 +414,6 @@ public void testRecoverySourceWithSyntheticSource() throws IOException {
ParsedDocument doc = docMapper.parse(source(b -> { b.field("field1", "value1"); }));
assertNotNull(doc.rootDoc().getField("_recovery_source"));
assertThat(doc.rootDoc().getField("_recovery_source").binaryValue(), equalTo(new BytesRef("{\"field1\":\"value1\"}")));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
{
Settings settings = Settings.builder().put(INDICES_RECOVERY_SOURCE_ENABLED_SETTING.getKey(), false).build();
@@ -434,7 +424,6 @@ public void testRecoverySourceWithSyntheticSource() throws IOException {
DocumentMapper docMapper = mapperService.documentMapper();
ParsedDocument doc = docMapper.parse(source(b -> b.field("field1", "value1")));
assertNull(doc.rootDoc().getField("_recovery_source"));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
}
@@ -629,7 +618,6 @@ public void testRecoverySourceWithLogsCustom() throws IOException {
ParsedDocument doc = docMapper.parse(source(b -> { b.field("@timestamp", "2012-02-13"); }));
assertNotNull(doc.rootDoc().getField("_recovery_source"));
assertThat(doc.rootDoc().getField("_recovery_source").binaryValue(), equalTo(new BytesRef("{\"@timestamp\":\"2012-02-13\"}")));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
{
Settings settings = Settings.builder()
@@ -640,7 +628,6 @@ public void testRecoverySourceWithLogsCustom() throws IOException {
DocumentMapper docMapper = mapperService.documentMapper();
ParsedDocument doc = docMapper.parse(source(b -> b.field("@timestamp", "2012-02-13")));
assertNull(doc.rootDoc().getField("_recovery_source"));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
}
@@ -709,7 +696,6 @@ public void testRecoverySourceWithTimeSeriesCustom() throws IOException {
doc.rootDoc().getField("_recovery_source").binaryValue(),
equalTo(new BytesRef("{\"@timestamp\":\"2012-02-13\",\"field\":\"value1\"}"))
);
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
{
Settings settings = Settings.builder()
@@ -723,7 +709,6 @@ public void testRecoverySourceWithTimeSeriesCustom() throws IOException {
source("123", b -> b.field("@timestamp", "2012-02-13").field("field", randomAlphaOfLength(5)), null)
);
assertNull(doc.rootDoc().getField("_recovery_source"));
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
}
}
diff --git a/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java b/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java
index 307bc26c44ba6..a49d895f38f67 100644
--- a/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java
@@ -21,7 +21,6 @@
import org.elasticsearch.index.engine.VersionConflictEngineException;
import org.elasticsearch.index.get.GetResult;
import org.elasticsearch.index.mapper.RoutingFieldMapper;
-import org.elasticsearch.index.mapper.SourceFieldMapper;
import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
import org.elasticsearch.xcontent.XContentType;
@@ -115,7 +114,6 @@ public void testGetFromTranslogWithSyntheticSource() throws IOException {
"mode": "synthetic"
""";
runGetFromTranslogWithOptions(docToIndex, sourceOptions, expectedFetchedSource, "\"long\"", 7L, true);
- assertWarnings(SourceFieldMapper.DEPRECATION_WARNING);
}
public void testGetFromTranslogWithDenseVector() throws IOException {
From 418cbbf7b9f175ceba858a684215f42c55c9830e Mon Sep 17 00:00:00 2001
From: Jack Conradson
Date: Wed, 27 Nov 2024 07:56:54 -0800
Subject: [PATCH 02/39] Remove entitlement parameter (#117597)
Removes the "entitlement" parameter from policy parsing.
---
.../runtime/policy/PolicyParser.java | 13 --------
.../policy/PolicyParserFailureTests.java | 30 ++++++++-----------
.../runtime/policy/test-policy.yaml | 11 ++++---
3 files changed, 18 insertions(+), 36 deletions(-)
diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java
index 229ccec3b8b2c..ea6603af99925 100644
--- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java
+++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java
@@ -9,7 +9,6 @@
package org.elasticsearch.entitlement.runtime.policy;
-import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.XContentParser;
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.yaml.YamlXContent;
@@ -31,8 +30,6 @@
*/
public class PolicyParser {
- protected static final ParseField ENTITLEMENTS_PARSEFIELD = new ParseField("entitlements");
-
protected static final String entitlementPackageName = Entitlement.class.getPackage().getName();
protected final XContentParser policyParser;
@@ -65,13 +62,6 @@ public Policy parsePolicy() {
protected Scope parseScope(String scopeName) throws IOException {
try {
- if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
- throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
- }
- if (policyParser.nextToken() != XContentParser.Token.FIELD_NAME
- || policyParser.currentName().equals(ENTITLEMENTS_PARSEFIELD.getPreferredName()) == false) {
- throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
- }
if (policyParser.nextToken() != XContentParser.Token.START_ARRAY) {
throw newPolicyParserException(scopeName, "expected array of ");
}
@@ -90,9 +80,6 @@ protected Scope parseScope(String scopeName) throws IOException {
throw newPolicyParserException(scopeName, "expected closing object");
}
}
- if (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
- throw newPolicyParserException(scopeName, "expected closing object");
- }
return new Scope(scopeName, entitlements);
} catch (IOException ioe) {
throw new UncheckedIOException(ioe);
diff --git a/libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java
index b21d206f3eb6a..de8280ea87fe5 100644
--- a/libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java
+++ b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java
@@ -29,11 +29,10 @@ public void testParserSyntaxFailures() {
public void testEntitlementDoesNotExist() throws IOException {
PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
entitlement-module-name:
- entitlements:
- - does_not_exist: {}
+ - does_not_exist: {}
""".getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
assertEquals(
- "[3:7] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name]: "
+ "[2:5] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name]: "
+ "unknown entitlement type [does_not_exist]",
ppe.getMessage()
);
@@ -42,23 +41,21 @@ public void testEntitlementDoesNotExist() throws IOException {
public void testEntitlementMissingParameter() throws IOException {
PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
entitlement-module-name:
- entitlements:
- - file: {}
+ - file: {}
""".getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
assertEquals(
- "[3:14] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "[2:12] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "for entitlement type [file]: missing entitlement parameter [path]",
ppe.getMessage()
);
ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
entitlement-module-name:
- entitlements:
- - file:
- path: test-path
+ - file:
+ path: test-path
""".getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
assertEquals(
- "[5:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "[4:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "for entitlement type [file]: missing entitlement parameter [actions]",
ppe.getMessage()
);
@@ -67,15 +64,14 @@ public void testEntitlementMissingParameter() throws IOException {
public void testEntitlementExtraneousParameter() throws IOException {
PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
entitlement-module-name:
- entitlements:
- - file:
- path: test-path
- actions:
- - read
- extra: test
+ - file:
+ path: test-path
+ actions:
+ - read
+ extra: test
""".getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
assertEquals(
- "[8:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "[7:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+ "for entitlement type [file]: extraneous entitlement parameter(s) {extra=test}",
ppe.getMessage()
);
diff --git a/libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml b/libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml
index b58287cfc83b7..f13f574535bec 100644
--- a/libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml
+++ b/libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml
@@ -1,7 +1,6 @@
entitlement-module-name:
- entitlements:
- - file:
- path: "test/path/to/file"
- actions:
- - "read"
- - "write"
+ - file:
+ path: "test/path/to/file"
+ actions:
+ - "read"
+ - "write"
From 9022cccba7b617d6ccd0b2ec411dbd1aa6aff0c1 Mon Sep 17 00:00:00 2001
From: Nik Everett
Date: Wed, 27 Nov 2024 11:44:55 -0500
Subject: [PATCH 03/39] ESQL: CATEGORIZE as a BlockHash (#114317)
Re-implement `CATEGORIZE` in a way that works for multi-node clusters.
This requires that data is first categorized on each data node in a first pass, then the categorizers from each data node are merged on the coordinator node and previously categorized rows are re-categorized.
BlockHashes, used in HashAggregations, already work in a very similar way. E.g. for queries like `... | STATS ... BY field1, field2` they map values for `field1` and `field2` to unique integer ids that are then passed to the actual aggregate functions to identify which "bucket" a row belongs to. When passed from the data nodes to the coordinator, the BlockHashes are also merged to obtain unique ids for every value in `field1, field2` that is seen on the coordinator (not only on the local data nodes).
Therefore, we re-implement `CATEGORIZE` as a special BlockHash.
To choose the correct BlockHash when a query plan is mapped to physical operations, the `AggregateExec` query plan node needs to know that we will be categorizing the field `message` in a query containing `... | STATS ... BY c = CATEGORIZE(message)`. For this reason, _we do not extract the expression_ `c = CATEGORIZE(message)` into an `EVAL` node, in contrast to e.g. `STATS ... BY b = BUCKET(field, 10)`. The expression `c = CATEGORIZE(message)` simply remains inside the `AggregateExec`'s groupings.
**Important limitation:** For now, to use `CATEGORIZE` in a `STATS` command, there can be only 1 grouping (the `CATEGORIZE`) overall.
---
docs/changelog/114317.yaml | 5 +
.../kibana/definition/categorize.json | 4 +-
.../esql/functions/types/categorize.asciidoc | 4 +-
muted-tests.yml | 18 -
.../AbstractCategorizeBlockHash.java | 105 ++++
.../aggregation/blockhash/BlockHash.java | 28 +-
.../blockhash/CategorizeRawBlockHash.java | 137 +++++
.../CategorizedIntermediateBlockHash.java | 77 +++
.../operator/HashAggregationOperator.java | 9 +
.../GroupingAggregatorFunctionTestCase.java | 1 +
.../blockhash/BlockHashTestCase.java | 34 ++
.../aggregation/blockhash/BlockHashTests.java | 22 +-
.../blockhash/CategorizeBlockHashTests.java | 406 ++++++++++++++
.../HashAggregationOperatorTests.java | 1 +
.../xpack/esql/CsvTestsDataLoader.java | 2 +
.../src/main/resources/categorize.csv-spec | 526 +++++++++++++++++-
.../resources/mapping-mv_sample_data.json | 16 +
.../src/main/resources/mv_sample_data.csv | 8 +
.../grouping/CategorizeEvaluator.java | 145 -----
.../xpack/esql/action/EsqlCapabilities.java | 5 +-
.../function/grouping/Categorize.java | 76 +--
.../rules/logical/CombineProjections.java | 38 +-
.../optimizer/rules/logical/FoldNull.java | 2 +
...laceAggregateNestedExpressionWithEval.java | 31 +-
.../physical/local/InsertFieldExtraction.java | 17 +-
.../AbstractPhysicalOperationProviders.java | 42 +-
.../xpack/esql/analysis/VerifierTests.java | 6 +-
.../function/AbstractAggregationTestCase.java | 3 +-
.../function/AbstractFunctionTestCase.java | 19 +-
.../AbstractScalarFunctionTestCase.java | 1 +
.../expression/function/TestCaseSupplier.java | 83 ++-
.../function/grouping/CategorizeTests.java | 16 +-
.../optimizer/LogicalPlanOptimizerTests.java | 61 ++
.../rules/logical/FoldNullTests.java | 13 +
.../categorization/TokenListCategorizer.java | 24 +
35 files changed, 1660 insertions(+), 325 deletions(-)
create mode 100644 docs/changelog/114317.yaml
create mode 100644 x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/AbstractCategorizeBlockHash.java
create mode 100644 x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java
create mode 100644 x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java
create mode 100644 x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTestCase.java
create mode 100644 x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHashTests.java
create mode 100644 x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-mv_sample_data.json
create mode 100644 x-pack/plugin/esql/qa/testFixtures/src/main/resources/mv_sample_data.csv
delete mode 100644 x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeEvaluator.java
diff --git a/docs/changelog/114317.yaml b/docs/changelog/114317.yaml
new file mode 100644
index 0000000000000..9c73fe513e197
--- /dev/null
+++ b/docs/changelog/114317.yaml
@@ -0,0 +1,5 @@
+pr: 114317
+summary: "ESQL: CATEGORIZE as a `BlockHash`"
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/docs/reference/esql/functions/kibana/definition/categorize.json b/docs/reference/esql/functions/kibana/definition/categorize.json
index 386b178d3753f..ca3971a6e05a3 100644
--- a/docs/reference/esql/functions/kibana/definition/categorize.json
+++ b/docs/reference/esql/functions/kibana/definition/categorize.json
@@ -14,7 +14,7 @@
}
],
"variadic" : false,
- "returnType" : "integer"
+ "returnType" : "keyword"
},
{
"params" : [
@@ -26,7 +26,7 @@
}
],
"variadic" : false,
- "returnType" : "integer"
+ "returnType" : "keyword"
}
],
"preview" : false,
diff --git a/docs/reference/esql/functions/types/categorize.asciidoc b/docs/reference/esql/functions/types/categorize.asciidoc
index 4917ed313e6d7..5b64971cbc482 100644
--- a/docs/reference/esql/functions/types/categorize.asciidoc
+++ b/docs/reference/esql/functions/types/categorize.asciidoc
@@ -5,6 +5,6 @@
[%header.monospaced.styled,format=dsv,separator=|]
|===
field | result
-keyword | integer
-text | integer
+keyword | keyword
+text | keyword
|===
diff --git a/muted-tests.yml b/muted-tests.yml
index c97e46375c597..8b12bd2dd3365 100644
--- a/muted-tests.yml
+++ b/muted-tests.yml
@@ -67,9 +67,6 @@ tests:
- class: org.elasticsearch.xpack.transform.integration.TransformIT
method: testStopWaitForCheckpoint
issue: https://github.com/elastic/elasticsearch/issues/106113
-- class: org.elasticsearch.xpack.esql.qa.mixed.MixedClusterEsqlSpecIT
- method: test {categorize.Categorize SYNC}
- issue: https://github.com/elastic/elasticsearch/issues/113722
- class: org.elasticsearch.kibana.KibanaThreadPoolIT
method: testBlockedThreadPoolsRejectUserRequests
issue: https://github.com/elastic/elasticsearch/issues/113939
@@ -126,12 +123,6 @@ tests:
- class: org.elasticsearch.xpack.ml.integration.DatafeedJobsRestIT
method: testLookbackWithIndicesOptions
issue: https://github.com/elastic/elasticsearch/issues/116127
-- class: org.elasticsearch.xpack.esql.qa.multi_node.EsqlSpecIT
- method: test {categorize.Categorize SYNC}
- issue: https://github.com/elastic/elasticsearch/issues/113054
-- class: org.elasticsearch.xpack.esql.qa.multi_node.EsqlSpecIT
- method: test {categorize.Categorize ASYNC}
- issue: https://github.com/elastic/elasticsearch/issues/113055
- class: org.elasticsearch.xpack.test.rest.XPackRestIT
method: test {p0=transform/transforms_start_stop/Test start already started transform}
issue: https://github.com/elastic/elasticsearch/issues/98802
@@ -153,9 +144,6 @@ tests:
- class: org.elasticsearch.xpack.shutdown.NodeShutdownIT
method: testAllocationPreventedForRemoval
issue: https://github.com/elastic/elasticsearch/issues/116363
-- class: org.elasticsearch.xpack.esql.qa.mixed.MixedClusterEsqlSpecIT
- method: test {categorize.Categorize ASYNC}
- issue: https://github.com/elastic/elasticsearch/issues/116373
- class: org.elasticsearch.threadpool.SimpleThreadPoolIT
method: testThreadPoolMetrics
issue: https://github.com/elastic/elasticsearch/issues/108320
@@ -168,9 +156,6 @@ tests:
- class: org.elasticsearch.xpack.searchablesnapshots.SearchableSnapshotsCanMatchOnCoordinatorIntegTests
method: testSearchableSnapshotShardsAreSkippedBySearchRequestWithoutQueryingAnyNodeWhenTheyAreOutsideOfTheQueryRange
issue: https://github.com/elastic/elasticsearch/issues/116523
-- class: org.elasticsearch.xpack.esql.ccq.MultiClusterSpecIT
- method: test {categorize.Categorize}
- issue: https://github.com/elastic/elasticsearch/issues/116434
- class: org.elasticsearch.upgrades.SearchStatesIT
method: testBWCSearchStates
issue: https://github.com/elastic/elasticsearch/issues/116617
@@ -229,9 +214,6 @@ tests:
- class: org.elasticsearch.xpack.test.rest.XPackRestIT
method: test {p0=transform/transforms_reset/Test reset running transform}
issue: https://github.com/elastic/elasticsearch/issues/117473
-- class: org.elasticsearch.xpack.esql.qa.single_node.FieldExtractorIT
- method: testConstantKeywordField
- issue: https://github.com/elastic/elasticsearch/issues/117524
- class: org.elasticsearch.xpack.esql.qa.multi_node.FieldExtractorIT
method: testConstantKeywordField
issue: https://github.com/elastic/elasticsearch/issues/117524
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/AbstractCategorizeBlockHash.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/AbstractCategorizeBlockHash.java
new file mode 100644
index 0000000000000..22d3a10facb06
--- /dev/null
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/AbstractCategorizeBlockHash.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.compute.aggregation.blockhash;
+
+import org.apache.lucene.util.BytesRefBuilder;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.unit.ByteSizeValue;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.util.BitArray;
+import org.elasticsearch.common.util.BytesRefHash;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.core.ReleasableIterator;
+import org.elasticsearch.xpack.ml.aggs.categorization.CategorizationBytesRefHash;
+import org.elasticsearch.xpack.ml.aggs.categorization.CategorizationPartOfSpeechDictionary;
+import org.elasticsearch.xpack.ml.aggs.categorization.SerializableTokenListCategory;
+import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer;
+
+import java.io.IOException;
+
+/**
+ * Base BlockHash implementation for {@code Categorize} grouping function.
+ */
+public abstract class AbstractCategorizeBlockHash extends BlockHash {
+ // TODO: this should probably also take an emitBatchSize
+ private final int channel;
+ private final boolean outputPartial;
+ protected final TokenListCategorizer.CloseableTokenListCategorizer categorizer;
+
+ AbstractCategorizeBlockHash(BlockFactory blockFactory, int channel, boolean outputPartial) {
+ super(blockFactory);
+ this.channel = channel;
+ this.outputPartial = outputPartial;
+ this.categorizer = new TokenListCategorizer.CloseableTokenListCategorizer(
+ new CategorizationBytesRefHash(new BytesRefHash(2048, blockFactory.bigArrays())),
+ CategorizationPartOfSpeechDictionary.getInstance(),
+ 0.70f
+ );
+ }
+
+ protected int channel() {
+ return channel;
+ }
+
+ @Override
+ public Block[] getKeys() {
+ return new Block[] { outputPartial ? buildIntermediateBlock() : buildFinalBlock() };
+ }
+
+ @Override
+ public IntVector nonEmpty() {
+ return IntVector.range(0, categorizer.getCategoryCount(), blockFactory);
+ }
+
+ @Override
+ public BitArray seenGroupIds(BigArrays bigArrays) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public final ReleasableIterator lookup(Page page, ByteSizeValue targetBlockSize) {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Serializes the intermediate state into a single BytesRef block, or an empty Null block if there are no categories.
+ */
+ private Block buildIntermediateBlock() {
+ if (categorizer.getCategoryCount() == 0) {
+ return blockFactory.newConstantNullBlock(0);
+ }
+ try (BytesStreamOutput out = new BytesStreamOutput()) {
+ // TODO be more careful here.
+ out.writeVInt(categorizer.getCategoryCount());
+ for (SerializableTokenListCategory category : categorizer.toCategoriesById()) {
+ category.writeTo(out);
+ }
+ // We're returning a block with N positions just because the Page must have all blocks with the same position count!
+ return blockFactory.newConstantBytesRefBlockWith(out.bytes().toBytesRef(), categorizer.getCategoryCount());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Block buildFinalBlock() {
+ try (BytesRefVector.Builder result = blockFactory.newBytesRefVectorBuilder(categorizer.getCategoryCount())) {
+ BytesRefBuilder scratch = new BytesRefBuilder();
+ for (SerializableTokenListCategory category : categorizer.toCategoriesById()) {
+ scratch.copyChars(category.getRegex());
+ result.appendBytesRef(scratch.get());
+ scratch.clear();
+ }
+ return result.build().asBlock();
+ }
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/BlockHash.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/BlockHash.java
index 919cb92f79260..ef0f3ceb112c4 100644
--- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/BlockHash.java
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/BlockHash.java
@@ -14,6 +14,7 @@
import org.elasticsearch.common.util.Int3Hash;
import org.elasticsearch.common.util.LongHash;
import org.elasticsearch.common.util.LongLongHash;
+import org.elasticsearch.compute.aggregation.AggregatorMode;
import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
import org.elasticsearch.compute.aggregation.SeenGroupIds;
import org.elasticsearch.compute.data.Block;
@@ -58,9 +59,7 @@
* leave a big gap, even if we never see {@code null}.
*
*/
-public abstract sealed class BlockHash implements Releasable, SeenGroupIds //
- permits BooleanBlockHash, BytesRefBlockHash, DoubleBlockHash, IntBlockHash, LongBlockHash, BytesRef2BlockHash, BytesRef3BlockHash, //
- NullBlockHash, PackedValuesBlockHash, BytesRefLongBlockHash, LongLongBlockHash, TimeSeriesBlockHash {
+public abstract class BlockHash implements Releasable, SeenGroupIds {
protected final BlockFactory blockFactory;
@@ -107,7 +106,15 @@ public abstract sealed class BlockHash implements Releasable, SeenGroupIds //
@Override
public abstract BitArray seenGroupIds(BigArrays bigArrays);
- public record GroupSpec(int channel, ElementType elementType) {}
+ /**
+ * @param isCategorize Whether this group is a CATEGORIZE() or not.
+ * May be changed in the future when more stateful grouping functions are added.
+ */
+ public record GroupSpec(int channel, ElementType elementType, boolean isCategorize) {
+ public GroupSpec(int channel, ElementType elementType) {
+ this(channel, elementType, false);
+ }
+ }
/**
* Creates a specialized hash table that maps one or more {@link Block}s to ids.
@@ -159,6 +166,19 @@ public static BlockHash buildPackedValuesBlockHash(List groups, Block
return new PackedValuesBlockHash(groups, blockFactory, emitBatchSize);
}
+ /**
+ * Builds a BlockHash for the Categorize grouping function.
+ */
+ public static BlockHash buildCategorizeBlockHash(List groups, AggregatorMode aggregatorMode, BlockFactory blockFactory) {
+ if (groups.size() != 1) {
+ throw new IllegalArgumentException("only a single CATEGORIZE group can used");
+ }
+
+ return aggregatorMode.isInputPartial()
+ ? new CategorizedIntermediateBlockHash(groups.get(0).channel, blockFactory, aggregatorMode.isOutputPartial())
+ : new CategorizeRawBlockHash(groups.get(0).channel, blockFactory, aggregatorMode.isOutputPartial());
+ }
+
/**
* Creates a specialized hash table that maps a {@link Block} of the given input element type to ids.
*/
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java
new file mode 100644
index 0000000000000..bf633e0454384
--- /dev/null
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.compute.aggregation.blockhash;
+
+import org.apache.lucene.analysis.core.WhitespaceTokenizer;
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.core.Releasable;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.index.analysis.CharFilterFactory;
+import org.elasticsearch.index.analysis.CustomAnalyzer;
+import org.elasticsearch.index.analysis.TokenFilterFactory;
+import org.elasticsearch.index.analysis.TokenizerFactory;
+import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer;
+import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer;
+
+/**
+ * BlockHash implementation for {@code Categorize} grouping function.
+ *
+ * This implementation expects rows, and can't deserialize intermediate states coming from other nodes.
+ *
+ */
+public class CategorizeRawBlockHash extends AbstractCategorizeBlockHash {
+ private final CategorizeEvaluator evaluator;
+
+ CategorizeRawBlockHash(int channel, BlockFactory blockFactory, boolean outputPartial) {
+ super(blockFactory, channel, outputPartial);
+ CategorizationAnalyzer analyzer = new CategorizationAnalyzer(
+ // TODO: should be the same analyzer as used in Production
+ new CustomAnalyzer(
+ TokenizerFactory.newFactory("whitespace", WhitespaceTokenizer::new),
+ new CharFilterFactory[0],
+ new TokenFilterFactory[0]
+ ),
+ true
+ );
+ this.evaluator = new CategorizeEvaluator(analyzer, categorizer, blockFactory);
+ }
+
+ @Override
+ public void add(Page page, GroupingAggregatorFunction.AddInput addInput) {
+ try (IntBlock result = (IntBlock) evaluator.eval(page.getBlock(channel()))) {
+ addInput.add(0, result);
+ }
+ }
+
+ @Override
+ public void close() {
+ evaluator.close();
+ }
+
+ /**
+ * Similar implementation to an Evaluator.
+ */
+ public static final class CategorizeEvaluator implements Releasable {
+ private final CategorizationAnalyzer analyzer;
+
+ private final TokenListCategorizer.CloseableTokenListCategorizer categorizer;
+
+ private final BlockFactory blockFactory;
+
+ public CategorizeEvaluator(
+ CategorizationAnalyzer analyzer,
+ TokenListCategorizer.CloseableTokenListCategorizer categorizer,
+ BlockFactory blockFactory
+ ) {
+ this.analyzer = analyzer;
+ this.categorizer = categorizer;
+ this.blockFactory = blockFactory;
+ }
+
+ public Block eval(BytesRefBlock vBlock) {
+ BytesRefVector vVector = vBlock.asVector();
+ if (vVector == null) {
+ return eval(vBlock.getPositionCount(), vBlock);
+ }
+ IntVector vector = eval(vBlock.getPositionCount(), vVector);
+ return vector.asBlock();
+ }
+
+ public IntBlock eval(int positionCount, BytesRefBlock vBlock) {
+ try (IntBlock.Builder result = blockFactory.newIntBlockBuilder(positionCount)) {
+ BytesRef vScratch = new BytesRef();
+ for (int p = 0; p < positionCount; p++) {
+ if (vBlock.isNull(p)) {
+ result.appendNull();
+ continue;
+ }
+ int first = vBlock.getFirstValueIndex(p);
+ int count = vBlock.getValueCount(p);
+ if (count == 1) {
+ result.appendInt(process(vBlock.getBytesRef(first, vScratch)));
+ continue;
+ }
+ int end = first + count;
+ result.beginPositionEntry();
+ for (int i = first; i < end; i++) {
+ result.appendInt(process(vBlock.getBytesRef(i, vScratch)));
+ }
+ result.endPositionEntry();
+ }
+ return result.build();
+ }
+ }
+
+ public IntVector eval(int positionCount, BytesRefVector vVector) {
+ try (IntVector.FixedBuilder result = blockFactory.newIntVectorFixedBuilder(positionCount)) {
+ BytesRef vScratch = new BytesRef();
+ for (int p = 0; p < positionCount; p++) {
+ result.appendInt(p, process(vVector.getBytesRef(p, vScratch)));
+ }
+ return result.build();
+ }
+ }
+
+ private int process(BytesRef v) {
+ return categorizer.computeCategory(v.utf8ToString(), analyzer).getId();
+ }
+
+ @Override
+ public void close() {
+ Releasables.closeExpectNoException(analyzer, categorizer);
+ }
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java
new file mode 100644
index 0000000000000..1bca34a70e5fa
--- /dev/null
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.compute.aggregation.blockhash;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.xpack.ml.aggs.categorization.SerializableTokenListCategory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * BlockHash implementation for {@code Categorize} grouping function.
+ *
+ * This implementation expects a single intermediate state in a block, as generated by {@link AbstractCategorizeBlockHash}.
+ *
+ */
+public class CategorizedIntermediateBlockHash extends AbstractCategorizeBlockHash {
+
+ CategorizedIntermediateBlockHash(int channel, BlockFactory blockFactory, boolean outputPartial) {
+ super(blockFactory, channel, outputPartial);
+ }
+
+ @Override
+ public void add(Page page, GroupingAggregatorFunction.AddInput addInput) {
+ if (page.getPositionCount() == 0) {
+ // No categories
+ return;
+ }
+ BytesRefBlock categorizerState = page.getBlock(channel());
+ Map idMap = readIntermediate(categorizerState.getBytesRef(0, new BytesRef()));
+ try (IntBlock.Builder newIdsBuilder = blockFactory.newIntBlockBuilder(idMap.size())) {
+ for (int i = 0; i < idMap.size(); i++) {
+ newIdsBuilder.appendInt(idMap.get(i));
+ }
+ try (IntBlock newIds = newIdsBuilder.build()) {
+ addInput.add(0, newIds);
+ }
+ }
+ }
+
+ /**
+ * Read intermediate state from a block.
+ *
+ * @return a map from the old category id to the new one. The old ids go from 0 to {@code size - 1}.
+ */
+ private Map readIntermediate(BytesRef bytes) {
+ Map idMap = new HashMap<>();
+ try (StreamInput in = new BytesArray(bytes).streamInput()) {
+ int count = in.readVInt();
+ for (int oldCategoryId = 0; oldCategoryId < count; oldCategoryId++) {
+ int newCategoryId = categorizer.mergeWireCategory(new SerializableTokenListCategory(in)).getId();
+ idMap.put(oldCategoryId, newCategoryId);
+ }
+ return idMap;
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void close() {
+ categorizer.close();
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/HashAggregationOperator.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/HashAggregationOperator.java
index 03a4ca2b0ad5e..a69e8ca767014 100644
--- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/HashAggregationOperator.java
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/HashAggregationOperator.java
@@ -14,6 +14,7 @@
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.compute.Describable;
+import org.elasticsearch.compute.aggregation.AggregatorMode;
import org.elasticsearch.compute.aggregation.GroupingAggregator;
import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
import org.elasticsearch.compute.aggregation.blockhash.BlockHash;
@@ -39,11 +40,19 @@ public class HashAggregationOperator implements Operator {
public record HashAggregationOperatorFactory(
List groups,
+ AggregatorMode aggregatorMode,
List aggregators,
int maxPageSize
) implements OperatorFactory {
@Override
public Operator get(DriverContext driverContext) {
+ if (groups.stream().anyMatch(BlockHash.GroupSpec::isCategorize)) {
+ return new HashAggregationOperator(
+ aggregators,
+ () -> BlockHash.buildCategorizeBlockHash(groups, aggregatorMode, driverContext.blockFactory()),
+ driverContext
+ );
+ }
return new HashAggregationOperator(
aggregators,
() -> BlockHash.build(groups, driverContext.blockFactory(), maxPageSize, false),
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/GroupingAggregatorFunctionTestCase.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/GroupingAggregatorFunctionTestCase.java
index cb190dfffafb9..1e97bdf5a2e79 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/GroupingAggregatorFunctionTestCase.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/GroupingAggregatorFunctionTestCase.java
@@ -105,6 +105,7 @@ private Operator.OperatorFactory simpleWithMode(
}
return new HashAggregationOperator.HashAggregationOperatorFactory(
List.of(new BlockHash.GroupSpec(0, ElementType.LONG)),
+ mode,
List.of(supplier.groupingAggregatorFactory(mode)),
randomPageSize()
);
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTestCase.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTestCase.java
new file mode 100644
index 0000000000000..fa93c0aa1c375
--- /dev/null
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTestCase.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.compute.aggregation.blockhash;
+
+import org.elasticsearch.common.breaker.CircuitBreaker;
+import org.elasticsearch.common.unit.ByteSizeValue;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.util.MockBigArrays;
+import org.elasticsearch.common.util.PageCacheRecycler;
+import org.elasticsearch.compute.data.MockBlockFactory;
+import org.elasticsearch.indices.breaker.CircuitBreakerService;
+import org.elasticsearch.test.ESTestCase;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public abstract class BlockHashTestCase extends ESTestCase {
+
+ final CircuitBreaker breaker = newLimitedBreaker(ByteSizeValue.ofGb(1));
+ final BigArrays bigArrays = new MockBigArrays(PageCacheRecycler.NON_RECYCLING_INSTANCE, mockBreakerService(breaker));
+ final MockBlockFactory blockFactory = new MockBlockFactory(breaker, bigArrays);
+
+ // A breaker service that always returns the given breaker for getBreaker(CircuitBreaker.REQUEST)
+ private static CircuitBreakerService mockBreakerService(CircuitBreaker breaker) {
+ CircuitBreakerService breakerService = mock(CircuitBreakerService.class);
+ when(breakerService.getBreaker(CircuitBreaker.REQUEST)).thenReturn(breaker);
+ return breakerService;
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTests.java
index 088e791348840..ede2d68ca2367 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTests.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/BlockHashTests.java
@@ -11,11 +11,7 @@
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import org.apache.lucene.util.BytesRef;
-import org.elasticsearch.common.breaker.CircuitBreaker;
import org.elasticsearch.common.unit.ByteSizeValue;
-import org.elasticsearch.common.util.BigArrays;
-import org.elasticsearch.common.util.MockBigArrays;
-import org.elasticsearch.common.util.PageCacheRecycler;
import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
import org.elasticsearch.compute.data.Block;
import org.elasticsearch.compute.data.BooleanBlock;
@@ -26,7 +22,6 @@
import org.elasticsearch.compute.data.IntBlock;
import org.elasticsearch.compute.data.IntVector;
import org.elasticsearch.compute.data.LongBlock;
-import org.elasticsearch.compute.data.MockBlockFactory;
import org.elasticsearch.compute.data.OrdinalBytesRefBlock;
import org.elasticsearch.compute.data.OrdinalBytesRefVector;
import org.elasticsearch.compute.data.Page;
@@ -34,8 +29,6 @@
import org.elasticsearch.core.Releasable;
import org.elasticsearch.core.ReleasableIterator;
import org.elasticsearch.core.Releasables;
-import org.elasticsearch.indices.breaker.CircuitBreakerService;
-import org.elasticsearch.test.ESTestCase;
import org.junit.After;
import java.util.ArrayList;
@@ -54,14 +47,8 @@
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-public class BlockHashTests extends ESTestCase {
-
- final CircuitBreaker breaker = new MockBigArrays.LimitedBreaker("esql-test-breaker", ByteSizeValue.ofGb(1));
- final BigArrays bigArrays = new MockBigArrays(PageCacheRecycler.NON_RECYCLING_INSTANCE, mockBreakerService(breaker));
- final MockBlockFactory blockFactory = new MockBlockFactory(breaker, bigArrays);
+public class BlockHashTests extends BlockHashTestCase {
@ParametersFactory
public static List params() {
@@ -1534,13 +1521,6 @@ private void assertKeys(Block[] actualKeys, Object[][] expectedKeys) {
}
}
- // A breaker service that always returns the given breaker for getBreaker(CircuitBreaker.REQUEST)
- static CircuitBreakerService mockBreakerService(CircuitBreaker breaker) {
- CircuitBreakerService breakerService = mock(CircuitBreakerService.class);
- when(breakerService.getBreaker(CircuitBreaker.REQUEST)).thenReturn(breaker);
- return breakerService;
- }
-
IntVector intRange(int startInclusive, int endExclusive) {
return IntVector.range(startInclusive, endExclusive, TestBlockFactory.getNonBreakingInstance());
}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHashTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHashTests.java
new file mode 100644
index 0000000000000..de8a2a44266fe
--- /dev/null
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHashTests.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.compute.aggregation.blockhash;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.breaker.CircuitBreaker;
+import org.elasticsearch.common.collect.Iterators;
+import org.elasticsearch.common.unit.ByteSizeValue;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.util.MockBigArrays;
+import org.elasticsearch.common.util.PageCacheRecycler;
+import org.elasticsearch.compute.aggregation.AggregatorMode;
+import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction;
+import org.elasticsearch.compute.aggregation.MaxLongAggregatorFunctionSupplier;
+import org.elasticsearch.compute.aggregation.SumLongAggregatorFunctionSupplier;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.ElementType;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.data.LongVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.CannedSourceOperator;
+import org.elasticsearch.compute.operator.Driver;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.HashAggregationOperator;
+import org.elasticsearch.compute.operator.LocalSourceOperator;
+import org.elasticsearch.compute.operator.PageConsumerOperator;
+import org.elasticsearch.core.Releasables;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.elasticsearch.compute.operator.OperatorTestCase.runDriver;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+
+public class CategorizeBlockHashTests extends BlockHashTestCase {
+
+ public void testCategorizeRaw() {
+ final Page page;
+ final int positions = 7;
+ try (BytesRefBlock.Builder builder = blockFactory.newBytesRefBlockBuilder(positions)) {
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.1"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Disconnected"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.2"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.3"));
+ page = new Page(builder.build());
+ }
+
+ try (BlockHash hash = new CategorizeRawBlockHash(0, blockFactory, true)) {
+ hash.add(page, new GroupingAggregatorFunction.AddInput() {
+ @Override
+ public void add(int positionOffset, IntBlock groupIds) {
+ assertEquals(groupIds.getPositionCount(), positions);
+
+ assertEquals(0, groupIds.getInt(0));
+ assertEquals(1, groupIds.getInt(1));
+ assertEquals(1, groupIds.getInt(2));
+ assertEquals(1, groupIds.getInt(3));
+ assertEquals(2, groupIds.getInt(4));
+ assertEquals(0, groupIds.getInt(5));
+ assertEquals(0, groupIds.getInt(6));
+ }
+
+ @Override
+ public void add(int positionOffset, IntVector groupIds) {
+ add(positionOffset, groupIds.asBlock());
+ }
+
+ @Override
+ public void close() {
+ fail("hashes should not close AddInput");
+ }
+ });
+ } finally {
+ page.releaseBlocks();
+ }
+
+ // TODO: randomize and try multiple pages.
+ // TODO: assert the state of the BlockHash after adding pages. Including the categorizer state.
+ // TODO: also test the lookup method and other stuff.
+ }
+
+ public void testCategorizeIntermediate() {
+ Page page1;
+ int positions1 = 7;
+ try (BytesRefBlock.Builder builder = blockFactory.newBytesRefBlockBuilder(positions1)) {
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.1"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.2"));
+ builder.appendBytesRef(new BytesRef("Connection error"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.3"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.1.0.4"));
+ page1 = new Page(builder.build());
+ }
+ Page page2;
+ int positions2 = 5;
+ try (BytesRefBlock.Builder builder = blockFactory.newBytesRefBlockBuilder(positions2)) {
+ builder.appendBytesRef(new BytesRef("Disconnected"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.2.0.1"));
+ builder.appendBytesRef(new BytesRef("Disconnected"));
+ builder.appendBytesRef(new BytesRef("Connected to 10.3.0.2"));
+ builder.appendBytesRef(new BytesRef("System shutdown"));
+ page2 = new Page(builder.build());
+ }
+
+ Page intermediatePage1, intermediatePage2;
+
+ // Fill intermediatePages with the intermediate state from the raw hashes
+ try (
+ BlockHash rawHash1 = new CategorizeRawBlockHash(0, blockFactory, true);
+ BlockHash rawHash2 = new CategorizeRawBlockHash(0, blockFactory, true)
+ ) {
+ rawHash1.add(page1, new GroupingAggregatorFunction.AddInput() {
+ @Override
+ public void add(int positionOffset, IntBlock groupIds) {
+ assertEquals(groupIds.getPositionCount(), positions1);
+ assertEquals(0, groupIds.getInt(0));
+ assertEquals(1, groupIds.getInt(1));
+ assertEquals(1, groupIds.getInt(2));
+ assertEquals(0, groupIds.getInt(3));
+ assertEquals(1, groupIds.getInt(4));
+ assertEquals(0, groupIds.getInt(5));
+ assertEquals(0, groupIds.getInt(6));
+ }
+
+ @Override
+ public void add(int positionOffset, IntVector groupIds) {
+ add(positionOffset, groupIds.asBlock());
+ }
+
+ @Override
+ public void close() {
+ fail("hashes should not close AddInput");
+ }
+ });
+ intermediatePage1 = new Page(rawHash1.getKeys()[0]);
+
+ rawHash2.add(page2, new GroupingAggregatorFunction.AddInput() {
+ @Override
+ public void add(int positionOffset, IntBlock groupIds) {
+ assertEquals(groupIds.getPositionCount(), positions2);
+ assertEquals(0, groupIds.getInt(0));
+ assertEquals(1, groupIds.getInt(1));
+ assertEquals(0, groupIds.getInt(2));
+ assertEquals(1, groupIds.getInt(3));
+ assertEquals(2, groupIds.getInt(4));
+ }
+
+ @Override
+ public void add(int positionOffset, IntVector groupIds) {
+ add(positionOffset, groupIds.asBlock());
+ }
+
+ @Override
+ public void close() {
+ fail("hashes should not close AddInput");
+ }
+ });
+ intermediatePage2 = new Page(rawHash2.getKeys()[0]);
+ } finally {
+ page1.releaseBlocks();
+ page2.releaseBlocks();
+ }
+
+ try (BlockHash intermediateHash = new CategorizedIntermediateBlockHash(0, blockFactory, true)) {
+ intermediateHash.add(intermediatePage1, new GroupingAggregatorFunction.AddInput() {
+ @Override
+ public void add(int positionOffset, IntBlock groupIds) {
+ Set values = IntStream.range(0, groupIds.getPositionCount())
+ .map(groupIds::getInt)
+ .boxed()
+ .collect(Collectors.toSet());
+ assertEquals(values, Set.of(0, 1));
+ }
+
+ @Override
+ public void add(int positionOffset, IntVector groupIds) {
+ add(positionOffset, groupIds.asBlock());
+ }
+
+ @Override
+ public void close() {
+ fail("hashes should not close AddInput");
+ }
+ });
+
+ intermediateHash.add(intermediatePage2, new GroupingAggregatorFunction.AddInput() {
+ @Override
+ public void add(int positionOffset, IntBlock groupIds) {
+ Set values = IntStream.range(0, groupIds.getPositionCount())
+ .map(groupIds::getInt)
+ .boxed()
+ .collect(Collectors.toSet());
+ // The category IDs {0, 1, 2} should map to groups {0, 2, 3}, because
+ // 0 matches an existing category (Connected to ...), and the others are new.
+ assertEquals(values, Set.of(0, 2, 3));
+ }
+
+ @Override
+ public void add(int positionOffset, IntVector groupIds) {
+ add(positionOffset, groupIds.asBlock());
+ }
+
+ @Override
+ public void close() {
+ fail("hashes should not close AddInput");
+ }
+ });
+ } finally {
+ intermediatePage1.releaseBlocks();
+ intermediatePage2.releaseBlocks();
+ }
+ }
+
+ public void testCategorize_withDriver() {
+ BigArrays bigArrays = new MockBigArrays(PageCacheRecycler.NON_RECYCLING_INSTANCE, ByteSizeValue.ofMb(256)).withCircuitBreaking();
+ CircuitBreaker breaker = bigArrays.breakerService().getBreaker(CircuitBreaker.REQUEST);
+ DriverContext driverContext = new DriverContext(bigArrays, new BlockFactory(breaker, bigArrays));
+
+ LocalSourceOperator.BlockSupplier input1 = () -> {
+ try (
+ BytesRefVector.Builder textsBuilder = driverContext.blockFactory().newBytesRefVectorBuilder(10);
+ LongVector.Builder countsBuilder = driverContext.blockFactory().newLongVectorBuilder(10)
+ ) {
+ textsBuilder.appendBytesRef(new BytesRef("a"));
+ textsBuilder.appendBytesRef(new BytesRef("b"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words goodbye jan"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words goodbye nik"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words goodbye tom"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words hello jan"));
+ textsBuilder.appendBytesRef(new BytesRef("c"));
+ textsBuilder.appendBytesRef(new BytesRef("d"));
+ countsBuilder.appendLong(1);
+ countsBuilder.appendLong(2);
+ countsBuilder.appendLong(800);
+ countsBuilder.appendLong(80);
+ countsBuilder.appendLong(8000);
+ countsBuilder.appendLong(900);
+ countsBuilder.appendLong(30);
+ countsBuilder.appendLong(4);
+ return new Block[] { textsBuilder.build().asBlock(), countsBuilder.build().asBlock() };
+ }
+ };
+ LocalSourceOperator.BlockSupplier input2 = () -> {
+ try (
+ BytesRefVector.Builder textsBuilder = driverContext.blockFactory().newBytesRefVectorBuilder(10);
+ LongVector.Builder countsBuilder = driverContext.blockFactory().newLongVectorBuilder(10)
+ ) {
+ textsBuilder.appendBytesRef(new BytesRef("words words words hello nik"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words hello nik"));
+ textsBuilder.appendBytesRef(new BytesRef("c"));
+ textsBuilder.appendBytesRef(new BytesRef("words words words goodbye chris"));
+ textsBuilder.appendBytesRef(new BytesRef("d"));
+ textsBuilder.appendBytesRef(new BytesRef("e"));
+ countsBuilder.appendLong(9);
+ countsBuilder.appendLong(90);
+ countsBuilder.appendLong(3);
+ countsBuilder.appendLong(8);
+ countsBuilder.appendLong(40);
+ countsBuilder.appendLong(5);
+ return new Block[] { textsBuilder.build().asBlock(), countsBuilder.build().asBlock() };
+ }
+ };
+
+ List intermediateOutput = new ArrayList<>();
+
+ Driver driver = new Driver(
+ driverContext,
+ new LocalSourceOperator(input1),
+ List.of(
+ new HashAggregationOperator.HashAggregationOperatorFactory(
+ List.of(makeGroupSpec()),
+ AggregatorMode.INITIAL,
+ List.of(
+ new SumLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL),
+ new MaxLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL)
+ ),
+ 16 * 1024
+ ).get(driverContext)
+ ),
+ new PageConsumerOperator(intermediateOutput::add),
+ () -> {}
+ );
+ runDriver(driver);
+
+ driver = new Driver(
+ driverContext,
+ new LocalSourceOperator(input2),
+ List.of(
+ new HashAggregationOperator.HashAggregationOperatorFactory(
+ List.of(makeGroupSpec()),
+ AggregatorMode.INITIAL,
+ List.of(
+ new SumLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL),
+ new MaxLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL)
+ ),
+ 16 * 1024
+ ).get(driverContext)
+ ),
+ new PageConsumerOperator(intermediateOutput::add),
+ () -> {}
+ );
+ runDriver(driver);
+
+ List finalOutput = new ArrayList<>();
+
+ driver = new Driver(
+ driverContext,
+ new CannedSourceOperator(intermediateOutput.iterator()),
+ List.of(
+ new HashAggregationOperator.HashAggregationOperatorFactory(
+ List.of(makeGroupSpec()),
+ AggregatorMode.FINAL,
+ List.of(
+ new SumLongAggregatorFunctionSupplier(List.of(1, 2)).groupingAggregatorFactory(AggregatorMode.FINAL),
+ new MaxLongAggregatorFunctionSupplier(List.of(3, 4)).groupingAggregatorFactory(AggregatorMode.FINAL)
+ ),
+ 16 * 1024
+ ).get(driverContext)
+ ),
+ new PageConsumerOperator(finalOutput::add),
+ () -> {}
+ );
+ runDriver(driver);
+
+ assertThat(finalOutput, hasSize(1));
+ assertThat(finalOutput.get(0).getBlockCount(), equalTo(3));
+ BytesRefBlock outputTexts = finalOutput.get(0).getBlock(0);
+ LongBlock outputSums = finalOutput.get(0).getBlock(1);
+ LongBlock outputMaxs = finalOutput.get(0).getBlock(2);
+ assertThat(outputSums.getPositionCount(), equalTo(outputTexts.getPositionCount()));
+ assertThat(outputMaxs.getPositionCount(), equalTo(outputTexts.getPositionCount()));
+ Map sums = new HashMap<>();
+ Map maxs = new HashMap<>();
+ for (int i = 0; i < outputTexts.getPositionCount(); i++) {
+ sums.put(outputTexts.getBytesRef(i, new BytesRef()).utf8ToString(), outputSums.getLong(i));
+ maxs.put(outputTexts.getBytesRef(i, new BytesRef()).utf8ToString(), outputMaxs.getLong(i));
+ }
+ assertThat(
+ sums,
+ equalTo(
+ Map.of(
+ ".*?a.*?",
+ 1L,
+ ".*?b.*?",
+ 2L,
+ ".*?c.*?",
+ 33L,
+ ".*?d.*?",
+ 44L,
+ ".*?e.*?",
+ 5L,
+ ".*?words.+?words.+?words.+?goodbye.*?",
+ 8888L,
+ ".*?words.+?words.+?words.+?hello.*?",
+ 999L
+ )
+ )
+ );
+ assertThat(
+ maxs,
+ equalTo(
+ Map.of(
+ ".*?a.*?",
+ 1L,
+ ".*?b.*?",
+ 2L,
+ ".*?c.*?",
+ 30L,
+ ".*?d.*?",
+ 40L,
+ ".*?e.*?",
+ 5L,
+ ".*?words.+?words.+?words.+?goodbye.*?",
+ 8000L,
+ ".*?words.+?words.+?words.+?hello.*?",
+ 900L
+ )
+ )
+ );
+ Releasables.close(() -> Iterators.map(finalOutput.iterator(), (Page p) -> p::releaseBlocks));
+ }
+
+ private BlockHash.GroupSpec makeGroupSpec() {
+ return new BlockHash.GroupSpec(0, ElementType.BYTES_REF, true);
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/HashAggregationOperatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/HashAggregationOperatorTests.java
index f2fa94c1feb08..b2f4ad594936e 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/HashAggregationOperatorTests.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/HashAggregationOperatorTests.java
@@ -54,6 +54,7 @@ protected Operator.OperatorFactory simpleWithMode(AggregatorMode mode) {
return new HashAggregationOperator.HashAggregationOperatorFactory(
List.of(new BlockHash.GroupSpec(0, ElementType.LONG)),
+ mode,
List.of(
new SumLongAggregatorFunctionSupplier(sumChannels).groupingAggregatorFactory(mode),
new MaxLongAggregatorFunctionSupplier(maxChannels).groupingAggregatorFactory(mode)
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java
index ffbac2829ea4a..9c987a02aca2d 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java
@@ -61,6 +61,7 @@ public class CsvTestsDataLoader {
private static final TestsDataset ALERTS = new TestsDataset("alerts");
private static final TestsDataset UL_LOGS = new TestsDataset("ul_logs");
private static final TestsDataset SAMPLE_DATA = new TestsDataset("sample_data");
+ private static final TestsDataset MV_SAMPLE_DATA = new TestsDataset("mv_sample_data");
private static final TestsDataset SAMPLE_DATA_STR = SAMPLE_DATA.withIndex("sample_data_str")
.withTypeMapping(Map.of("client_ip", "keyword"));
private static final TestsDataset SAMPLE_DATA_TS_LONG = SAMPLE_DATA.withIndex("sample_data_ts_long")
@@ -104,6 +105,7 @@ public class CsvTestsDataLoader {
Map.entry(LANGUAGES_LOOKUP.indexName, LANGUAGES_LOOKUP),
Map.entry(UL_LOGS.indexName, UL_LOGS),
Map.entry(SAMPLE_DATA.indexName, SAMPLE_DATA),
+ Map.entry(MV_SAMPLE_DATA.indexName, MV_SAMPLE_DATA),
Map.entry(ALERTS.indexName, ALERTS),
Map.entry(SAMPLE_DATA_STR.indexName, SAMPLE_DATA_STR),
Map.entry(SAMPLE_DATA_TS_LONG.indexName, SAMPLE_DATA_TS_LONG),
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/categorize.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/categorize.csv-spec
index 8e0fcd78f0322..89d9026423204 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/categorize.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/categorize.csv-spec
@@ -1,14 +1,524 @@
-categorize
-required_capability: categorize
+standard aggs
+required_capability: categorize_v2
FROM sample_data
- | SORT message ASC
- | STATS count=COUNT(), values=MV_SORT(VALUES(message)) BY category=CATEGORIZE(message)
+ | STATS count=COUNT(),
+ sum=SUM(event_duration),
+ avg=AVG(event_duration),
+ count_distinct=COUNT_DISTINCT(event_duration)
+ BY category=CATEGORIZE(message)
+ | SORT count DESC, category
+;
+
+count:long | sum:long | avg:double | count_distinct:long | category:keyword
+ 3 | 7971589 | 2657196.3333333335 | 3 | .*?Connected.+?to.*?
+ 3 | 14027356 | 4675785.333333333 | 3 | .*?Connection.+?error.*?
+ 1 | 1232382 | 1232382.0 | 1 | .*?Disconnected.*?
+;
+
+values aggs
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS values=MV_SORT(VALUES(message)),
+ top=TOP(event_duration, 2, "DESC")
+ BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+values:keyword | top:long | category:keyword
+[Connected to 10.1.0.1, Connected to 10.1.0.2, Connected to 10.1.0.3] | [3450233, 2764889] | .*?Connected.+?to.*?
+[Connection error] | [8268153, 5033755] | .*?Connection.+?error.*?
+[Disconnected] | 1232382 | .*?Disconnected.*?
+;
+
+mv
+required_capability: categorize_v2
+
+FROM mv_sample_data
+ | STATS COUNT(), SUM(event_duration) BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | SUM(event_duration):long | category:keyword
+ 7 | 23231327 | .*?Banana.*?
+ 3 | 7971589 | .*?Connected.+?to.*?
+ 3 | 14027356 | .*?Connection.+?error.*?
+ 1 | 1232382 | .*?Disconnected.*?
+;
+
+row mv
+required_capability: categorize_v2
+
+ROW message = ["connected to a", "connected to b", "disconnected"], str = ["a", "b", "c"]
+ | STATS COUNT(), VALUES(str) BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | VALUES(str):keyword | category:keyword
+ 2 | [a, b, c] | .*?connected.+?to.*?
+ 1 | [a, b, c] | .*?disconnected.*?
+;
+
+with multiple indices
+required_capability: categorize_v2
+required_capability: union_types
+
+FROM sample_data*
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 12 | .*?Connected.+?to.*?
+ 12 | .*?Connection.+?error.*?
+ 4 | .*?Disconnected.*?
+;
+
+mv with many values
+required_capability: categorize_v2
+
+FROM employees
+ | STATS COUNT() BY category=CATEGORIZE(job_positions)
+ | SORT category
+ | LIMIT 5
+;
+
+COUNT():long | category:keyword
+ 18 | .*?Accountant.*?
+ 13 | .*?Architect.*?
+ 11 | .*?Business.+?Analyst.*?
+ 13 | .*?Data.+?Scientist.*?
+ 10 | .*?Head.+?Human.+?Resources.*?
+;
+
+# Throws when calling AbstractCategorizeBlockHash.seenGroupIds() - Requires nulls support?
+mv with many values-Ignore
+required_capability: categorize_v2
+
+FROM employees
+ | STATS SUM(languages) BY category=CATEGORIZE(job_positions)
+ | SORT category DESC
+ | LIMIT 3
+;
+
+SUM(languages):integer | category:keyword
+ 43 | .*?Accountant.*?
+ 46 | .*?Architect.*?
+ 35 | .*?Business.+?Analyst.*?
+;
+
+mv via eval
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL message = MV_APPEND(message, "Banana")
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | .*?Banana.*?
+ 3 | .*?Connected.+?to.*?
+ 3 | .*?Connection.+?error.*?
+ 1 | .*?Disconnected.*?
+;
+
+mv via eval const
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL message = ["Banana", "Bread"]
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | .*?Banana.*?
+ 7 | .*?Bread.*?
+;
+
+mv via eval const without aliases
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL message = ["Banana", "Bread"]
+ | STATS COUNT() BY CATEGORIZE(message)
+ | SORT `CATEGORIZE(message)`
+;
+
+COUNT():long | CATEGORIZE(message):keyword
+ 7 | .*?Banana.*?
+ 7 | .*?Bread.*?
+;
+
+mv const in parameter
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY c = CATEGORIZE(["Banana", "Bread"])
+ | SORT c
+;
+
+COUNT():long | c:keyword
+ 7 | .*?Banana.*?
+ 7 | .*?Bread.*?
+;
+
+agg alias shadowing
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS c = COUNT() BY c = CATEGORIZE(["Banana", "Bread"])
+ | SORT c
+;
+
+warning:Line 2:9: Field 'c' shadowed by field at line 2:24
+
+c:keyword
+.*?Banana.*?
+.*?Bread.*?
+;
+
+chained aggregations using categorize
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | STATS COUNT() BY category=CATEGORIZE(category)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 1 | .*?\.\*\?Connected\.\+\?to\.\*\?.*?
+ 1 | .*?\.\*\?Connection\.\+\?error\.\*\?.*?
+ 1 | .*?\.\*\?Disconnected\.\*\?.*?
+;
+
+stats without aggs
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+category:keyword
+.*?Connected.+?to.*?
+.*?Connection.+?error.*?
+.*?Disconnected.*?
+;
+
+text field
+required_capability: categorize_v2
+
+FROM hosts
+ | STATS COUNT() BY category=CATEGORIZE(host_group)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 2 | .*?DB.+?servers.*?
+ 2 | .*?Gateway.+?instances.*?
+ 5 | .*?Kubernetes.+?cluster.*?
+;
+
+on TO_UPPER
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(TO_UPPER(message))
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 3 | .*?CONNECTED.+?TO.*?
+ 3 | .*?CONNECTION.+?ERROR.*?
+ 1 | .*?DISCONNECTED.*?
+;
+
+on CONCAT
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " banana"))
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 3 | .*?Connected.+?to.+?banana.*?
+ 3 | .*?Connection.+?error.+?banana.*?
+ 1 | .*?Disconnected.+?banana.*?
+;
+
+on CONCAT with unicode
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " 👍🏽😊"))
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 3 | .*?Connected.+?to.+?👍🏽😊.*?
+ 3 | .*?Connection.+?error.+?👍🏽😊.*?
+ 1 | .*?Disconnected.+?👍🏽😊.*?
+;
+
+on REVERSE(CONCAT())
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(REVERSE(CONCAT(message, " 👍🏽😊")))
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 1 | .*?😊👍🏽.+?detcennocsiD.*?
+ 3 | .*?😊👍🏽.+?ot.+?detcennoC.*?
+ 3 | .*?😊👍🏽.+?rorre.+?noitcennoC.*?
+;
+
+and then TO_LOWER
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | EVAL category=TO_LOWER(category)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 3 | .*?connected.+?to.*?
+ 3 | .*?connection.+?error.*?
+ 1 | .*?disconnected.*?
+;
+
+# Throws NPE - Requires nulls support
+on const empty string-Ignore
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE("")
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | .*?.*?
+;
+
+# Throws NPE - Requires nulls support
+on const empty string from eval-Ignore
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL x = ""
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | .*?.*?
+;
+
+# Doesn't give the correct results - Requires nulls support
+on null-Ignore
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL x = null
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | null
+;
+
+# Doesn't give the correct results - Requires nulls support
+on null string-Ignore
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL x = null::string
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+ 7 | null
+;
+
+filtering out all data
+required_capability: categorize_v2
+
+FROM sample_data
+ | WHERE @timestamp < "2023-10-23T00:00:00Z"
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | SORT category
+;
+
+COUNT():long | category:keyword
+;
+
+filtering out all data with constant
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | WHERE false
+;
+
+COUNT():long | category:keyword
+;
+
+drop output columns
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS count=COUNT() BY category=CATEGORIZE(message)
+ | EVAL x=1
+ | DROP count, category
+;
+
+x:integer
+1
+1
+1
+;
+
+category value processing
+required_capability: categorize_v2
+
+ROW message = ["connected to a", "connected to b", "disconnected"]
+ | STATS COUNT() BY category=CATEGORIZE(message)
+ | EVAL category = TO_UPPER(category)
| SORT category
;
-count:long | values:keyword | category:integer
-3 | [Connected to 10.1.0.1, Connected to 10.1.0.2, Connected to 10.1.0.3] | 0
-3 | [Connection error] | 1
-1 | [Disconnected] | 2
+COUNT():long | category:keyword
+ 2 | .*?CONNECTED.+?TO.*?
+ 1 | .*?DISCONNECTED.*?
+;
+
+row aliases
+required_capability: categorize_v2
+
+ROW message = "connected to a"
+ | EVAL x = message
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | EVAL y = category
+ | SORT y
+;
+
+COUNT():long | category:keyword | y:keyword
+ 1 | .*?connected.+?to.+?a.*? | .*?connected.+?to.+?a.*?
+;
+
+from aliases
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL x = message
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | EVAL y = category
+ | SORT y
+;
+
+COUNT():long | category:keyword | y:keyword
+ 3 | .*?Connected.+?to.*? | .*?Connected.+?to.*?
+ 3 | .*?Connection.+?error.*? | .*?Connection.+?error.*?
+ 1 | .*?Disconnected.*? | .*?Disconnected.*?
+;
+
+row aliases with keep
+required_capability: categorize_v2
+
+ROW message = "connected to a"
+ | EVAL x = message
+ | KEEP x
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | EVAL y = category
+ | KEEP `COUNT()`, y
+ | SORT y
+;
+
+COUNT():long | y:keyword
+ 1 | .*?connected.+?to.+?a.*?
+;
+
+from aliases with keep
+required_capability: categorize_v2
+
+FROM sample_data
+ | EVAL x = message
+ | KEEP x
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | EVAL y = category
+ | KEEP `COUNT()`, y
+ | SORT y
+;
+
+COUNT():long | y:keyword
+ 3 | .*?Connected.+?to.*?
+ 3 | .*?Connection.+?error.*?
+ 1 | .*?Disconnected.*?
+;
+
+row rename
+required_capability: categorize_v2
+
+ROW message = "connected to a"
+ | RENAME message as x
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | RENAME category as y
+ | SORT y
+;
+
+COUNT():long | y:keyword
+ 1 | .*?connected.+?to.+?a.*?
+;
+
+from rename
+required_capability: categorize_v2
+
+FROM sample_data
+ | RENAME message as x
+ | STATS COUNT() BY category=CATEGORIZE(x)
+ | RENAME category as y
+ | SORT y
+;
+
+COUNT():long | y:keyword
+ 3 | .*?Connected.+?to.*?
+ 3 | .*?Connection.+?error.*?
+ 1 | .*?Disconnected.*?
+;
+
+row drop
+required_capability: categorize_v2
+
+ROW message = "connected to a"
+ | STATS c = COUNT() BY category=CATEGORIZE(message)
+ | DROP category
+ | SORT c
+;
+
+c:long
+1
+;
+
+from drop
+required_capability: categorize_v2
+
+FROM sample_data
+ | STATS c = COUNT() BY category=CATEGORIZE(message)
+ | DROP category
+ | SORT c
+;
+
+c:long
+1
+3
+3
;
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-mv_sample_data.json b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-mv_sample_data.json
new file mode 100644
index 0000000000000..838a8ba09b45a
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-mv_sample_data.json
@@ -0,0 +1,16 @@
+{
+ "properties": {
+ "@timestamp": {
+ "type": "date"
+ },
+ "client_ip": {
+ "type": "ip"
+ },
+ "event_duration": {
+ "type": "long"
+ },
+ "message": {
+ "type": "keyword"
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mv_sample_data.csv b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mv_sample_data.csv
new file mode 100644
index 0000000000000..c02a4a7a5845f
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mv_sample_data.csv
@@ -0,0 +1,8 @@
+@timestamp:date ,client_ip:ip,event_duration:long,message:keyword
+2023-10-23T13:55:01.543Z,172.21.3.15 ,1756467,[Connected to 10.1.0.1, Banana]
+2023-10-23T13:53:55.832Z,172.21.3.15 ,5033755,[Connection error, Banana]
+2023-10-23T13:52:55.015Z,172.21.3.15 ,8268153,[Connection error, Banana]
+2023-10-23T13:51:54.732Z,172.21.3.15 , 725448,[Connection error, Banana]
+2023-10-23T13:33:34.937Z,172.21.0.5 ,1232382,[Disconnected, Banana]
+2023-10-23T12:27:28.948Z,172.21.2.113,2764889,[Connected to 10.1.0.2, Banana]
+2023-10-23T12:15:03.360Z,172.21.2.162,3450233,[Connected to 10.1.0.3, Banana]
diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeEvaluator.java
deleted file mode 100644
index c6349907f9b4b..0000000000000
--- a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeEvaluator.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
-// or more contributor license agreements. Licensed under the Elastic License
-// 2.0; you may not use this file except in compliance with the Elastic License
-// 2.0.
-package org.elasticsearch.xpack.esql.expression.function.grouping;
-
-import java.lang.IllegalArgumentException;
-import java.lang.Override;
-import java.lang.String;
-import java.util.function.Function;
-import org.apache.lucene.util.BytesRef;
-import org.elasticsearch.compute.data.Block;
-import org.elasticsearch.compute.data.BytesRefBlock;
-import org.elasticsearch.compute.data.BytesRefVector;
-import org.elasticsearch.compute.data.IntBlock;
-import org.elasticsearch.compute.data.IntVector;
-import org.elasticsearch.compute.data.Page;
-import org.elasticsearch.compute.operator.DriverContext;
-import org.elasticsearch.compute.operator.EvalOperator;
-import org.elasticsearch.compute.operator.Warnings;
-import org.elasticsearch.core.Releasables;
-import org.elasticsearch.xpack.esql.core.tree.Source;
-import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer;
-import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer;
-
-/**
- * {@link EvalOperator.ExpressionEvaluator} implementation for {@link Categorize}.
- * This class is generated. Do not edit it.
- */
-public final class CategorizeEvaluator implements EvalOperator.ExpressionEvaluator {
- private final Source source;
-
- private final EvalOperator.ExpressionEvaluator v;
-
- private final CategorizationAnalyzer analyzer;
-
- private final TokenListCategorizer.CloseableTokenListCategorizer categorizer;
-
- private final DriverContext driverContext;
-
- private Warnings warnings;
-
- public CategorizeEvaluator(Source source, EvalOperator.ExpressionEvaluator v,
- CategorizationAnalyzer analyzer,
- TokenListCategorizer.CloseableTokenListCategorizer categorizer, DriverContext driverContext) {
- this.source = source;
- this.v = v;
- this.analyzer = analyzer;
- this.categorizer = categorizer;
- this.driverContext = driverContext;
- }
-
- @Override
- public Block eval(Page page) {
- try (BytesRefBlock vBlock = (BytesRefBlock) v.eval(page)) {
- BytesRefVector vVector = vBlock.asVector();
- if (vVector == null) {
- return eval(page.getPositionCount(), vBlock);
- }
- return eval(page.getPositionCount(), vVector).asBlock();
- }
- }
-
- public IntBlock eval(int positionCount, BytesRefBlock vBlock) {
- try(IntBlock.Builder result = driverContext.blockFactory().newIntBlockBuilder(positionCount)) {
- BytesRef vScratch = new BytesRef();
- position: for (int p = 0; p < positionCount; p++) {
- if (vBlock.isNull(p)) {
- result.appendNull();
- continue position;
- }
- if (vBlock.getValueCount(p) != 1) {
- if (vBlock.getValueCount(p) > 1) {
- warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value"));
- }
- result.appendNull();
- continue position;
- }
- result.appendInt(Categorize.process(vBlock.getBytesRef(vBlock.getFirstValueIndex(p), vScratch), this.analyzer, this.categorizer));
- }
- return result.build();
- }
- }
-
- public IntVector eval(int positionCount, BytesRefVector vVector) {
- try(IntVector.FixedBuilder result = driverContext.blockFactory().newIntVectorFixedBuilder(positionCount)) {
- BytesRef vScratch = new BytesRef();
- position: for (int p = 0; p < positionCount; p++) {
- result.appendInt(p, Categorize.process(vVector.getBytesRef(p, vScratch), this.analyzer, this.categorizer));
- }
- return result.build();
- }
- }
-
- @Override
- public String toString() {
- return "CategorizeEvaluator[" + "v=" + v + "]";
- }
-
- @Override
- public void close() {
- Releasables.closeExpectNoException(v, analyzer, categorizer);
- }
-
- private Warnings warnings() {
- if (warnings == null) {
- this.warnings = Warnings.createWarnings(
- driverContext.warningsMode(),
- source.source().getLineNumber(),
- source.source().getColumnNumber(),
- source.text()
- );
- }
- return warnings;
- }
-
- static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
- private final Source source;
-
- private final EvalOperator.ExpressionEvaluator.Factory v;
-
- private final Function analyzer;
-
- private final Function categorizer;
-
- public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory v,
- Function analyzer,
- Function categorizer) {
- this.source = source;
- this.v = v;
- this.analyzer = analyzer;
- this.categorizer = categorizer;
- }
-
- @Override
- public CategorizeEvaluator get(DriverContext context) {
- return new CategorizeEvaluator(source, v.get(context), analyzer.apply(context), categorizer.apply(context), context);
- }
-
- @Override
- public String toString() {
- return "CategorizeEvaluator[" + "v=" + v + "]";
- }
- }
-}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
index 3eaeceaa86564..58748781d1778 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@@ -402,8 +402,11 @@ public enum Cap {
/**
* Supported the text categorization function "CATEGORIZE".
+ *
+ * This capability was initially named `CATEGORIZE`, and got renamed after the function started correctly returning keywords.
+ *
*/
- CATEGORIZE(Build.current().isSnapshot()),
+ CATEGORIZE_V2(Build.current().isSnapshot()),
/**
* QSTR function
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Categorize.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Categorize.java
index 75a9883a77102..31b603ecef889 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Categorize.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Categorize.java
@@ -7,20 +7,10 @@
package org.elasticsearch.xpack.esql.expression.function.grouping;
-import org.apache.lucene.analysis.TokenStream;
-import org.apache.lucene.analysis.core.WhitespaceTokenizer;
-import org.apache.lucene.util.BytesRef;
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.util.BytesRefHash;
-import org.elasticsearch.compute.ann.Evaluator;
-import org.elasticsearch.compute.ann.Fixed;
import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
-import org.elasticsearch.index.analysis.CharFilterFactory;
-import org.elasticsearch.index.analysis.CustomAnalyzer;
-import org.elasticsearch.index.analysis.TokenFilterFactory;
-import org.elasticsearch.index.analysis.TokenizerFactory;
import org.elasticsearch.xpack.esql.capabilities.Validatable;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
@@ -29,10 +19,6 @@
import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
import org.elasticsearch.xpack.esql.expression.function.Param;
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
-import org.elasticsearch.xpack.ml.aggs.categorization.CategorizationBytesRefHash;
-import org.elasticsearch.xpack.ml.aggs.categorization.CategorizationPartOfSpeechDictionary;
-import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer;
-import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer;
import java.io.IOException;
import java.util.List;
@@ -42,16 +28,16 @@
/**
* Categorizes text messages.
- *
- * This implementation is incomplete and comes with the following caveats:
- * - it only works correctly on a single node.
- * - when running on multiple nodes, category IDs of the different nodes are
- * aggregated, even though the same ID can correspond to a totally different
- * category
- * - the output consists of category IDs, which should be replaced by category
- * regexes or keys
- *
- * TODO(jan, nik): fix this
+ *
+ * This function has no evaluators, as it works like an aggregation (Accumulates values, stores intermediate states, etc).
+ *
+ *
+ * For the implementation, see:
+ *
+ *
+ * {@link org.elasticsearch.compute.aggregation.blockhash.CategorizedIntermediateBlockHash}
+ * {@link org.elasticsearch.compute.aggregation.blockhash.CategorizeRawBlockHash}
+ *
*/
public class Categorize extends GroupingFunction implements Validatable {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
@@ -62,7 +48,7 @@ public class Categorize extends GroupingFunction implements Validatable {
private final Expression field;
- @FunctionInfo(returnType = { "integer" }, description = "Categorizes text messages.")
+ @FunctionInfo(returnType = "keyword", description = "Categorizes text messages.")
public Categorize(
Source source,
@Param(name = "field", type = { "text", "keyword" }, description = "Expression to categorize") Expression field
@@ -88,43 +74,13 @@ public String getWriteableName() {
@Override
public boolean foldable() {
- return field.foldable();
- }
-
- @Evaluator
- static int process(
- BytesRef v,
- @Fixed(includeInToString = false, build = true) CategorizationAnalyzer analyzer,
- @Fixed(includeInToString = false, build = true) TokenListCategorizer.CloseableTokenListCategorizer categorizer
- ) {
- String s = v.utf8ToString();
- try (TokenStream ts = analyzer.tokenStream("text", s)) {
- return categorizer.computeCategory(ts, s.length(), 1).getId();
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
+ // Categorize cannot be currently folded
+ return false;
}
@Override
public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
- return new CategorizeEvaluator.Factory(
- source(),
- toEvaluator.apply(field),
- context -> new CategorizationAnalyzer(
- // TODO(jan): get the correct analyzer in here, see CategorizationAnalyzerConfig::buildStandardCategorizationAnalyzer
- new CustomAnalyzer(
- TokenizerFactory.newFactory("whitespace", WhitespaceTokenizer::new),
- new CharFilterFactory[0],
- new TokenFilterFactory[0]
- ),
- true
- ),
- context -> new TokenListCategorizer.CloseableTokenListCategorizer(
- new CategorizationBytesRefHash(new BytesRefHash(2048, context.bigArrays())),
- CategorizationPartOfSpeechDictionary.getInstance(),
- 0.70f
- )
- );
+ throw new UnsupportedOperationException("CATEGORIZE is only evaluated during aggregations");
}
@Override
@@ -134,11 +90,11 @@ protected TypeResolution resolveType() {
@Override
public DataType dataType() {
- return DataType.INTEGER;
+ return DataType.KEYWORD;
}
@Override
- public Expression replaceChildren(List newChildren) {
+ public Categorize replaceChildren(List newChildren) {
return new Categorize(source(), newChildren.get(0));
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/CombineProjections.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/CombineProjections.java
index 1c256012baeb0..be7096538fb9a 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/CombineProjections.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/CombineProjections.java
@@ -15,6 +15,7 @@
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.Expressions;
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.Project;
@@ -61,12 +62,15 @@ protected LogicalPlan rule(UnaryPlan plan) {
if (plan instanceof Aggregate a) {
if (child instanceof Project p) {
var groupings = a.groupings();
- List groupingAttrs = new ArrayList<>(a.groupings().size());
+ List groupingAttrs = new ArrayList<>(a.groupings().size());
for (Expression grouping : groupings) {
if (grouping instanceof Attribute attribute) {
groupingAttrs.add(attribute);
+ } else if (grouping instanceof Alias as && as.child() instanceof Categorize) {
+ groupingAttrs.add(as);
} else {
- // After applying ReplaceAggregateNestedExpressionWithEval, groupings can only contain attributes.
+ // After applying ReplaceAggregateNestedExpressionWithEval,
+ // groupings (except Categorize) can only contain attributes.
throw new EsqlIllegalArgumentException("Expected an Attribute, got {}", grouping);
}
}
@@ -137,23 +141,33 @@ private static List combineProjections(List extends NamedExpr
}
private static List combineUpperGroupingsAndLowerProjections(
- List extends Attribute> upperGroupings,
+ List extends NamedExpression> upperGroupings,
List extends NamedExpression> lowerProjections
) {
// Collect the alias map for resolving the source (f1 = 1, f2 = f1, etc..)
- AttributeMap aliases = new AttributeMap<>();
+ AttributeMap aliases = new AttributeMap<>();
for (NamedExpression ne : lowerProjections) {
- // Projections are just aliases for attributes, so casting is safe.
- aliases.put(ne.toAttribute(), (Attribute) Alias.unwrap(ne));
+ // record the alias
+ aliases.put(ne.toAttribute(), Alias.unwrap(ne));
}
-
// Replace any matching attribute directly with the aliased attribute from the projection.
- AttributeSet replaced = new AttributeSet();
- for (Attribute attr : upperGroupings) {
- // All substitutions happen before; groupings must be attributes at this point.
- replaced.add(aliases.resolve(attr, attr));
+ AttributeSet seen = new AttributeSet();
+ List replaced = new ArrayList<>();
+ for (NamedExpression ne : upperGroupings) {
+ // Duplicated attributes are ignored.
+ if (ne instanceof Attribute attribute) {
+ var newExpression = aliases.resolve(attribute, attribute);
+ if (newExpression instanceof Attribute newAttribute && seen.add(newAttribute) == false) {
+ // Already seen, skip
+ continue;
+ }
+ replaced.add(newExpression);
+ } else {
+ // For grouping functions, this will replace nested properties too
+ replaced.add(ne.transformUp(Attribute.class, a -> aliases.resolve(a, a)));
+ }
}
- return new ArrayList<>(replaced);
+ return replaced;
}
/**
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNull.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNull.java
index 0f08cd66444a3..638fa1b8db456 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNull.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNull.java
@@ -13,6 +13,7 @@
import org.elasticsearch.xpack.esql.core.expression.Literal;
import org.elasticsearch.xpack.esql.core.expression.Nullability;
import org.elasticsearch.xpack.esql.expression.function.aggregate.AggregateFunction;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.In;
public class FoldNull extends OptimizerRules.OptimizerExpressionRule {
@@ -42,6 +43,7 @@ public Expression rule(Expression e) {
}
} else if (e instanceof Alias == false
&& e.nullable() == Nullability.TRUE
+ && e instanceof Categorize == false
&& Expressions.anyMatch(e.children(), Expressions::isNull)) {
return Literal.of(e, null);
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceAggregateNestedExpressionWithEval.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceAggregateNestedExpressionWithEval.java
index 173940af19935..985e68252a1f9 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceAggregateNestedExpressionWithEval.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceAggregateNestedExpressionWithEval.java
@@ -13,6 +13,7 @@
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.util.Holder;
import org.elasticsearch.xpack.esql.expression.function.aggregate.AggregateFunction;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.expression.function.grouping.GroupingFunction;
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
@@ -46,15 +47,29 @@ protected LogicalPlan rule(Aggregate aggregate) {
// start with the groupings since the aggs might duplicate it
for (int i = 0, s = newGroupings.size(); i < s; i++) {
Expression g = newGroupings.get(i);
- // move the alias into an eval and replace it with its attribute
+ // Move the alias into an eval and replace it with its attribute.
+ // Exception: Categorize is internal to the aggregation and remains in the groupings. We move its child expression into an eval.
if (g instanceof Alias as) {
- groupingChanged = true;
- var attr = as.toAttribute();
- evals.add(as);
- evalNames.put(as.name(), attr);
- newGroupings.set(i, attr);
- if (as.child() instanceof GroupingFunction gf) {
- groupingAttributes.put(gf, attr);
+ if (as.child() instanceof Categorize cat) {
+ if (cat.field() instanceof Attribute == false) {
+ groupingChanged = true;
+ var fieldAs = new Alias(as.source(), as.name(), cat.field(), null, true);
+ var fieldAttr = fieldAs.toAttribute();
+ evals.add(fieldAs);
+ evalNames.put(fieldAs.name(), fieldAttr);
+ Categorize replacement = cat.replaceChildren(List.of(fieldAttr));
+ newGroupings.set(i, as.replaceChild(replacement));
+ groupingAttributes.put(cat, fieldAttr);
+ }
+ } else {
+ groupingChanged = true;
+ var attr = as.toAttribute();
+ evals.add(as);
+ evalNames.put(as.name(), attr);
+ newGroupings.set(i, attr);
+ if (as.child() instanceof GroupingFunction gf) {
+ groupingAttributes.put(gf, attr);
+ }
}
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/InsertFieldExtraction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/InsertFieldExtraction.java
index ea9cd76bcb9bc..72573821dfeb8 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/InsertFieldExtraction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/InsertFieldExtraction.java
@@ -12,6 +12,7 @@
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute;
import org.elasticsearch.xpack.esql.core.expression.TypedAttribute;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.optimizer.rules.physical.ProjectAwayColumns;
import org.elasticsearch.xpack.esql.plan.physical.AggregateExec;
import org.elasticsearch.xpack.esql.plan.physical.EsQueryExec;
@@ -58,11 +59,17 @@ public PhysicalPlan apply(PhysicalPlan plan) {
* make sure the fields are loaded for the standard hash aggregator.
*/
if (p instanceof AggregateExec agg && agg.groupings().size() == 1) {
- var leaves = new LinkedList<>();
- // TODO: this seems out of place
- agg.aggregates().stream().filter(a -> agg.groupings().contains(a) == false).forEach(a -> leaves.addAll(a.collectLeaves()));
- var remove = agg.groupings().stream().filter(g -> leaves.contains(g) == false).toList();
- missing.removeAll(Expressions.references(remove));
+ // CATEGORIZE requires the standard hash aggregator as well.
+ if (agg.groupings().get(0).anyMatch(e -> e instanceof Categorize) == false) {
+ var leaves = new LinkedList<>();
+ // TODO: this seems out of place
+ agg.aggregates()
+ .stream()
+ .filter(a -> agg.groupings().contains(a) == false)
+ .forEach(a -> leaves.addAll(a.collectLeaves()));
+ var remove = agg.groupings().stream().filter(g -> leaves.contains(g) == false).toList();
+ missing.removeAll(Expressions.references(remove));
+ }
}
// add extractor
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AbstractPhysicalOperationProviders.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AbstractPhysicalOperationProviders.java
index 94a9246a56f83..a7418654f6b0e 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AbstractPhysicalOperationProviders.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AbstractPhysicalOperationProviders.java
@@ -29,6 +29,7 @@
import org.elasticsearch.xpack.esql.evaluator.EvalMapper;
import org.elasticsearch.xpack.esql.expression.function.aggregate.AggregateFunction;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Count;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.plan.physical.AggregateExec;
import org.elasticsearch.xpack.esql.plan.physical.ExchangeSourceExec;
import org.elasticsearch.xpack.esql.planner.LocalExecutionPlanner.LocalExecutionPlannerContext;
@@ -52,6 +53,7 @@ public final PhysicalOperation groupingPhysicalOperation(
PhysicalOperation source,
LocalExecutionPlannerContext context
) {
+ // The layout this operation will produce.
Layout.Builder layout = new Layout.Builder();
Operator.OperatorFactory operatorFactory = null;
AggregatorMode aggregatorMode = aggregateExec.getMode();
@@ -95,12 +97,17 @@ public final PhysicalOperation groupingPhysicalOperation(
List aggregatorFactories = new ArrayList<>();
List groupSpecs = new ArrayList<>(aggregateExec.groupings().size());
for (Expression group : aggregateExec.groupings()) {
- var groupAttribute = Expressions.attribute(group);
- if (groupAttribute == null) {
+ Attribute groupAttribute = Expressions.attribute(group);
+ // In case of `... BY groupAttribute = CATEGORIZE(sourceGroupAttribute)` the actual source attribute is different.
+ Attribute sourceGroupAttribute = (aggregatorMode.isInputPartial() == false
+ && group instanceof Alias as
+ && as.child() instanceof Categorize categorize) ? Expressions.attribute(categorize.field()) : groupAttribute;
+ if (sourceGroupAttribute == null) {
throw new EsqlIllegalArgumentException("Unexpected non-named expression[{}] as grouping in [{}]", group, aggregateExec);
}
- Layout.ChannelSet groupAttributeLayout = new Layout.ChannelSet(new HashSet<>(), groupAttribute.dataType());
- groupAttributeLayout.nameIds().add(groupAttribute.id());
+ Layout.ChannelSet groupAttributeLayout = new Layout.ChannelSet(new HashSet<>(), sourceGroupAttribute.dataType());
+ groupAttributeLayout.nameIds()
+ .add(group instanceof Alias as && as.child() instanceof Categorize ? groupAttribute.id() : sourceGroupAttribute.id());
/*
* Check for aliasing in aggregates which occurs in two cases (due to combining project + stats):
@@ -119,7 +126,7 @@ public final PhysicalOperation groupingPhysicalOperation(
// check if there's any alias used in grouping - no need for the final reduction since the intermediate data
// is in the output form
// if the group points to an alias declared in the aggregate, use the alias child as source
- else if (aggregatorMode == AggregatorMode.INITIAL || aggregatorMode == AggregatorMode.INTERMEDIATE) {
+ else if (aggregatorMode.isOutputPartial()) {
if (groupAttribute.semanticEquals(a.toAttribute())) {
groupAttribute = attr;
break;
@@ -129,8 +136,8 @@ else if (aggregatorMode == AggregatorMode.INITIAL || aggregatorMode == Aggregato
}
}
layout.append(groupAttributeLayout);
- Layout.ChannelAndType groupInput = source.layout.get(groupAttribute.id());
- groupSpecs.add(new GroupSpec(groupInput == null ? null : groupInput.channel(), groupAttribute));
+ Layout.ChannelAndType groupInput = source.layout.get(sourceGroupAttribute.id());
+ groupSpecs.add(new GroupSpec(groupInput == null ? null : groupInput.channel(), sourceGroupAttribute, group));
}
if (aggregatorMode == AggregatorMode.FINAL) {
@@ -164,6 +171,7 @@ else if (aggregatorMode == AggregatorMode.INITIAL || aggregatorMode == Aggregato
} else {
operatorFactory = new HashAggregationOperatorFactory(
groupSpecs.stream().map(GroupSpec::toHashGroupSpec).toList(),
+ aggregatorMode,
aggregatorFactories,
context.pageSize(aggregateExec.estimatedRowSize())
);
@@ -178,10 +186,14 @@ else if (aggregatorMode == AggregatorMode.INITIAL || aggregatorMode == Aggregato
/***
* Creates a standard layout for intermediate aggregations, typically used across exchanges.
* Puts the group first, followed by each aggregation.
- *
- * It's similar to the code above (groupingPhysicalOperation) but ignores the factory creation.
+ *
+ * It's similar to the code above (groupingPhysicalOperation) but ignores the factory creation.
+ *
*/
public static List intermediateAttributes(List extends NamedExpression> aggregates, List extends Expression> groupings) {
+ // TODO: This should take CATEGORIZE into account:
+ // it currently works because the CATEGORIZE intermediate state is just 1 block with the same type as the function return,
+ // so the attribute generated here is the expected one
var aggregateMapper = new AggregateMapper();
List attrs = new ArrayList<>();
@@ -304,12 +316,20 @@ private static AggregatorFunctionSupplier supplier(AggregateFunction aggregateFu
throw new EsqlIllegalArgumentException("aggregate functions must extend ToAggregator");
}
- private record GroupSpec(Integer channel, Attribute attribute) {
+ /**
+ * The input configuration of this group.
+ *
+ * @param channel The source channel of this group
+ * @param attribute The attribute, source of this group
+ * @param expression The expression being used to group
+ */
+ private record GroupSpec(Integer channel, Attribute attribute, Expression expression) {
BlockHash.GroupSpec toHashGroupSpec() {
if (channel == null) {
throw new EsqlIllegalArgumentException("planned to use ordinals but tried to use the hash instead");
}
- return new BlockHash.GroupSpec(channel, elementType());
+
+ return new BlockHash.GroupSpec(channel, elementType(), Alias.unwrap(expression) instanceof Categorize);
}
ElementType elementType() {
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
index f25b19c4e5d1c..355073fcc873f 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
@@ -1821,7 +1821,7 @@ public void testIntervalAsString() {
}
public void testCategorizeSingleGrouping() {
- assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE.isEnabled());
+ assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled());
query("from test | STATS COUNT(*) BY CATEGORIZE(first_name)");
query("from test | STATS COUNT(*) BY cat = CATEGORIZE(first_name)");
@@ -1850,7 +1850,7 @@ public void testCategorizeSingleGrouping() {
}
public void testCategorizeNestedGrouping() {
- assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE.isEnabled());
+ assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled());
query("from test | STATS COUNT(*) BY CATEGORIZE(LENGTH(first_name)::string)");
@@ -1865,7 +1865,7 @@ public void testCategorizeNestedGrouping() {
}
public void testCategorizeWithinAggregations() {
- assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE.isEnabled());
+ assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled());
query("from test | STATS MV_COUNT(cat), COUNT(*) BY cat = CATEGORIZE(first_name)");
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java
index db5d8e03458ea..df1675ba22568 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java
@@ -111,7 +111,8 @@ protected static List withNoRowsExpectingNull(List anyNullIsNull(
oc.getExpectedTypeError(),
null,
null,
- null
+ null,
+ oc.canBuildEvaluator()
);
}));
@@ -260,7 +261,8 @@ protected static List anyNullIsNull(
oc.getExpectedTypeError(),
null,
null,
- null
+ null,
+ oc.canBuildEvaluator()
);
}));
}
@@ -648,18 +650,7 @@ protected static List randomizeBytesRefsOffset(List data, String expectedTypeError)
Class extends Throwable> foldingExceptionClass,
String foldingExceptionMessage,
Object extra
+ ) {
+ this(
+ data,
+ evaluatorToString,
+ expectedType,
+ matcher,
+ expectedWarnings,
+ expectedBuildEvaluatorWarnings,
+ expectedTypeError,
+ foldingExceptionClass,
+ foldingExceptionMessage,
+ extra,
+ data.stream().allMatch(d -> d.forceLiteral || DataType.isRepresentable(d.type))
+ );
+ }
+
+ TestCase(
+ List data,
+ Matcher evaluatorToString,
+ DataType expectedType,
+ Matcher> matcher,
+ String[] expectedWarnings,
+ String[] expectedBuildEvaluatorWarnings,
+ String expectedTypeError,
+ Class extends Throwable> foldingExceptionClass,
+ String foldingExceptionMessage,
+ Object extra,
+ boolean canBuildEvaluator
) {
this.source = Source.EMPTY;
this.data = data;
@@ -1442,10 +1470,10 @@ public static TestCase typeError(List data, String expectedTypeError)
this.expectedWarnings = expectedWarnings;
this.expectedBuildEvaluatorWarnings = expectedBuildEvaluatorWarnings;
this.expectedTypeError = expectedTypeError;
- this.canBuildEvaluator = data.stream().allMatch(d -> d.forceLiteral || DataType.isRepresentable(d.type));
this.foldingExceptionClass = foldingExceptionClass;
this.foldingExceptionMessage = foldingExceptionMessage;
this.extra = extra;
+ this.canBuildEvaluator = canBuildEvaluator;
}
public Source getSource() {
@@ -1520,6 +1548,25 @@ public Object extra() {
return extra;
}
+ /**
+ * Build a new {@link TestCase} with new {@link #data}.
+ */
+ public TestCase withData(List data) {
+ return new TestCase(
+ data,
+ evaluatorToString,
+ expectedType,
+ matcher,
+ expectedWarnings,
+ expectedBuildEvaluatorWarnings,
+ expectedTypeError,
+ foldingExceptionClass,
+ foldingExceptionMessage,
+ extra,
+ canBuildEvaluator
+ );
+ }
+
/**
* Build a new {@link TestCase} with new {@link #extra()}.
*/
@@ -1534,7 +1581,8 @@ public TestCase withExtra(Object extra) {
expectedTypeError,
foldingExceptionClass,
foldingExceptionMessage,
- extra
+ extra,
+ canBuildEvaluator
);
}
@@ -1549,7 +1597,8 @@ public TestCase withWarning(String warning) {
expectedTypeError,
foldingExceptionClass,
foldingExceptionMessage,
- extra
+ extra,
+ canBuildEvaluator
);
}
@@ -1568,7 +1617,8 @@ public TestCase withBuildEvaluatorWarning(String warning) {
expectedTypeError,
foldingExceptionClass,
foldingExceptionMessage,
- extra
+ extra,
+ canBuildEvaluator
);
}
@@ -1592,7 +1642,30 @@ public TestCase withFoldingException(Class extends Throwable> clazz, String me
expectedTypeError,
clazz,
message,
- extra
+ extra,
+ canBuildEvaluator
+ );
+ }
+
+ /**
+ * Build a new {@link TestCase} that can't build an evaluator.
+ *
+ * Useful for special cases that can't be executed, but should still be considered.
+ *
+ */
+ public TestCase withoutEvaluator() {
+ return new TestCase(
+ data,
+ evaluatorToString,
+ expectedType,
+ matcher,
+ expectedWarnings,
+ expectedBuildEvaluatorWarnings,
+ expectedTypeError,
+ foldingExceptionClass,
+ foldingExceptionMessage,
+ extra,
+ false
);
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeTests.java
index f93389d5cb659..d29ac635e4bb7 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/grouping/CategorizeTests.java
@@ -23,6 +23,12 @@
import static org.hamcrest.Matchers.equalTo;
+/**
+ * Dummy test implementation for Categorize. Used just to generate documentation.
+ *
+ * Most test cases are currently skipped as this function can't build an evaluator.
+ *
+ */
public class CategorizeTests extends AbstractScalarFunctionTestCase {
public CategorizeTests(@Name("TestCase") Supplier testCaseSupplier) {
this.testCase = testCaseSupplier.get();
@@ -37,11 +43,11 @@ public static Iterable parameters() {
"text with " + dataType.typeName(),
List.of(dataType),
() -> new TestCaseSupplier.TestCase(
- List.of(new TestCaseSupplier.TypedData(new BytesRef("blah blah blah"), dataType, "f")),
- "CategorizeEvaluator[v=Attribute[channel=0]]",
- DataType.INTEGER,
- equalTo(0)
- )
+ List.of(new TestCaseSupplier.TypedData(new BytesRef(""), dataType, "field")),
+ "",
+ DataType.KEYWORD,
+ equalTo(new BytesRef(""))
+ ).withoutEvaluator()
)
);
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
index a11a9cef82989..2b4fb6ad68972 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
@@ -57,6 +57,7 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.ToPartial;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Values;
import org.elasticsearch.xpack.esql.expression.function.grouping.Bucket;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDouble;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToInteger;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToLong;
@@ -1203,6 +1204,33 @@ public void testCombineProjectionWithAggregationFirstAndAliasedGroupingUsedInAgg
assertThat(Expressions.names(agg.groupings()), contains("first_name"));
}
+ /**
+ * Expects
+ * Limit[1000[INTEGER]]
+ * \_Aggregate[STANDARD,[CATEGORIZE(first_name{f}#18) AS cat],[SUM(salary{f}#22,true[BOOLEAN]) AS s, cat{r}#10]]
+ * \_EsRelation[test][_meta_field{f}#23, emp_no{f}#17, first_name{f}#18, ..]
+ */
+ public void testCombineProjectionWithCategorizeGrouping() {
+ var plan = plan("""
+ from test
+ | eval k = first_name, k1 = k
+ | stats s = sum(salary) by cat = CATEGORIZE(k)
+ | keep s, cat
+ """);
+
+ var limit = as(plan, Limit.class);
+ var agg = as(limit.child(), Aggregate.class);
+ assertThat(agg.child(), instanceOf(EsRelation.class));
+
+ assertThat(Expressions.names(agg.aggregates()), contains("s", "cat"));
+ assertThat(Expressions.names(agg.groupings()), contains("cat"));
+
+ var categorizeAlias = as(agg.groupings().get(0), Alias.class);
+ var categorize = as(categorizeAlias.child(), Categorize.class);
+ var categorizeField = as(categorize.field(), FieldAttribute.class);
+ assertThat(categorizeField.name(), is("first_name"));
+ }
+
/**
* Expects
* Limit[1000[INTEGER]]
@@ -3909,6 +3937,39 @@ public void testNestedExpressionsInGroups() {
assertThat(eval.fields().get(0).name(), is("emp_no % 2"));
}
+ /**
+ * Expects
+ * Limit[1000[INTEGER]]
+ * \_Aggregate[STANDARD,[CATEGORIZE(CATEGORIZE(CONCAT(first_name, "abc")){r$}#18) AS CATEGORIZE(CONCAT(first_name, "abc"))],[CO
+ * UNT(salary{f}#13,true[BOOLEAN]) AS c, CATEGORIZE(CONCAT(first_name, "abc")){r}#3]]
+ * \_Eval[[CONCAT(first_name{f}#9,[61 62 63][KEYWORD]) AS CATEGORIZE(CONCAT(first_name, "abc"))]]
+ * \_EsRelation[test][_meta_field{f}#14, emp_no{f}#8, first_name{f}#9, ge..]
+ */
+ public void testNestedExpressionsInGroupsWithCategorize() {
+ var plan = optimizedPlan("""
+ from test
+ | stats c = count(salary) by CATEGORIZE(CONCAT(first_name, "abc"))
+ """);
+
+ var limit = as(plan, Limit.class);
+ var agg = as(limit.child(), Aggregate.class);
+ var groupings = agg.groupings();
+ var categorizeAlias = as(groupings.get(0), Alias.class);
+ var categorize = as(categorizeAlias.child(), Categorize.class);
+ var aggs = agg.aggregates();
+ assertThat(aggs.get(1), is(categorizeAlias.toAttribute()));
+
+ var eval = as(agg.child(), Eval.class);
+ assertThat(eval.fields(), hasSize(1));
+ var evalFieldAlias = as(eval.fields().get(0), Alias.class);
+ var evalField = as(evalFieldAlias.child(), Concat.class);
+
+ assertThat(evalFieldAlias.name(), is("CATEGORIZE(CONCAT(first_name, \"abc\"))"));
+ assertThat(categorize.field(), is(evalFieldAlias.toAttribute()));
+ assertThat(evalField.source().text(), is("CONCAT(first_name, \"abc\")"));
+ assertThat(categorizeAlias.source(), is(evalFieldAlias.source()));
+ }
+
/**
* Expects
* Limit[1000[INTEGER]]
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNullTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNullTests.java
index 89117b5d4e729..ae31576184938 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNullTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/FoldNullTests.java
@@ -28,6 +28,8 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.Percentile;
import org.elasticsearch.xpack.esql.expression.function.aggregate.SpatialCentroid;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Sum;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Bucket;
+import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateExtract;
import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateFormat;
@@ -267,6 +269,17 @@ public void testNullFoldableDoesNotApplyToIsNullAndNotNull() {
}
}
+ public void testNullBucketGetsFolded() {
+ FoldNull foldNull = new FoldNull();
+ assertEquals(NULL, foldNull.rule(new Bucket(EMPTY, NULL, NULL, NULL, NULL)));
+ }
+
+ public void testNullCategorizeGroupingNotFolded() {
+ FoldNull foldNull = new FoldNull();
+ Categorize categorize = new Categorize(EMPTY, NULL);
+ assertEquals(categorize, foldNull.rule(categorize));
+ }
+
private void assertNullLiteral(Expression expression) {
assertEquals(Literal.class, expression.getClass());
assertNull(expression.fold());
diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizer.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizer.java
index d0088edcb0805..e4257270ce641 100644
--- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizer.java
+++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizer.java
@@ -19,6 +19,7 @@
import org.elasticsearch.search.aggregations.AggregationReduceContext;
import org.elasticsearch.search.aggregations.InternalAggregations;
import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategory.TokenAndWeight;
+import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -83,6 +84,8 @@ public void close() {
@Nullable
private final CategorizationPartOfSpeechDictionary partOfSpeechDictionary;
+ private final List categoriesById;
+
/**
* Categories stored in such a way that the most common are accessed first.
* This is implemented as an {@link ArrayList} with bespoke ordering rather
@@ -108,9 +111,18 @@ public TokenListCategorizer(
this.lowerThreshold = threshold;
this.upperThreshold = (1.0f + threshold) / 2.0f;
this.categoriesByNumMatches = new ArrayList<>();
+ this.categoriesById = new ArrayList<>();
cacheRamUsage(0);
}
+ public TokenListCategory computeCategory(String s, CategorizationAnalyzer analyzer) {
+ try (TokenStream ts = analyzer.tokenStream("text", s)) {
+ return computeCategory(ts, s.length(), 1);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
public TokenListCategory computeCategory(TokenStream ts, int unfilteredStringLen, long numDocs) throws IOException {
assert partOfSpeechDictionary != null
: "This version of computeCategory should only be used when a part-of-speech dictionary is available";
@@ -301,6 +313,7 @@ private synchronized TokenListCategory computeCategory(
maxUnfilteredStringLen,
numDocs
);
+ categoriesById.add(newCategory);
categoriesByNumMatches.add(newCategory);
cacheRamUsage(newCategory.ramBytesUsed());
return repositionCategory(newCategory, newIndex);
@@ -412,6 +425,17 @@ static float similarity(List left, int leftWeight, List toCategories(int size) {
+ return categoriesByNumMatches.stream()
+ .limit(size)
+ .map(category -> new SerializableTokenListCategory(category, bytesRefHash))
+ .toList();
+ }
+
+ public List toCategoriesById() {
+ return categoriesById.stream().map(category -> new SerializableTokenListCategory(category, bytesRefHash)).toList();
+ }
+
public InternalCategorizationAggregation.Bucket[] toOrderedBuckets(int size) {
return categoriesByNumMatches.stream()
.limit(size)
From 31ebc5f33fece5e32a4350c13bcd385ee20aabcc Mon Sep 17 00:00:00 2001
From: Brian Seeders
Date: Wed, 27 Nov 2024 13:51:02 -0500
Subject: [PATCH 04/39] Bump versions after 8.15.5 release
---
.buildkite/pipelines/periodic-packaging.yml | 6 +++---
.buildkite/pipelines/periodic.yml | 6 +++---
.ci/bwcVersions | 2 +-
server/src/main/java/org/elasticsearch/Version.java | 1 +
.../main/resources/org/elasticsearch/TransportVersions.csv | 1 +
.../resources/org/elasticsearch/index/IndexVersions.csv | 1 +
6 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/.buildkite/pipelines/periodic-packaging.yml b/.buildkite/pipelines/periodic-packaging.yml
index a49e486176484..c1b10a46c62a7 100644
--- a/.buildkite/pipelines/periodic-packaging.yml
+++ b/.buildkite/pipelines/periodic-packaging.yml
@@ -273,8 +273,8 @@ steps:
env:
BWC_VERSION: 8.14.3
- - label: "{{matrix.image}} / 8.15.4 / packaging-tests-upgrade"
- command: ./.ci/scripts/packaging-test.sh -Dbwc.checkout.align=true destructiveDistroUpgradeTest.v8.15.4
+ - label: "{{matrix.image}} / 8.15.6 / packaging-tests-upgrade"
+ command: ./.ci/scripts/packaging-test.sh -Dbwc.checkout.align=true destructiveDistroUpgradeTest.v8.15.6
timeout_in_minutes: 300
matrix:
setup:
@@ -287,7 +287,7 @@ steps:
machineType: custom-16-32768
buildDirectory: /dev/shm/bk
env:
- BWC_VERSION: 8.15.4
+ BWC_VERSION: 8.15.6
- label: "{{matrix.image}} / 8.16.2 / packaging-tests-upgrade"
command: ./.ci/scripts/packaging-test.sh -Dbwc.checkout.align=true destructiveDistroUpgradeTest.v8.16.2
diff --git a/.buildkite/pipelines/periodic.yml b/.buildkite/pipelines/periodic.yml
index aa1db893df8cc..69d11ef1dabb6 100644
--- a/.buildkite/pipelines/periodic.yml
+++ b/.buildkite/pipelines/periodic.yml
@@ -287,8 +287,8 @@ steps:
- signal_reason: agent_stop
limit: 3
- - label: 8.15.4 / bwc
- command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true v8.15.4#bwcTest
+ - label: 8.15.6 / bwc
+ command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true v8.15.6#bwcTest
timeout_in_minutes: 300
agents:
provider: gcp
@@ -297,7 +297,7 @@ steps:
buildDirectory: /dev/shm/bk
preemptible: true
env:
- BWC_VERSION: 8.15.4
+ BWC_VERSION: 8.15.6
retry:
automatic:
- exit_status: "-1"
diff --git a/.ci/bwcVersions b/.ci/bwcVersions
index a8d6dda4fb0c2..826091807ce57 100644
--- a/.ci/bwcVersions
+++ b/.ci/bwcVersions
@@ -14,7 +14,7 @@ BWC_VERSION:
- "8.12.2"
- "8.13.4"
- "8.14.3"
- - "8.15.4"
+ - "8.15.6"
- "8.16.2"
- "8.17.0"
- "8.18.0"
diff --git a/server/src/main/java/org/elasticsearch/Version.java b/server/src/main/java/org/elasticsearch/Version.java
index 7b65547a7d591..24aa5bd261d7e 100644
--- a/server/src/main/java/org/elasticsearch/Version.java
+++ b/server/src/main/java/org/elasticsearch/Version.java
@@ -187,6 +187,7 @@ public class Version implements VersionId, ToXContentFragment {
public static final Version V_8_15_2 = new Version(8_15_02_99);
public static final Version V_8_15_3 = new Version(8_15_03_99);
public static final Version V_8_15_4 = new Version(8_15_04_99);
+ public static final Version V_8_15_6 = new Version(8_15_06_99);
public static final Version V_8_16_0 = new Version(8_16_00_99);
public static final Version V_8_16_1 = new Version(8_16_01_99);
public static final Version V_8_16_2 = new Version(8_16_02_99);
diff --git a/server/src/main/resources/org/elasticsearch/TransportVersions.csv b/server/src/main/resources/org/elasticsearch/TransportVersions.csv
index 6191922f13094..faeb7fe848159 100644
--- a/server/src/main/resources/org/elasticsearch/TransportVersions.csv
+++ b/server/src/main/resources/org/elasticsearch/TransportVersions.csv
@@ -132,5 +132,6 @@
8.15.2,8702003
8.15.3,8702003
8.15.4,8702003
+8.15.5,8702003
8.16.0,8772001
8.16.1,8772004
diff --git a/server/src/main/resources/org/elasticsearch/index/IndexVersions.csv b/server/src/main/resources/org/elasticsearch/index/IndexVersions.csv
index f84d69af727ac..1fc8bd8648ad6 100644
--- a/server/src/main/resources/org/elasticsearch/index/IndexVersions.csv
+++ b/server/src/main/resources/org/elasticsearch/index/IndexVersions.csv
@@ -132,5 +132,6 @@
8.15.2,8512000
8.15.3,8512000
8.15.4,8512000
+8.15.5,8512000
8.16.0,8518000
8.16.1,8518000
From 807d994c5b956841546c2ce40eb2cd8ddd6a339d Mon Sep 17 00:00:00 2001
From: Brian Seeders
Date: Wed, 27 Nov 2024 13:52:47 -0500
Subject: [PATCH 05/39] Prune changelogs after 8.15.5 release
---
docs/changelog/114193.yaml | 5 -----
docs/changelog/114227.yaml | 6 ------
docs/changelog/114268.yaml | 5 -----
docs/changelog/114521.yaml | 5 -----
docs/changelog/114548.yaml | 5 -----
docs/changelog/116277.yaml | 6 ------
docs/changelog/116292.yaml | 5 -----
docs/changelog/116357.yaml | 5 -----
docs/changelog/116382.yaml | 5 -----
docs/changelog/116408.yaml | 6 ------
docs/changelog/116478.yaml | 5 -----
docs/changelog/116650.yaml | 5 -----
docs/changelog/116676.yaml | 5 -----
docs/changelog/116915.yaml | 5 -----
docs/changelog/116918.yaml | 5 -----
docs/changelog/116942.yaml | 5 -----
docs/changelog/116995.yaml | 5 -----
docs/changelog/117182.yaml | 6 ------
18 files changed, 94 deletions(-)
delete mode 100644 docs/changelog/114193.yaml
delete mode 100644 docs/changelog/114227.yaml
delete mode 100644 docs/changelog/114268.yaml
delete mode 100644 docs/changelog/114521.yaml
delete mode 100644 docs/changelog/114548.yaml
delete mode 100644 docs/changelog/116277.yaml
delete mode 100644 docs/changelog/116292.yaml
delete mode 100644 docs/changelog/116357.yaml
delete mode 100644 docs/changelog/116382.yaml
delete mode 100644 docs/changelog/116408.yaml
delete mode 100644 docs/changelog/116478.yaml
delete mode 100644 docs/changelog/116650.yaml
delete mode 100644 docs/changelog/116676.yaml
delete mode 100644 docs/changelog/116915.yaml
delete mode 100644 docs/changelog/116918.yaml
delete mode 100644 docs/changelog/116942.yaml
delete mode 100644 docs/changelog/116995.yaml
delete mode 100644 docs/changelog/117182.yaml
diff --git a/docs/changelog/114193.yaml b/docs/changelog/114193.yaml
deleted file mode 100644
index f18f9359007b8..0000000000000
--- a/docs/changelog/114193.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 114193
-summary: Add postal_code support to the City and Enterprise databases
-area: Ingest Node
-type: enhancement
-issues: []
diff --git a/docs/changelog/114227.yaml b/docs/changelog/114227.yaml
deleted file mode 100644
index 9b508f07c9e5a..0000000000000
--- a/docs/changelog/114227.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-pr: 114227
-summary: Ignore conflicting fields during dynamic mapping update
-area: Mapping
-type: bug
-issues:
- - 114228
diff --git a/docs/changelog/114268.yaml b/docs/changelog/114268.yaml
deleted file mode 100644
index 5e4457005d7d3..0000000000000
--- a/docs/changelog/114268.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 114268
-summary: Support more maxmind fields in the geoip processor
-area: Ingest Node
-type: enhancement
-issues: []
diff --git a/docs/changelog/114521.yaml b/docs/changelog/114521.yaml
deleted file mode 100644
index c3a9c7cdd0848..0000000000000
--- a/docs/changelog/114521.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 114521
-summary: Add support for registered country fields for maxmind geoip databases
-area: Ingest Node
-type: enhancement
-issues: []
diff --git a/docs/changelog/114548.yaml b/docs/changelog/114548.yaml
deleted file mode 100644
index b9692bcb2d10c..0000000000000
--- a/docs/changelog/114548.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 114548
-summary: Support IPinfo database configurations
-area: Ingest Node
-type: enhancement
-issues: []
diff --git a/docs/changelog/116277.yaml b/docs/changelog/116277.yaml
deleted file mode 100644
index 62262b7797783..0000000000000
--- a/docs/changelog/116277.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-pr: 116277
-summary: Update Semantic Query To Handle Zero Size Responses
-area: Vector Search
-type: bug
-issues:
- - 116083
diff --git a/docs/changelog/116292.yaml b/docs/changelog/116292.yaml
deleted file mode 100644
index f741c67bea155..0000000000000
--- a/docs/changelog/116292.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116292
-summary: Add missing header in `put_data_lifecycle` rest-api-spec
-area: Data streams
-type: bug
-issues: []
diff --git a/docs/changelog/116357.yaml b/docs/changelog/116357.yaml
deleted file mode 100644
index a1a7831eab9ca..0000000000000
--- a/docs/changelog/116357.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116357
-summary: Add tracking for query rule types
-area: Relevance
-type: enhancement
-issues: []
diff --git a/docs/changelog/116382.yaml b/docs/changelog/116382.yaml
deleted file mode 100644
index c941fb6eaa1e4..0000000000000
--- a/docs/changelog/116382.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116382
-summary: Validate missing shards after the coordinator rewrite
-area: Search
-type: bug
-issues: []
diff --git a/docs/changelog/116408.yaml b/docs/changelog/116408.yaml
deleted file mode 100644
index 5f4c8459778a6..0000000000000
--- a/docs/changelog/116408.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-pr: 116408
-summary: Propagating nested `inner_hits` to the parent compound retriever
-area: Ranking
-type: bug
-issues:
- - 116397
diff --git a/docs/changelog/116478.yaml b/docs/changelog/116478.yaml
deleted file mode 100644
index ec50799eb2019..0000000000000
--- a/docs/changelog/116478.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116478
-summary: Semantic text simple partial update
-area: Search
-type: bug
-issues: []
diff --git a/docs/changelog/116650.yaml b/docs/changelog/116650.yaml
deleted file mode 100644
index d314a918aede9..0000000000000
--- a/docs/changelog/116650.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116650
-summary: Fix bug in ML autoscaling when some node info is unavailable
-area: Machine Learning
-type: bug
-issues: []
diff --git a/docs/changelog/116676.yaml b/docs/changelog/116676.yaml
deleted file mode 100644
index 8c6671e177499..0000000000000
--- a/docs/changelog/116676.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116676
-summary: Fix handling of time exceeded exception in fetch phase
-area: Search
-type: bug
-issues: []
diff --git a/docs/changelog/116915.yaml b/docs/changelog/116915.yaml
deleted file mode 100644
index 9686f0023a14a..0000000000000
--- a/docs/changelog/116915.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116915
-summary: Improve message about insecure S3 settings
-area: Snapshot/Restore
-type: enhancement
-issues: []
diff --git a/docs/changelog/116918.yaml b/docs/changelog/116918.yaml
deleted file mode 100644
index 3b04b4ae4a69a..0000000000000
--- a/docs/changelog/116918.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116918
-summary: Split searchable snapshot into multiple repo operations
-area: Snapshot/Restore
-type: enhancement
-issues: []
diff --git a/docs/changelog/116942.yaml b/docs/changelog/116942.yaml
deleted file mode 100644
index 5037e8c59cd85..0000000000000
--- a/docs/changelog/116942.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116942
-summary: Fix handling of bulk requests with semantic text fields and delete ops
-area: Relevance
-type: bug
-issues: []
diff --git a/docs/changelog/116995.yaml b/docs/changelog/116995.yaml
deleted file mode 100644
index a0467c630edf3..0000000000000
--- a/docs/changelog/116995.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 116995
-summary: "Apm-data: disable date_detection for all apm data streams"
-area: Data streams
-type: enhancement
-issues: []
\ No newline at end of file
diff --git a/docs/changelog/117182.yaml b/docs/changelog/117182.yaml
deleted file mode 100644
index b5398bec1ef30..0000000000000
--- a/docs/changelog/117182.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-pr: 117182
-summary: Change synthetic source logic for `constant_keyword`
-area: Mapping
-type: bug
-issues:
- - 117083
From a46547c8dcf8b58d822b2e30639fe35e4687883b Mon Sep 17 00:00:00 2001
From: Brian Seeders
Date: Wed, 27 Nov 2024 15:26:23 -0500
Subject: [PATCH 06/39] [CI] Pull in the latest mutes from base branch for PRs
at runtime (#117587)
---
.buildkite/hooks/pre-command | 4 ++++
.buildkite/hooks/pre-command.bat | 3 +++
.buildkite/scripts/get-latest-test-mutes.sh | 20 +++++++++++++++++++
.../internal/test/MutedTestsBuildService.java | 12 ++++++-----
4 files changed, 34 insertions(+), 5 deletions(-)
create mode 100755 .buildkite/scripts/get-latest-test-mutes.sh
diff --git a/.buildkite/hooks/pre-command b/.buildkite/hooks/pre-command
index 0ece129a3c238..f25092bc6d42f 100644
--- a/.buildkite/hooks/pre-command
+++ b/.buildkite/hooks/pre-command
@@ -47,6 +47,8 @@ export GRADLE_BUILD_CACHE_PASSWORD
BUILDKITE_API_TOKEN=$(vault read -field=token secret/ci/elastic-elasticsearch/buildkite-api-token)
export BUILDKITE_API_TOKEN
+export GH_TOKEN="$VAULT_GITHUB_TOKEN"
+
if [[ "${USE_LUCENE_SNAPSHOT_CREDS:-}" == "true" ]]; then
data=$(.buildkite/scripts/get-legacy-secret.sh aws-elastic/creds/lucene-snapshots)
@@ -117,3 +119,5 @@ if [[ -f /etc/os-release ]] && grep -q '"Amazon Linux 2"' /etc/os-release; then
echo "$(hostname -i | cut -d' ' -f 2) $(hostname -f)." | sudo tee /etc/dnsmasq.hosts
sudo systemctl restart dnsmasq.service
fi
+
+.buildkite/scripts/get-latest-test-mutes.sh
diff --git a/.buildkite/hooks/pre-command.bat b/.buildkite/hooks/pre-command.bat
index fe7c2371de0e5..752c2bf23eb14 100644
--- a/.buildkite/hooks/pre-command.bat
+++ b/.buildkite/hooks/pre-command.bat
@@ -15,9 +15,12 @@ set BUILD_NUMBER=%BUILDKITE_BUILD_NUMBER%
set COMPOSE_HTTP_TIMEOUT=120
set JOB_BRANCH=%BUILDKITE_BRANCH%
+set GH_TOKEN=%VAULT_GITHUB_TOKEN%
+
set GRADLE_BUILD_CACHE_USERNAME=vault read -field=username secret/ci/elastic-elasticsearch/migrated/gradle-build-cache
set GRADLE_BUILD_CACHE_PASSWORD=vault read -field=password secret/ci/elastic-elasticsearch/migrated/gradle-build-cache
bash.exe -c "nohup bash .buildkite/scripts/setup-monitoring.sh /dev/null 2>&1 &"
+bash.exe -c "bash .buildkite/scripts/get-latest-test-mutes.sh"
exit /b 0
diff --git a/.buildkite/scripts/get-latest-test-mutes.sh b/.buildkite/scripts/get-latest-test-mutes.sh
new file mode 100755
index 0000000000000..5721e29f1b773
--- /dev/null
+++ b/.buildkite/scripts/get-latest-test-mutes.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+if [[ ! "${BUILDKITE_PULL_REQUEST:-}" || "${BUILDKITE_AGENT_META_DATA_PROVIDER:-}" == "k8s" ]]; then
+ exit 0
+fi
+
+testMuteBranch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}"
+testMuteFile="$(mktemp)"
+
+# If this PR contains changes to muted-tests.yml, we disable this functionality
+# Otherwise, we wouldn't be able to test unmutes
+if [[ ! $(gh pr diff "$BUILDKITE_PULL_REQUEST" --name-only | grep 'muted-tests.yml') ]]; then
+ gh api -H 'Accept: application/vnd.github.v3.raw' "repos/elastic/elasticsearch/contents/muted-tests.yml?ref=$testMuteBranch" > "$testMuteFile"
+
+ if [[ -s "$testMuteFile" ]]; then
+ mkdir -p ~/.gradle
+ # This is using gradle.properties instead of an env var so that it's easily compatible with the Windows pre-command hook
+ echo "org.gradle.project.org.elasticsearch.additional.muted.tests=$testMuteFile" >> ~/.gradle/gradle.properties
+ fi
+fi
diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/MutedTestsBuildService.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/MutedTestsBuildService.java
index 1dfa3bbb29aa2..df3d1c9b70a94 100644
--- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/MutedTestsBuildService.java
+++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/MutedTestsBuildService.java
@@ -28,10 +28,12 @@
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
public abstract class MutedTestsBuildService implements BuildService {
- private final List excludePatterns = new ArrayList<>();
+ private final Set excludePatterns = new LinkedHashSet<>();
private final ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());
public MutedTestsBuildService() {
@@ -43,23 +45,23 @@ public MutedTestsBuildService() {
}
}
- public List getExcludePatterns() {
+ public Set getExcludePatterns() {
return excludePatterns;
}
- private List buildExcludePatterns(File file) {
+ private Set buildExcludePatterns(File file) {
List mutedTests;
try (InputStream is = new BufferedInputStream(new FileInputStream(file))) {
mutedTests = objectMapper.readValue(is, MutedTests.class).getTests();
if (mutedTests == null) {
- return Collections.emptyList();
+ return Collections.emptySet();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
- List excludes = new ArrayList<>();
+ Set excludes = new LinkedHashSet<>();
if (mutedTests.isEmpty() == false) {
for (MutedTestsBuildService.MutedTest mutedTest : mutedTests) {
if (mutedTest.getClassName() != null && mutedTest.getMethods().isEmpty() == false) {
From 7a98e31f9db4e7155eecc3563284640ea8b5dbf1 Mon Sep 17 00:00:00 2001
From: Brendan Cully
Date: Wed, 27 Nov 2024 12:30:02 -0800
Subject: [PATCH 07/39] Make VerifyingIndexInput public (#117518)
This way we can verify store files as we read them directly,
without going through a store abstraction we may not have if we
copy lucene files around.
---
server/src/main/java/org/elasticsearch/index/store/Store.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/server/src/main/java/org/elasticsearch/index/store/Store.java b/server/src/main/java/org/elasticsearch/index/store/Store.java
index 887fe486b6003..e6b499c07f189 100644
--- a/server/src/main/java/org/elasticsearch/index/store/Store.java
+++ b/server/src/main/java/org/elasticsearch/index/store/Store.java
@@ -1217,14 +1217,14 @@ public static String digestToString(long digest) {
* mechanism that is used in some repository plugins (S3 for example). However, the checksum is only calculated on
* the first read. All consecutive reads of the same data are not used to calculate the checksum.
*/
- static class VerifyingIndexInput extends ChecksumIndexInput {
+ public static class VerifyingIndexInput extends ChecksumIndexInput {
private final IndexInput input;
private final Checksum digest;
private final long checksumPosition;
private final byte[] checksum = new byte[8];
private long verifiedPosition = 0;
- VerifyingIndexInput(IndexInput input) {
+ public VerifyingIndexInput(IndexInput input) {
this(input, new BufferedChecksum(new CRC32()));
}
From e33e1a03da31c88e4fa7bbaa074fa33ecd4c68ab Mon Sep 17 00:00:00 2001
From: Michael Peterson
Date: Wed, 27 Nov 2024 16:14:57 -0500
Subject: [PATCH 08/39] ESQL: async search responses have CCS metadata while
searches are running (#117265)
ES|QL async search responses now include CCS metadata while the query is still running.
The CCS metadata will be present only if a remote cluster is queried and the user requested
it with the `include_ccs_metadata: true` setting on the original request to `POST /_query/async`.
The setting cannot be modified in the query to `GET /_query/async/:id`.
The core change is that the EsqlExecutionInfo object is set on the EsqlQueryTask, which is used
for async ES|QL queries, so that calls to `GET /_query/async/:id` have access to the same
EsqlExecutionInfo object that is being updated as the planning and query progress.
Secondly, the overall `took` time is now always present on ES|QL responses, even for
async-searches while the query is still running. The took time shows a "took-so-far" value
and will change upon refresh until the query has finished. This is present regardless of
the `include_ccs_metadata` setting.
Example response showing in progress state of the query:
```
GET _query/async/FlhaeTBxUU0yU2xhVzM2TlRLY3F1eXcceWlSWWZlRDhUVTJEUGFfZUROaDdtUTo0MDQwNA
```
```json
{
"id": "FlhaeTBxUU0yU2xhVzM2TlRLY3F1eXcceWlSWWZlRDhUVTJEUGFfZUROaDdtUTo0MDQwNA==",
"is_running": true,
"took": 2032,
"columns": [],
"values": [],
"_clusters": {
"total": 3,
"successful": 1,
"running": 2,
"skipped": 0,
"partial": 0,
"failed": 0,
"details": {
"(local)": {
"status": "running",
"indices": "web_traffic",
"_shards": {
"total": 2,
"skipped": 0
}
},
"remote1": {
"status": "running",
"indices": "web_traffic"
},
"remote2": {
"status": "successful",
"indices": "web_traffic",
"took": 180,
"_shards": {
"total": 2,
"successful": 2,
"skipped": 0,
"failed": 0
}
}
}
}
}
```
---
docs/changelog/117265.yaml | 5 +
.../esql/action/CrossClusterAsyncQueryIT.java | 522 ++++++++++++++++++
.../esql/action/CrossClustersQueryIT.java | 9 +-
.../xpack/esql/action/EsqlExecutionInfo.java | 13 +-
.../xpack/esql/action/EsqlQueryResponse.java | 7 +-
.../xpack/esql/action/EsqlQueryTask.java | 13 +-
.../xpack/esql/plugin/ComputeListener.java | 29 +-
.../xpack/esql/plugin/ComputeService.java | 26 +-
.../esql/plugin/TransportEsqlQueryAction.java | 23 +-
.../xpack/esql/session/EsqlSession.java | 1 +
.../esql/action/EsqlQueryResponseTests.java | 3 +-
.../esql/plugin/ComputeListenerTests.java | 16 +-
12 files changed, 634 insertions(+), 33 deletions(-)
create mode 100644 docs/changelog/117265.yaml
create mode 100644 x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterAsyncQueryIT.java
diff --git a/docs/changelog/117265.yaml b/docs/changelog/117265.yaml
new file mode 100644
index 0000000000000..ec6605155538d
--- /dev/null
+++ b/docs/changelog/117265.yaml
@@ -0,0 +1,5 @@
+pr: 117265
+summary: Async search responses have CCS metadata while searches are running
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterAsyncQueryIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterAsyncQueryIT.java
new file mode 100644
index 0000000000000..440582dcfbb45
--- /dev/null
+++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClusterAsyncQueryIT.java
@@ -0,0 +1,522 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.action;
+
+import org.elasticsearch.ElasticsearchTimeoutException;
+import org.elasticsearch.action.bulk.BulkRequestBuilder;
+import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.settings.Setting;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.compute.operator.exchange.ExchangeService;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.index.mapper.OnScriptError;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.ScriptPlugin;
+import org.elasticsearch.script.LongFieldScript;
+import org.elasticsearch.script.ScriptContext;
+import org.elasticsearch.script.ScriptEngine;
+import org.elasticsearch.search.lookup.SearchLookup;
+import org.elasticsearch.test.AbstractMultiClustersTestCase;
+import org.elasticsearch.test.XContentTestUtils;
+import org.elasticsearch.transport.RemoteClusterAware;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.json.JsonXContent;
+import org.elasticsearch.xpack.core.async.DeleteAsyncResultRequest;
+import org.elasticsearch.xpack.core.async.GetAsyncResultRequest;
+import org.elasticsearch.xpack.core.async.TransportDeleteAsyncResultAction;
+import org.elasticsearch.xpack.esql.plugin.EsqlPlugin;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.elasticsearch.core.TimeValue.timeValueMillis;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
+import static org.hamcrest.Matchers.not;
+
+public class CrossClusterAsyncQueryIT extends AbstractMultiClustersTestCase {
+
+ private static final String REMOTE_CLUSTER_1 = "cluster-a";
+ private static final String REMOTE_CLUSTER_2 = "remote-b";
+ private static String LOCAL_INDEX = "logs-1";
+ private static String REMOTE_INDEX = "logs-2";
+ private static final String INDEX_WITH_RUNTIME_MAPPING = "blocking";
+
+ @Override
+ protected Collection remoteClusterAlias() {
+ return List.of(REMOTE_CLUSTER_1, REMOTE_CLUSTER_2);
+ }
+
+ @Override
+ protected Map skipUnavailableForRemoteClusters() {
+ return Map.of(REMOTE_CLUSTER_1, randomBoolean(), REMOTE_CLUSTER_2, randomBoolean());
+ }
+
+ @Override
+ protected Collection> nodePlugins(String clusterAlias) {
+ List> plugins = new ArrayList<>(super.nodePlugins(clusterAlias));
+ plugins.add(EsqlPlugin.class);
+ plugins.add(EsqlAsyncActionIT.LocalStateEsqlAsync.class); // allows the async_search DELETE action
+ plugins.add(InternalExchangePlugin.class);
+ plugins.add(PauseFieldPlugin.class);
+ return plugins;
+ }
+
+ public static class InternalExchangePlugin extends Plugin {
+ @Override
+ public List> getSettings() {
+ return List.of(
+ Setting.timeSetting(
+ ExchangeService.INACTIVE_SINKS_INTERVAL_SETTING,
+ TimeValue.timeValueSeconds(30),
+ Setting.Property.NodeScope
+ )
+ );
+ }
+ }
+
+ @Before
+ public void resetPlugin() {
+ PauseFieldPlugin.allowEmitting = new CountDownLatch(1);
+ PauseFieldPlugin.startEmitting = new CountDownLatch(1);
+ }
+
+ public static class PauseFieldPlugin extends Plugin implements ScriptPlugin {
+ public static CountDownLatch startEmitting = new CountDownLatch(1);
+ public static CountDownLatch allowEmitting = new CountDownLatch(1);
+
+ @Override
+ public ScriptEngine getScriptEngine(Settings settings, Collection> contexts) {
+ return new ScriptEngine() {
+ @Override
+
+ public String getType() {
+ return "pause";
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public FactoryType compile(
+ String name,
+ String code,
+ ScriptContext context,
+ Map params
+ ) {
+ if (context == LongFieldScript.CONTEXT) {
+ return (FactoryType) new LongFieldScript.Factory() {
+ @Override
+ public LongFieldScript.LeafFactory newFactory(
+ String fieldName,
+ Map params,
+ SearchLookup searchLookup,
+ OnScriptError onScriptError
+ ) {
+ return ctx -> new LongFieldScript(fieldName, params, searchLookup, onScriptError, ctx) {
+ @Override
+ public void execute() {
+ startEmitting.countDown();
+ try {
+ assertTrue(allowEmitting.await(30, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ throw new AssertionError(e);
+ }
+ emit(1);
+ }
+ };
+ }
+ };
+ }
+ throw new IllegalStateException("unsupported type " + context);
+ }
+
+ @Override
+ public Set> getSupportedContexts() {
+ return Set.of(LongFieldScript.CONTEXT);
+ }
+ };
+ }
+ }
+
+ /**
+ * Includes testing for CCS metadata in the GET /_query/async/:id response while the search is still running
+ */
+ public void testSuccessfulPathways() throws Exception {
+ Map testClusterInfo = setupClusters(3);
+ int localNumShards = (Integer) testClusterInfo.get("local.num_shards");
+ int remote1NumShards = (Integer) testClusterInfo.get("remote1.num_shards");
+ int remote2NumShards = (Integer) testClusterInfo.get("remote2.blocking_index.num_shards");
+
+ Tuple includeCCSMetadata = randomIncludeCCSMetadata();
+ Boolean requestIncludeMeta = includeCCSMetadata.v1();
+ boolean responseExpectMeta = includeCCSMetadata.v2();
+
+ AtomicReference asyncExecutionId = new AtomicReference<>();
+
+ String q = "FROM logs-*,cluster-a:logs-*,remote-b:blocking | STATS total=sum(const) | LIMIT 10";
+ try (EsqlQueryResponse resp = runAsyncQuery(q, requestIncludeMeta, null, TimeValue.timeValueMillis(100))) {
+ assertTrue(resp.isRunning());
+ assertNotNull("async execution id is null", resp.asyncExecutionId());
+ asyncExecutionId.set(resp.asyncExecutionId().get());
+ // executionInfo may or may not be set on the initial response when there is a relatively low wait_for_completion_timeout
+ // so we do not check for it here
+ }
+
+ // wait until we know that the query against 'remote-b:blocking' has started
+ PauseFieldPlugin.startEmitting.await(30, TimeUnit.SECONDS);
+
+ // wait until the query of 'cluster-a:logs-*' has finished (it is not blocked since we are not searching the 'blocking' index on it)
+ assertBusy(() -> {
+ try (EsqlQueryResponse asyncResponse = getAsyncResponse(asyncExecutionId.get())) {
+ EsqlExecutionInfo executionInfo = asyncResponse.getExecutionInfo();
+ assertNotNull(executionInfo);
+ EsqlExecutionInfo.Cluster clusterA = executionInfo.getCluster("cluster-a");
+ assertThat(clusterA.getStatus(), not(equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING)));
+ }
+ });
+
+ /* at this point:
+ * the query against cluster-a should be finished
+ * the query against remote-b should be running (blocked on the PauseFieldPlugin.allowEmitting CountDown)
+ * the query against the local cluster should be running because it has a STATS clause that needs to wait on remote-b
+ */
+ try (EsqlQueryResponse asyncResponse = getAsyncResponse(asyncExecutionId.get())) {
+ EsqlExecutionInfo executionInfo = asyncResponse.getExecutionInfo();
+ assertThat(asyncResponse.isRunning(), is(true));
+ assertThat(
+ executionInfo.clusterAliases(),
+ equalTo(Set.of(REMOTE_CLUSTER_1, REMOTE_CLUSTER_2, RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY))
+ );
+ assertThat(executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.RUNNING), equalTo(2));
+ assertThat(executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL), equalTo(1));
+
+ EsqlExecutionInfo.Cluster clusterA = executionInfo.getCluster(REMOTE_CLUSTER_1);
+ assertThat(clusterA.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(clusterA.getTotalShards(), greaterThanOrEqualTo(1));
+ assertThat(clusterA.getSuccessfulShards(), equalTo(clusterA.getTotalShards()));
+ assertThat(clusterA.getSkippedShards(), equalTo(0));
+ assertThat(clusterA.getFailedShards(), equalTo(0));
+ assertThat(clusterA.getFailures().size(), equalTo(0));
+ assertThat(clusterA.getTook().millis(), greaterThanOrEqualTo(0L));
+
+ EsqlExecutionInfo.Cluster local = executionInfo.getCluster(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY);
+ // should still be RUNNING since the local cluster has to do a STATS on the coordinator, waiting on remoteB
+ assertThat(local.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING));
+ assertThat(clusterA.getTotalShards(), greaterThanOrEqualTo(1));
+
+ EsqlExecutionInfo.Cluster remoteB = executionInfo.getCluster(REMOTE_CLUSTER_2);
+ // should still be RUNNING since we haven't released the countdown lock to proceed
+ assertThat(remoteB.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING));
+ assertNull(remoteB.getSuccessfulShards()); // should not be filled in until query is finished
+
+ assertClusterMetadataInResponse(asyncResponse, responseExpectMeta, 3);
+ }
+
+ // allow remoteB query to proceed
+ PauseFieldPlugin.allowEmitting.countDown();
+
+ // wait until both remoteB and local queries have finished
+ assertBusy(() -> {
+ try (EsqlQueryResponse asyncResponse = getAsyncResponse(asyncExecutionId.get())) {
+ EsqlExecutionInfo executionInfo = asyncResponse.getExecutionInfo();
+ assertNotNull(executionInfo);
+ EsqlExecutionInfo.Cluster remoteB = executionInfo.getCluster(REMOTE_CLUSTER_2);
+ assertThat(remoteB.getStatus(), not(equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING)));
+ EsqlExecutionInfo.Cluster local = executionInfo.getCluster(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY);
+ assertThat(local.getStatus(), not(equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING)));
+ assertThat(asyncResponse.isRunning(), is(false));
+ }
+ });
+
+ try (EsqlQueryResponse asyncResponse = getAsyncResponse(asyncExecutionId.get())) {
+ EsqlExecutionInfo executionInfo = asyncResponse.getExecutionInfo();
+ assertNotNull(executionInfo);
+ assertThat(executionInfo.overallTook().millis(), greaterThanOrEqualTo(1L));
+
+ EsqlExecutionInfo.Cluster clusterA = executionInfo.getCluster(REMOTE_CLUSTER_1);
+ assertThat(clusterA.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(clusterA.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(clusterA.getTotalShards(), equalTo(remote1NumShards));
+ assertThat(clusterA.getSuccessfulShards(), equalTo(remote1NumShards));
+ assertThat(clusterA.getSkippedShards(), equalTo(0));
+ assertThat(clusterA.getFailedShards(), equalTo(0));
+ assertThat(clusterA.getFailures().size(), equalTo(0));
+
+ EsqlExecutionInfo.Cluster remoteB = executionInfo.getCluster(REMOTE_CLUSTER_2);
+ assertThat(remoteB.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(remoteB.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(remoteB.getTotalShards(), equalTo(remote2NumShards));
+ assertThat(remoteB.getSuccessfulShards(), equalTo(remote2NumShards));
+ assertThat(remoteB.getSkippedShards(), equalTo(0));
+ assertThat(remoteB.getFailedShards(), equalTo(0));
+ assertThat(remoteB.getFailures().size(), equalTo(0));
+
+ EsqlExecutionInfo.Cluster local = executionInfo.getCluster(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY);
+ assertThat(local.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(local.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(local.getTotalShards(), equalTo(localNumShards));
+ assertThat(local.getSuccessfulShards(), equalTo(localNumShards));
+ assertThat(local.getSkippedShards(), equalTo(0));
+ assertThat(local.getFailedShards(), equalTo(0));
+ assertThat(local.getFailures().size(), equalTo(0));
+ } finally {
+ AcknowledgedResponse acknowledgedResponse = deleteAsyncId(asyncExecutionId.get());
+ assertThat(acknowledgedResponse.isAcknowledged(), is(true));
+ }
+ }
+
+ public void testAsyncQueriesWithLimit0() throws IOException {
+ setupClusters(3);
+ Tuple includeCCSMetadata = randomIncludeCCSMetadata();
+ Boolean requestIncludeMeta = includeCCSMetadata.v1();
+ boolean responseExpectMeta = includeCCSMetadata.v2();
+
+ final TimeValue waitForCompletion = TimeValue.timeValueNanos(randomFrom(1L, Long.MAX_VALUE));
+ String asyncExecutionId = null;
+ try (EsqlQueryResponse resp = runAsyncQuery("FROM logs*,*:logs* | LIMIT 0", requestIncludeMeta, null, waitForCompletion)) {
+ EsqlExecutionInfo executionInfo = resp.getExecutionInfo();
+ if (resp.isRunning()) {
+ asyncExecutionId = resp.asyncExecutionId().get();
+ assertThat(resp.columns().size(), equalTo(0));
+ assertThat(resp.values().hasNext(), is(false)); // values should be empty list
+
+ } else {
+ assertThat(resp.columns().size(), equalTo(4));
+ assertThat(resp.columns().contains(new ColumnInfoImpl("const", "long")), is(true));
+ assertThat(resp.columns().contains(new ColumnInfoImpl("id", "keyword")), is(true));
+ assertThat(resp.columns().contains(new ColumnInfoImpl("tag", "keyword")), is(true));
+ assertThat(resp.columns().contains(new ColumnInfoImpl("v", "long")), is(true));
+ assertThat(resp.values().hasNext(), is(false)); // values should be empty list
+
+ assertNotNull(executionInfo);
+ assertThat(executionInfo.isCrossClusterSearch(), is(true));
+ long overallTookMillis = executionInfo.overallTook().millis();
+ assertThat(overallTookMillis, greaterThanOrEqualTo(0L));
+ assertThat(executionInfo.includeCCSMetadata(), equalTo(responseExpectMeta));
+ assertThat(executionInfo.clusterAliases(), equalTo(Set.of(LOCAL_CLUSTER, REMOTE_CLUSTER_1, REMOTE_CLUSTER_2)));
+
+ EsqlExecutionInfo.Cluster remoteCluster = executionInfo.getCluster(REMOTE_CLUSTER_1);
+ assertThat(remoteCluster.getIndexExpression(), equalTo("logs*"));
+ assertThat(remoteCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(remoteCluster.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(remoteCluster.getTook().millis(), lessThanOrEqualTo(overallTookMillis));
+ assertThat(remoteCluster.getTotalShards(), equalTo(0));
+ assertThat(remoteCluster.getSuccessfulShards(), equalTo(0));
+ assertThat(remoteCluster.getSkippedShards(), equalTo(0));
+ assertThat(remoteCluster.getFailedShards(), equalTo(0));
+
+ EsqlExecutionInfo.Cluster remote2Cluster = executionInfo.getCluster(REMOTE_CLUSTER_1);
+ assertThat(remote2Cluster.getIndexExpression(), equalTo("logs*"));
+ assertThat(remote2Cluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(remote2Cluster.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(remote2Cluster.getTook().millis(), lessThanOrEqualTo(overallTookMillis));
+ assertThat(remote2Cluster.getTotalShards(), equalTo(0));
+ assertThat(remote2Cluster.getSuccessfulShards(), equalTo(0));
+ assertThat(remote2Cluster.getSkippedShards(), equalTo(0));
+ assertThat(remote2Cluster.getFailedShards(), equalTo(0));
+
+ EsqlExecutionInfo.Cluster localCluster = executionInfo.getCluster(LOCAL_CLUSTER);
+ assertThat(localCluster.getIndexExpression(), equalTo("logs*"));
+ assertThat(localCluster.getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
+ assertThat(localCluster.getTook().millis(), greaterThanOrEqualTo(0L));
+ assertThat(localCluster.getTook().millis(), lessThanOrEqualTo(overallTookMillis));
+ assertThat(remote2Cluster.getTotalShards(), equalTo(0));
+ assertThat(remote2Cluster.getSuccessfulShards(), equalTo(0));
+ assertThat(remote2Cluster.getSkippedShards(), equalTo(0));
+ assertThat(remote2Cluster.getFailedShards(), equalTo(0));
+
+ assertClusterMetadataInResponse(resp, responseExpectMeta, 3);
+ }
+ } finally {
+ if (asyncExecutionId != null) {
+ AcknowledgedResponse acknowledgedResponse = deleteAsyncId(asyncExecutionId);
+ assertThat(acknowledgedResponse.isAcknowledged(), is(true));
+ }
+ }
+ }
+
+ protected EsqlQueryResponse runAsyncQuery(String query, Boolean ccsMetadata, QueryBuilder filter, TimeValue waitCompletionTime) {
+ EsqlQueryRequest request = EsqlQueryRequest.asyncEsqlQueryRequest();
+ request.query(query);
+ request.pragmas(AbstractEsqlIntegTestCase.randomPragmas());
+ request.profile(randomInt(5) == 2);
+ request.columnar(randomBoolean());
+ if (ccsMetadata != null) {
+ request.includeCCSMetadata(ccsMetadata);
+ }
+ request.waitForCompletionTimeout(waitCompletionTime);
+ request.keepOnCompletion(false);
+ if (filter != null) {
+ request.filter(filter);
+ }
+ return runAsyncQuery(request);
+ }
+
+ protected EsqlQueryResponse runAsyncQuery(EsqlQueryRequest request) {
+ try {
+ return client(LOCAL_CLUSTER).execute(EsqlQueryAction.INSTANCE, request).actionGet(30, TimeUnit.SECONDS);
+ } catch (ElasticsearchTimeoutException e) {
+ throw new AssertionError("timeout waiting for query response", e);
+ }
+ }
+
+ AcknowledgedResponse deleteAsyncId(String id) {
+ try {
+ DeleteAsyncResultRequest request = new DeleteAsyncResultRequest(id);
+ return client().execute(TransportDeleteAsyncResultAction.TYPE, request).actionGet(30, TimeUnit.SECONDS);
+ } catch (ElasticsearchTimeoutException e) {
+ throw new AssertionError("timeout waiting for DELETE response", e);
+ }
+ }
+
+ EsqlQueryResponse getAsyncResponse(String id) {
+ try {
+ var getResultsRequest = new GetAsyncResultRequest(id).setWaitForCompletionTimeout(timeValueMillis(1));
+ return client().execute(EsqlAsyncGetResultAction.INSTANCE, getResultsRequest).actionGet(30, TimeUnit.SECONDS);
+ } catch (ElasticsearchTimeoutException e) {
+ throw new AssertionError("timeout waiting for GET async result", e);
+ }
+ }
+
+ private static void assertClusterMetadataInResponse(EsqlQueryResponse resp, boolean responseExpectMeta, int numClusters) {
+ try {
+ final Map esqlResponseAsMap = XContentTestUtils.convertToMap(resp);
+ final Object clusters = esqlResponseAsMap.get("_clusters");
+ if (responseExpectMeta) {
+ assertNotNull(clusters);
+ // test a few entries to ensure it looks correct (other tests do a full analysis of the metadata in the response)
+ @SuppressWarnings("unchecked")
+ Map inner = (Map) clusters;
+ assertTrue(inner.containsKey("total"));
+ assertThat((int) inner.get("total"), equalTo(numClusters));
+ assertTrue(inner.containsKey("details"));
+ } else {
+ assertNull(clusters);
+ }
+ } catch (IOException e) {
+ fail("Could not convert ESQLQueryResponse to Map: " + e);
+ }
+ }
+
+ /**
+ * v1: value to send to runQuery (can be null; null means use default value)
+ * v2: whether to expect CCS Metadata in the response (cannot be null)
+ * @return
+ */
+ public static Tuple randomIncludeCCSMetadata() {
+ return switch (randomIntBetween(1, 3)) {
+ case 1 -> new Tuple<>(Boolean.TRUE, Boolean.TRUE);
+ case 2 -> new Tuple<>(Boolean.FALSE, Boolean.FALSE);
+ case 3 -> new Tuple<>(null, Boolean.FALSE);
+ default -> throw new AssertionError("should not get here");
+ };
+ }
+
+ Map setupClusters(int numClusters) throws IOException {
+ assert numClusters == 2 || numClusters == 3 : "2 or 3 clusters supported not: " + numClusters;
+ int numShardsLocal = randomIntBetween(1, 5);
+ populateLocalIndices(LOCAL_INDEX, numShardsLocal);
+
+ int numShardsRemote = randomIntBetween(1, 5);
+ populateRemoteIndices(REMOTE_CLUSTER_1, REMOTE_INDEX, numShardsRemote);
+
+ Map clusterInfo = new HashMap<>();
+ clusterInfo.put("local.num_shards", numShardsLocal);
+ clusterInfo.put("local.index", LOCAL_INDEX);
+ clusterInfo.put("remote1.num_shards", numShardsRemote);
+ clusterInfo.put("remote1.index", REMOTE_INDEX);
+
+ if (numClusters == 3) {
+ int numShardsRemote2 = randomIntBetween(1, 5);
+ populateRemoteIndices(REMOTE_CLUSTER_2, REMOTE_INDEX, numShardsRemote2);
+ populateRemoteIndicesWithRuntimeMapping(REMOTE_CLUSTER_2);
+ clusterInfo.put("remote2.index", REMOTE_INDEX);
+ clusterInfo.put("remote2.num_shards", numShardsRemote2);
+ clusterInfo.put("remote2.blocking_index", INDEX_WITH_RUNTIME_MAPPING);
+ clusterInfo.put("remote2.blocking_index.num_shards", 1);
+ }
+
+ String skipUnavailableKey = Strings.format("cluster.remote.%s.skip_unavailable", REMOTE_CLUSTER_1);
+ Setting> skipUnavailableSetting = cluster(REMOTE_CLUSTER_1).clusterService().getClusterSettings().get(skipUnavailableKey);
+ boolean skipUnavailable = (boolean) cluster(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY).clusterService()
+ .getClusterSettings()
+ .get(skipUnavailableSetting);
+ clusterInfo.put("remote.skip_unavailable", skipUnavailable);
+
+ return clusterInfo;
+ }
+
+ void populateLocalIndices(String indexName, int numShards) {
+ Client localClient = client(LOCAL_CLUSTER);
+ assertAcked(
+ localClient.admin()
+ .indices()
+ .prepareCreate(indexName)
+ .setSettings(Settings.builder().put("index.number_of_shards", numShards))
+ .setMapping("id", "type=keyword", "tag", "type=keyword", "v", "type=long", "const", "type=long")
+ );
+ for (int i = 0; i < 10; i++) {
+ localClient.prepareIndex(indexName).setSource("id", "local-" + i, "tag", "local", "v", i).get();
+ }
+ localClient.admin().indices().prepareRefresh(indexName).get();
+ }
+
+ void populateRemoteIndicesWithRuntimeMapping(String clusterAlias) throws IOException {
+ XContentBuilder mapping = JsonXContent.contentBuilder().startObject();
+ mapping.startObject("runtime");
+ {
+ mapping.startObject("const");
+ {
+ mapping.field("type", "long");
+ mapping.startObject("script").field("source", "").field("lang", "pause").endObject();
+ }
+ mapping.endObject();
+ }
+ mapping.endObject();
+ mapping.endObject();
+ client(clusterAlias).admin().indices().prepareCreate(INDEX_WITH_RUNTIME_MAPPING).setMapping(mapping).get();
+ BulkRequestBuilder bulk = client(clusterAlias).prepareBulk(INDEX_WITH_RUNTIME_MAPPING)
+ .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE);
+ for (int i = 0; i < 10; i++) {
+ bulk.add(new IndexRequest().source("foo", i));
+ }
+ bulk.get();
+ }
+
+ void populateRemoteIndices(String clusterAlias, String indexName, int numShards) throws IOException {
+ Client remoteClient = client(clusterAlias);
+ assertAcked(
+ remoteClient.admin()
+ .indices()
+ .prepareCreate(indexName)
+ .setSettings(Settings.builder().put("index.number_of_shards", numShards))
+ .setMapping("id", "type=keyword", "tag", "type=keyword", "v", "type=long")
+ );
+ for (int i = 0; i < 10; i++) {
+ remoteClient.prepareIndex(indexName).setSource("id", "remote-" + i, "tag", "remote", "v", i * i).get();
+ }
+ remoteClient.admin().indices().prepareRefresh(indexName).get();
+ }
+}
diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersQueryIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersQueryIT.java
index 6801e1f4eb404..596c70e57ccd6 100644
--- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersQueryIT.java
+++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersQueryIT.java
@@ -61,6 +61,10 @@
public class CrossClustersQueryIT extends AbstractMultiClustersTestCase {
private static final String REMOTE_CLUSTER_1 = "cluster-a";
private static final String REMOTE_CLUSTER_2 = "remote-b";
+ private static String LOCAL_INDEX = "logs-1";
+ private static String IDX_ALIAS = "alias1";
+ private static String FILTERED_IDX_ALIAS = "alias-filtered-1";
+ private static String REMOTE_INDEX = "logs-2";
@Override
protected Collection remoteClusterAlias() {
@@ -1278,11 +1282,6 @@ Map setupTwoClusters() {
return setupClusters(2);
}
- private static String LOCAL_INDEX = "logs-1";
- private static String IDX_ALIAS = "alias1";
- private static String FILTERED_IDX_ALIAS = "alias-filtered-1";
- private static String REMOTE_INDEX = "logs-2";
-
Map setupClusters(int numClusters) {
assert numClusters == 2 || numClusters == 3 : "2 or 3 clusters supported not: " + numClusters;
int numShardsLocal = randomIntBetween(1, 5);
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 80bb2afe57122..ba7a7e8266845 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
@@ -169,6 +169,17 @@ public TimeValue overallTook() {
return overallTook;
}
+ /**
+ * How much time the query took since starting.
+ */
+ public TimeValue tookSoFar() {
+ if (relativeStartNanos == null) {
+ return new TimeValue(0);
+ } else {
+ return new TimeValue(System.nanoTime() - relativeStartNanos, TimeUnit.NANOSECONDS);
+ }
+ }
+
public Set clusterAliases() {
return clusterInfo.keySet();
}
@@ -478,7 +489,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
{
builder.field(STATUS_FIELD.getPreferredName(), getStatus().toString());
builder.field(INDICES_FIELD.getPreferredName(), indexExpression);
- if (took != null) {
+ if (took != null && status != Status.RUNNING) {
builder.field(TOOK.getPreferredName(), took.millis());
}
if (totalShards != null) {
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 4e59d5419fe6f..77aed298baea5 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
@@ -196,8 +196,11 @@ public Iterator extends ToXContent> toXContentChunked(ToXContent.Params params
}
b.field("is_running", isRunning);
}
- if (executionInfo != null && executionInfo.overallTook() != null) {
- b.field("took", executionInfo.overallTook().millis());
+ if (executionInfo != null) {
+ long tookInMillis = executionInfo.overallTook() == null
+ ? executionInfo.tookSoFar().millis()
+ : executionInfo.overallTook().millis();
+ b.field("took", tookInMillis);
}
if (dropNullColumns) {
b.append(ResponseXContentUtils.allColumns(columns, "all_columns"))
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryTask.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryTask.java
index b12cf4eb354bf..f896a25317102 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryTask.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlQueryTask.java
@@ -17,6 +17,8 @@
public class EsqlQueryTask extends StoredAsyncTask {
+ private EsqlExecutionInfo executionInfo;
+
public EsqlQueryTask(
long id,
String type,
@@ -29,10 +31,19 @@ public EsqlQueryTask(
TimeValue keepAlive
) {
super(id, type, action, description, parentTaskId, headers, originHeaders, asyncExecutionId, keepAlive);
+ this.executionInfo = null;
+ }
+
+ public void setExecutionInfo(EsqlExecutionInfo executionInfo) {
+ this.executionInfo = executionInfo;
+ }
+
+ public EsqlExecutionInfo executionInfo() {
+ return executionInfo;
}
@Override
public EsqlQueryResponse getCurrentResult() {
- return new EsqlQueryResponse(List.of(), List.of(), null, false, getExecutionId().getEncoded(), true, true, null);
+ return new EsqlQueryResponse(List.of(), List.of(), null, false, getExecutionId().getEncoded(), true, true, executionInfo);
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeListener.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeListener.java
index 49af4a593e6e5..8d041ffbdf0e4 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeListener.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeListener.java
@@ -112,6 +112,7 @@ private ComputeListener(
if (runningOnRemoteCluster()) {
// for remote executions - this ComputeResponse is created on the remote cluster/node and will be serialized and
// received by the acquireCompute method callback on the coordinating cluster
+ setFinalStatusAndShardCounts(clusterAlias, executionInfo);
EsqlExecutionInfo.Cluster cluster = esqlExecutionInfo.getCluster(clusterAlias);
result = new ComputeResponse(
collectedProfiles.isEmpty() ? List.of() : collectedProfiles.stream().toList(),
@@ -126,19 +127,33 @@ private ComputeListener(
if (coordinatingClusterIsSearchedInCCS()) {
// if not already marked as SKIPPED, mark the local cluster as finished once the coordinator and all
// data nodes have finished processing
- executionInfo.swapCluster(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, (k, v) -> {
- if (v.getStatus() != EsqlExecutionInfo.Cluster.Status.SKIPPED) {
- return new EsqlExecutionInfo.Cluster.Builder(v).setStatus(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL).build();
- } else {
- return v;
- }
- });
+ setFinalStatusAndShardCounts(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, executionInfo);
}
}
delegate.onResponse(result);
}, e -> delegate.onFailure(failureCollector.getFailure())));
}
+ private static void setFinalStatusAndShardCounts(String clusterAlias, EsqlExecutionInfo executionInfo) {
+ executionInfo.swapCluster(clusterAlias, (k, v) -> {
+ // TODO: once PARTIAL status is supported (partial results work to come), modify this code as needed
+ if (v.getStatus() != EsqlExecutionInfo.Cluster.Status.SKIPPED) {
+ assert v.getTotalShards() != null && v.getSkippedShards() != null : "Null total or skipped shard count: " + v;
+ return new EsqlExecutionInfo.Cluster.Builder(v).setStatus(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL)
+ /*
+ * Total and skipped shard counts are set early in execution (after can-match).
+ * Until ES|QL supports shard-level partial results, we just set all non-skipped shards
+ * as successful and none are failed.
+ */
+ .setSuccessfulShards(v.getTotalShards())
+ .setFailedShards(0)
+ .build();
+ } else {
+ return v;
+ }
+ });
+ }
+
/**
* @return true if the "local" querying/coordinator cluster is being searched in a cross-cluster search
*/
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
index 6a0d1bf9bb035..73266551f169c 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
@@ -178,6 +178,7 @@ public void execute(
null
);
String local = RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY;
+ updateShardCountForCoordinatorOnlyQuery(execInfo);
try (var computeListener = ComputeListener.create(local, transportService, rootTask, execInfo, listener.map(r -> {
updateExecutionInfoAfterCoordinatorOnlyQuery(execInfo);
return new Result(physicalPlan.output(), collectedPages, r.getProfiles(), execInfo);
@@ -260,6 +261,22 @@ public void execute(
}
}
+ // For queries like: FROM logs* | LIMIT 0 (including cross-cluster LIMIT 0 queries)
+ private static void updateShardCountForCoordinatorOnlyQuery(EsqlExecutionInfo execInfo) {
+ if (execInfo.isCrossClusterSearch()) {
+ for (String clusterAlias : execInfo.clusterAliases()) {
+ execInfo.swapCluster(
+ clusterAlias,
+ (k, v) -> new EsqlExecutionInfo.Cluster.Builder(v).setTotalShards(0)
+ .setSuccessfulShards(0)
+ .setSkippedShards(0)
+ .setFailedShards(0)
+ .build()
+ );
+ }
+ }
+ }
+
// For queries like: FROM logs* | LIMIT 0 (including cross-cluster LIMIT 0 queries)
private static void updateExecutionInfoAfterCoordinatorOnlyQuery(EsqlExecutionInfo execInfo) {
execInfo.markEndQuery(); // TODO: revisit this time recording model as part of INLINESTATS improvements
@@ -267,11 +284,7 @@ private static void updateExecutionInfoAfterCoordinatorOnlyQuery(EsqlExecutionIn
assert execInfo.planningTookTime() != null : "Planning took time should be set on EsqlExecutionInfo but is null";
for (String clusterAlias : execInfo.clusterAliases()) {
execInfo.swapCluster(clusterAlias, (k, v) -> {
- var builder = new EsqlExecutionInfo.Cluster.Builder(v).setTook(execInfo.overallTook())
- .setTotalShards(0)
- .setSuccessfulShards(0)
- .setSkippedShards(0)
- .setFailedShards(0);
+ var builder = new EsqlExecutionInfo.Cluster.Builder(v).setTook(execInfo.overallTook());
if (v.getStatus() == EsqlExecutionInfo.Cluster.Status.RUNNING) {
builder.setStatus(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL);
}
@@ -324,9 +337,8 @@ private void startComputeOnDataNodes(
executionInfo.swapCluster(
clusterAlias,
(k, v) -> new EsqlExecutionInfo.Cluster.Builder(v).setTotalShards(dataNodeResult.totalShards())
- .setSuccessfulShards(dataNodeResult.totalShards())
+ // do not set successful or failed shard count here - do it when search is done
.setSkippedShards(dataNodeResult.skippedShards())
- .setFailedShards(0)
.build()
);
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 fdc6e06a11032..76bfb95d07926 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
@@ -151,6 +151,8 @@ private void doExecuteForked(Task task, EsqlQueryRequest request, ActionListener
@Override
public void execute(EsqlQueryRequest request, EsqlQueryTask task, ActionListener listener) {
+ // set EsqlExecutionInfo on async-search task so that it is accessible to GET _query/async while the query is still running
+ task.setExecutionInfo(createEsqlExecutionInfo(request));
ActionListener.run(listener, l -> innerExecute(task, request, l));
}
@@ -170,10 +172,9 @@ private void innerExecute(Task task, EsqlQueryRequest request, ActionListener remoteClusterService.isSkipUnavailable(clusterAlias),
- request.includeCCSMetadata()
- );
+ // async-query uses EsqlQueryTask, so pull the EsqlExecutionInfo out of the task
+ // sync query uses CancellableTask which does not have EsqlExecutionInfo, so create one
+ EsqlExecutionInfo executionInfo = getOrCreateExecutionInfo(task, request);
PlanRunner planRunner = (plan, resultListener) -> computeService.execute(
sessionId,
(CancellableTask) task,
@@ -194,6 +195,18 @@ private void innerExecute(Task task, EsqlQueryRequest request, ActionListener remoteClusterService.isSkipUnavailable(clusterAlias), request.includeCCSMetadata());
+ }
+
private EsqlQueryResponse toResponse(Task task, EsqlQueryRequest request, Configuration configuration, Result result) {
List columns = result.schema().stream().map(c -> new ColumnInfoImpl(c.name(), c.dataType().outputType())).toList();
EsqlQueryResponse.Profile profile = configuration.profile() ? new EsqlQueryResponse.Profile(result.profiles()) : null;
@@ -269,7 +282,7 @@ public EsqlQueryResponse initialResponse(EsqlQueryTask task) {
asyncExecutionId,
true, // is_running
true, // isAsync
- null
+ task.executionInfo()
);
}
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 8f65914d1c30d..021596c31f65d 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
@@ -147,6 +147,7 @@ public String sessionId() {
* Execute an ESQL request.
*/
public void execute(EsqlQueryRequest request, EsqlExecutionInfo executionInfo, PlanRunner planRunner, ActionListener listener) {
+ assert executionInfo != null : "Null EsqlExecutionInfo";
LOGGER.debug("ESQL query:\n{}", request.query());
analyzedPlan(
parse(request.query(), request.params()),
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 35364089127cc..f7b402b909732 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
@@ -519,14 +519,15 @@ static EsqlQueryResponse fromXContent(XContentParser parser) {
}
public void testChunkResponseSizeColumnar() {
- int sizeClusterDetails = 14;
try (EsqlQueryResponse resp = randomResponse(true, null)) {
+ int sizeClusterDetails = 14;
int columnCount = resp.pages().get(0).getBlockCount();
int bodySize = resp.pages().stream().mapToInt(p -> p.getPositionCount() * p.getBlockCount()).sum() + columnCount * 2;
assertChunkCount(resp, r -> 5 + sizeClusterDetails + bodySize);
}
try (EsqlQueryResponse resp = randomResponseAsync(true, null, true)) {
+ int sizeClusterDetails = resp.isRunning() ? 13 : 14; // overall took time not present when is_running=true
int columnCount = resp.pages().get(0).getBlockCount();
int bodySize = resp.pages().stream().mapToInt(p -> p.getPositionCount() * p.getBlockCount()).sum() + columnCount * 2;
assertChunkCount(resp, r -> 7 + sizeClusterDetails + bodySize); // is_running
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plugin/ComputeListenerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plugin/ComputeListenerTests.java
index 625cb5628d039..b606f99df437c 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plugin/ComputeListenerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plugin/ComputeListenerTests.java
@@ -353,10 +353,7 @@ public void testAcquireComputeRunningOnRemoteClusterFillsInTookTime() {
assertThat(response.getTook().millis(), greaterThanOrEqualTo(0L));
assertThat(executionInfo.getCluster(remoteAlias).getTook().millis(), greaterThanOrEqualTo(0L));
assertThat(executionInfo.getCluster(remoteAlias).getTook(), equalTo(response.getTook()));
-
- // the status in the (remote) executionInfo will still be RUNNING, since the SUCCESSFUL status gets set on the querying
- // cluster executionInfo in the acquireCompute CCS listener, NOT present in this test - see testCollectComputeResultsInCCSListener
- assertThat(executionInfo.getCluster(remoteAlias).getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.RUNNING));
+ assertThat(executionInfo.getCluster(remoteAlias).getStatus(), equalTo(EsqlExecutionInfo.Cluster.Status.SUCCESSFUL));
Mockito.verifyNoInteractions(transportService.getTaskManager());
}
@@ -376,6 +373,17 @@ public void testAcquireComputeRunningOnQueryingClusterFillsInTookTime() {
// fully filled in for cross-cluster searches
executionInfo.swapCluster(localCluster, (k, v) -> new EsqlExecutionInfo.Cluster(localCluster, "logs*", false));
executionInfo.swapCluster("my_remote", (k, v) -> new EsqlExecutionInfo.Cluster("my_remote", "my_remote:logs*", false));
+
+ // before acquire-compute, can-match (SearchShards) runs filling in total shards and skipped shards, so simulate that here
+ executionInfo.swapCluster(
+ localCluster,
+ (k, v) -> new EsqlExecutionInfo.Cluster.Builder(v).setTotalShards(10).setSkippedShards(1).build()
+ );
+ executionInfo.swapCluster(
+ "my_remote",
+ (k, v) -> new EsqlExecutionInfo.Cluster.Builder(v).setTotalShards(10).setSkippedShards(1).build()
+ );
+
try (
ComputeListener computeListener = ComputeListener.create(
// whereRunning=localCluster simulates running on the querying cluster
From c2e4afcfd584fe35aa88a9b9840cf5ff4c3c80b6 Mon Sep 17 00:00:00 2001
From: Nhat Nguyen
Date: Wed, 27 Nov 2024 13:23:20 -0800
Subject: [PATCH 09/39] Try to finish remote sink once (#117592)
Currently, we have three clients fetching pages by default, each with
its own lifecycle. This can result in scenarios where more than one
request is sent to complete the remote sink. While this does not cause
correctness issues, it is inefficient, especially for cross-cluster
requests. This change tracks the status of the remote sink and tries to
send only one finish request per remote sink.
---
.../operator/exchange/ExchangeService.java | 28 +++++++++++++++++++
.../exchange/ExchangeServiceTests.java | 9 ++++++
2 files changed, 37 insertions(+)
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeService.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeService.java
index d633270b5c595..a943a90d02e87 100644
--- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeService.java
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeService.java
@@ -42,6 +42,7 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
@@ -292,6 +293,7 @@ static final class TransportRemoteSink implements RemoteSink {
final Executor responseExecutor;
final AtomicLong estimatedPageSizeInBytes = new AtomicLong(0L);
+ final AtomicBoolean finished = new AtomicBoolean(false);
TransportRemoteSink(
TransportService transportService,
@@ -311,6 +313,32 @@ static final class TransportRemoteSink implements RemoteSink {
@Override
public void fetchPageAsync(boolean allSourcesFinished, ActionListener listener) {
+ if (allSourcesFinished) {
+ if (finished.compareAndSet(false, true)) {
+ doFetchPageAsync(true, listener);
+ } else {
+ // already finished or promised
+ listener.onResponse(new ExchangeResponse(blockFactory, null, true));
+ }
+ } else {
+ // already finished
+ if (finished.get()) {
+ listener.onResponse(new ExchangeResponse(blockFactory, null, true));
+ return;
+ }
+ doFetchPageAsync(false, ActionListener.wrap(r -> {
+ if (r.finished()) {
+ finished.set(true);
+ }
+ listener.onResponse(r);
+ }, e -> {
+ finished.set(true);
+ listener.onFailure(e);
+ }));
+ }
+ }
+
+ private void doFetchPageAsync(boolean allSourcesFinished, ActionListener listener) {
final long reservedBytes = allSourcesFinished ? 0 : estimatedPageSizeInBytes.get();
if (reservedBytes > 0) {
// This doesn't fully protect ESQL from OOM, but reduces the likelihood.
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/exchange/ExchangeServiceTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/exchange/ExchangeServiceTests.java
index 8949f61b7420d..4178f02898d79 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/exchange/ExchangeServiceTests.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/exchange/ExchangeServiceTests.java
@@ -449,6 +449,15 @@ public void testConcurrentWithTransportActions() {
ExchangeService exchange1 = new ExchangeService(Settings.EMPTY, threadPool, ESQL_TEST_EXECUTOR, blockFactory());
exchange1.registerTransportHandler(node1);
AbstractSimpleTransportTestCase.connectToNode(node0, node1.getLocalNode());
+ Set finishingRequests = ConcurrentCollections.newConcurrentSet();
+ node1.addRequestHandlingBehavior(ExchangeService.EXCHANGE_ACTION_NAME, (handler, request, channel, task) -> {
+ final ExchangeRequest exchangeRequest = (ExchangeRequest) request;
+ if (exchangeRequest.sourcesFinished()) {
+ String exchangeId = exchangeRequest.exchangeId();
+ assertTrue("tried to finish [" + exchangeId + "] twice", finishingRequests.add(exchangeId));
+ }
+ handler.messageReceived(request, channel, task);
+ });
try (exchange0; exchange1; node0; node1) {
String exchangeId = "exchange";
From 656b5f94804a9efe9329041a933e92075400f592 Mon Sep 17 00:00:00 2001
From: Jack Conradson
Date: Wed, 27 Nov 2024 14:31:30 -0800
Subject: [PATCH 10/39] Refactor PluginsLoader to better support tests
(#117522)
This refactors the way PluginsLoader is created to better support
various types of testing.
---
.../script/ScriptScoreBenchmark.java | 2 +-
.../bootstrap/Elasticsearch.java | 2 +-
.../elasticsearch/plugins/PluginsLoader.java | 71 ++++++++++++-------
.../plugins/PluginsServiceTests.java | 12 ++--
.../plugins/MockPluginsService.java | 13 ++--
.../bench/WatcherScheduleEngineBenchmark.java | 5 +-
6 files changed, 61 insertions(+), 44 deletions(-)
diff --git a/benchmarks/src/main/java/org/elasticsearch/benchmark/script/ScriptScoreBenchmark.java b/benchmarks/src/main/java/org/elasticsearch/benchmark/script/ScriptScoreBenchmark.java
index d44586ef4901a..b44f04c3a26a4 100644
--- a/benchmarks/src/main/java/org/elasticsearch/benchmark/script/ScriptScoreBenchmark.java
+++ b/benchmarks/src/main/java/org/elasticsearch/benchmark/script/ScriptScoreBenchmark.java
@@ -77,7 +77,7 @@ public class ScriptScoreBenchmark {
private final PluginsService pluginsService = new PluginsService(
Settings.EMPTY,
null,
- new PluginsLoader(null, Path.of(System.getProperty("plugins.dir")))
+ PluginsLoader.createPluginsLoader(null, Path.of(System.getProperty("plugins.dir")))
);
private final ScriptModule scriptModule = new ScriptModule(Settings.EMPTY, pluginsService.filterPlugins(ScriptPlugin.class).toList());
diff --git a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java
index b7774259bf289..c06ea9305aef8 100644
--- a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java
+++ b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java
@@ -206,7 +206,7 @@ private static void initPhase2(Bootstrap bootstrap) throws IOException {
);
// load the plugin Java modules and layers now for use in entitlements
- var pluginsLoader = new PluginsLoader(nodeEnv.modulesFile(), nodeEnv.pluginsFile());
+ var pluginsLoader = PluginsLoader.createPluginsLoader(nodeEnv.modulesFile(), nodeEnv.pluginsFile());
bootstrap.setPluginsLoader(pluginsLoader);
if (Boolean.parseBoolean(System.getProperty("es.entitlements.enabled"))) {
diff --git a/server/src/main/java/org/elasticsearch/plugins/PluginsLoader.java b/server/src/main/java/org/elasticsearch/plugins/PluginsLoader.java
index 6b3eda6c0c9b4..aa21e5c64d903 100644
--- a/server/src/main/java/org/elasticsearch/plugins/PluginsLoader.java
+++ b/server/src/main/java/org/elasticsearch/plugins/PluginsLoader.java
@@ -118,15 +118,30 @@ public static LayerAndLoader ofLoader(ClassLoader loader) {
* @param modulesDirectory The directory modules exist in, or null if modules should not be loaded from the filesystem
* @param pluginsDirectory The directory plugins exist in, or null if plugins should not be loaded from the filesystem
*/
- @SuppressWarnings("this-escape")
- public PluginsLoader(Path modulesDirectory, Path pluginsDirectory) {
+ public static PluginsLoader createPluginsLoader(Path modulesDirectory, Path pluginsDirectory) {
+ return createPluginsLoader(modulesDirectory, pluginsDirectory, true);
+ }
- Map> qualifiedExports = new HashMap<>(ModuleQualifiedExportsService.getBootServices());
- addServerExportsService(qualifiedExports);
+ /**
+ * Constructs a new PluginsLoader
+ *
+ * @param modulesDirectory The directory modules exist in, or null if modules should not be loaded from the filesystem
+ * @param pluginsDirectory The directory plugins exist in, or null if plugins should not be loaded from the filesystem
+ * @param withServerExports {@code true} to add server module exports
+ */
+ public static PluginsLoader createPluginsLoader(Path modulesDirectory, Path pluginsDirectory, boolean withServerExports) {
+ Map> qualifiedExports;
+ if (withServerExports) {
+ qualifiedExports = new HashMap<>(ModuleQualifiedExportsService.getBootServices());
+ addServerExportsService(qualifiedExports);
+ } else {
+ qualifiedExports = Collections.emptyMap();
+ }
Set seenBundles = new LinkedHashSet<>();
// load (elasticsearch) module layers
+ List moduleDescriptors;
if (modulesDirectory != null) {
try {
Set modules = PluginsUtils.getModuleBundles(modulesDirectory);
@@ -140,6 +155,7 @@ public PluginsLoader(Path modulesDirectory, Path pluginsDirectory) {
}
// load plugin layers
+ List pluginDescriptors;
if (pluginsDirectory != null) {
try {
// TODO: remove this leniency, but tests bogusly rely on it
@@ -158,7 +174,28 @@ public PluginsLoader(Path modulesDirectory, Path pluginsDirectory) {
pluginDescriptors = Collections.emptyList();
}
- this.loadedPluginLayers = Collections.unmodifiableMap(loadPluginLayers(seenBundles, qualifiedExports));
+ Map loadedPluginLayers = new LinkedHashMap<>();
+ Map> transitiveUrls = new HashMap<>();
+ List sortedBundles = PluginsUtils.sortBundles(seenBundles);
+ if (sortedBundles.isEmpty() == false) {
+ Set systemLoaderURLs = JarHell.parseModulesAndClassPath();
+ for (PluginBundle bundle : sortedBundles) {
+ PluginsUtils.checkBundleJarHell(systemLoaderURLs, bundle, transitiveUrls);
+ loadPluginLayer(bundle, loadedPluginLayers, qualifiedExports);
+ }
+ }
+
+ return new PluginsLoader(moduleDescriptors, pluginDescriptors, loadedPluginLayers);
+ }
+
+ PluginsLoader(
+ List moduleDescriptors,
+ List pluginDescriptors,
+ Map loadedPluginLayers
+ ) {
+ this.moduleDescriptors = moduleDescriptors;
+ this.pluginDescriptors = pluginDescriptors;
+ this.loadedPluginLayers = loadedPluginLayers;
}
public List moduleDescriptors() {
@@ -173,25 +210,7 @@ public Stream pluginLayers() {
return loadedPluginLayers.values().stream().map(Function.identity());
}
- private Map loadPluginLayers(
- Set bundles,
- Map> qualifiedExports
- ) {
- Map loaded = new LinkedHashMap<>();
- Map> transitiveUrls = new HashMap<>();
- List sortedBundles = PluginsUtils.sortBundles(bundles);
- if (sortedBundles.isEmpty() == false) {
- Set systemLoaderURLs = JarHell.parseModulesAndClassPath();
- for (PluginBundle bundle : sortedBundles) {
- PluginsUtils.checkBundleJarHell(systemLoaderURLs, bundle, transitiveUrls);
- loadPluginLayer(bundle, loaded, qualifiedExports);
- }
- }
-
- return loaded;
- }
-
- private void loadPluginLayer(
+ private static void loadPluginLayer(
PluginBundle bundle,
Map loaded,
Map> qualifiedExports
@@ -211,7 +230,7 @@ private void loadPluginLayer(
}
final ClassLoader parentLoader = ExtendedPluginsClassLoader.create(
- getClass().getClassLoader(),
+ PluginsLoader.class.getClassLoader(),
extendedPlugins.stream().map(LoadedPluginLayer::spiClassLoader).toList()
);
LayerAndLoader spiLayerAndLoader = null;
@@ -427,7 +446,7 @@ private static List parentLayersOrBoot(List parentLaye
}
}
- protected void addServerExportsService(Map> qualifiedExports) {
+ private static void addServerExportsService(Map> qualifiedExports) {
var exportsService = new ModuleQualifiedExportsService(serverModule) {
@Override
protected void addExports(String pkg, Module target) {
diff --git a/server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java b/server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java
index 015bc72747bf2..79d8f98c7dca6 100644
--- a/server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java
@@ -18,7 +18,6 @@
import org.elasticsearch.env.Environment;
import org.elasticsearch.env.TestEnvironment;
import org.elasticsearch.index.IndexModule;
-import org.elasticsearch.jdk.ModuleQualifiedExportsService;
import org.elasticsearch.plugin.analysis.CharFilterFactory;
import org.elasticsearch.plugins.scanners.PluginInfo;
import org.elasticsearch.plugins.spi.BarPlugin;
@@ -66,12 +65,11 @@ public class PluginsServiceTests extends ESTestCase {
public static class FilterablePlugin extends Plugin implements ScriptPlugin {}
static PluginsService newPluginsService(Settings settings) {
- return new PluginsService(settings, null, new PluginsLoader(null, TestEnvironment.newEnvironment(settings).pluginsFile()) {
- @Override
- protected void addServerExportsService(Map> qualifiedExports) {
- // tests don't run modular
- }
- });
+ return new PluginsService(
+ settings,
+ null,
+ PluginsLoader.createPluginsLoader(null, TestEnvironment.newEnvironment(settings).pluginsFile(), false)
+ );
}
static PluginsService newMockPluginsService(List> classpathPlugins) {
diff --git a/test/framework/src/main/java/org/elasticsearch/plugins/MockPluginsService.java b/test/framework/src/main/java/org/elasticsearch/plugins/MockPluginsService.java
index 9e96396493bdf..a9a825af3b865 100644
--- a/test/framework/src/main/java/org/elasticsearch/plugins/MockPluginsService.java
+++ b/test/framework/src/main/java/org/elasticsearch/plugins/MockPluginsService.java
@@ -16,7 +16,6 @@
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ConcurrentCollections;
import org.elasticsearch.env.Environment;
-import org.elasticsearch.jdk.ModuleQualifiedExportsService;
import org.elasticsearch.plugins.spi.SPIClassIterator;
import java.lang.reflect.Constructor;
@@ -43,13 +42,11 @@ public class MockPluginsService extends PluginsService {
* @param classpathPlugins Plugins that exist in the classpath which should be loaded
*/
public MockPluginsService(Settings settings, Environment environment, Collection> classpathPlugins) {
- super(settings, environment.configFile(), new PluginsLoader(environment.modulesFile(), environment.pluginsFile()) {
-
- @Override
- protected void addServerExportsService(Map> qualifiedExports) {
- // tests don't run modular
- }
- });
+ super(
+ settings,
+ environment.configFile(),
+ new PluginsLoader(Collections.emptyList(), Collections.emptyList(), Collections.emptyMap())
+ );
List pluginsLoaded = new ArrayList<>();
diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/bench/WatcherScheduleEngineBenchmark.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/bench/WatcherScheduleEngineBenchmark.java
index 99fb626ad9474..59dc1db88e991 100644
--- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/bench/WatcherScheduleEngineBenchmark.java
+++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/bench/WatcherScheduleEngineBenchmark.java
@@ -109,7 +109,10 @@ public static void main(String[] args) throws Exception {
// First clean everything and index the watcher (but not via put alert api!)
try (
- Node node = new Node(internalNodeEnv, new PluginsLoader(internalNodeEnv.modulesFile(), internalNodeEnv.pluginsFile())).start()
+ Node node = new Node(
+ internalNodeEnv,
+ PluginsLoader.createPluginsLoader(internalNodeEnv.modulesFile(), internalNodeEnv.pluginsFile())
+ ).start()
) {
final Client client = node.client();
ClusterHealthResponse response = client.admin().cluster().prepareHealth(TimeValue.THIRTY_SECONDS).setWaitForNodes("2").get();
From 77626d686b62fc85ce91d65cfff8adf631f84bcd Mon Sep 17 00:00:00 2001
From: Nhat Nguyen
Date: Wed, 27 Nov 2024 16:45:22 -0800
Subject: [PATCH 11/39] Unmute FieldExtractorIT (#117669)
Fixed in #117529
Closes #117524 Closes #117531
---
muted-tests.yml | 6 ------
1 file changed, 6 deletions(-)
diff --git a/muted-tests.yml b/muted-tests.yml
index 8b12bd2dd3365..5cf16fdf3da0a 100644
--- a/muted-tests.yml
+++ b/muted-tests.yml
@@ -214,14 +214,8 @@ tests:
- class: org.elasticsearch.xpack.test.rest.XPackRestIT
method: test {p0=transform/transforms_reset/Test reset running transform}
issue: https://github.com/elastic/elasticsearch/issues/117473
-- class: org.elasticsearch.xpack.esql.qa.multi_node.FieldExtractorIT
- method: testConstantKeywordField
- issue: https://github.com/elastic/elasticsearch/issues/117524
- class: org.elasticsearch.repositories.s3.RepositoryS3EcsClientYamlTestSuiteIT
issue: https://github.com/elastic/elasticsearch/issues/117525
-- class: org.elasticsearch.xpack.esql.qa.mixed.FieldExtractorIT
- method: testConstantKeywordField
- issue: https://github.com/elastic/elasticsearch/issues/117531
- class: org.elasticsearch.backwards.MixedClusterClientYamlTestSuiteIT
method: test {p0=synonyms/90_synonyms_reloading_for_synset/Reload analyzers for specific synonym set}
issue: https://github.com/elastic/elasticsearch/issues/116777
From bb93f1f3ce8f1460e48a4b86d3b0fee72b4fa4b1 Mon Sep 17 00:00:00 2001
From: Michael Peterson
Date: Wed, 27 Nov 2024 21:14:19 -0500
Subject: [PATCH 12/39] Adjusted testChunkResponseSizeColumnar to always
expected the overall took time in the async response (#117673)
---
.../xpack/esql/action/EsqlQueryResponseTests.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
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 f7b402b909732..35364089127cc 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
@@ -519,15 +519,14 @@ static EsqlQueryResponse fromXContent(XContentParser parser) {
}
public void testChunkResponseSizeColumnar() {
+ int sizeClusterDetails = 14;
try (EsqlQueryResponse resp = randomResponse(true, null)) {
- int sizeClusterDetails = 14;
int columnCount = resp.pages().get(0).getBlockCount();
int bodySize = resp.pages().stream().mapToInt(p -> p.getPositionCount() * p.getBlockCount()).sum() + columnCount * 2;
assertChunkCount(resp, r -> 5 + sizeClusterDetails + bodySize);
}
try (EsqlQueryResponse resp = randomResponseAsync(true, null, true)) {
- int sizeClusterDetails = resp.isRunning() ? 13 : 14; // overall took time not present when is_running=true
int columnCount = resp.pages().get(0).getBlockCount();
int bodySize = resp.pages().stream().mapToInt(p -> p.getPositionCount() * p.getBlockCount()).sum() + columnCount * 2;
assertChunkCount(resp, r -> 7 + sizeClusterDetails + bodySize); // is_running
From c3ac2bd58a5c406982212def72580cc25e89761a Mon Sep 17 00:00:00 2001
From: Liam Thompson <32779855+leemthompo@users.noreply.github.com>
Date: Thu, 28 Nov 2024 08:23:28 +0100
Subject: [PATCH 13/39] [DOCS] Add Elastic Rerank usage docs (#117625)
---
.../inference/service-elasticsearch.asciidoc | 41 +++++++--
.../reranking/semantic-reranking.asciidoc | 20 +++--
docs/reference/search/retriever.asciidoc | 83 +++++++++++++++++--
3 files changed, 121 insertions(+), 23 deletions(-)
diff --git a/docs/reference/inference/service-elasticsearch.asciidoc b/docs/reference/inference/service-elasticsearch.asciidoc
index 0103b425faefe..cd06e6d7b2f64 100644
--- a/docs/reference/inference/service-elasticsearch.asciidoc
+++ b/docs/reference/inference/service-elasticsearch.asciidoc
@@ -69,15 +69,15 @@ include::inference-shared.asciidoc[tag=service-settings]
These settings are specific to the `elasticsearch` service.
--
-`adaptive_allocations`:::
-(Optional, object)
-include::{es-ref-dir}/ml/ml-shared.asciidoc[tag=adaptive-allocation]
-
`deployment_id`:::
(Optional, string)
The `deployment_id` of an existing trained model deployment.
When `deployment_id` is used the `model_id` is optional.
+`adaptive_allocations`:::
+(Optional, object)
+include::{es-ref-dir}/ml/ml-shared.asciidoc[tag=adaptive-allocation]
+
`enabled`::::
(Optional, Boolean)
include::{es-ref-dir}/ml/ml-shared.asciidoc[tag=adaptive-allocation-enabled]
@@ -119,7 +119,6 @@ include::inference-shared.asciidoc[tag=task-settings]
Returns the document instead of only the index. Defaults to `true`.
=====
-
[discrete]
[[inference-example-elasticsearch-elser]]
==== ELSER via the `elasticsearch` service
@@ -137,7 +136,7 @@ PUT _inference/sparse_embedding/my-elser-model
"adaptive_allocations": { <1>
"enabled": true,
"min_number_of_allocations": 1,
- "max_number_of_allocations": 10
+ "max_number_of_allocations": 4
},
"num_threads": 1,
"model_id": ".elser_model_2" <2>
@@ -150,6 +149,34 @@ PUT _inference/sparse_embedding/my-elser-model
Valid values are `.elser_model_2` and `.elser_model_2_linux-x86_64`.
For further details, refer to the {ml-docs}/ml-nlp-elser.html[ELSER model documentation].
+[discrete]
+[[inference-example-elastic-reranker]]
+==== Elastic Rerank via the `elasticsearch` service
+
+The following example shows how to create an {infer} endpoint called `my-elastic-rerank` to perform a `rerank` task type using the built-in Elastic Rerank cross-encoder model.
+
+The API request below will automatically download the Elastic Rerank model if it isn't already downloaded and then deploy the model.
+Once deployed, the model can be used for semantic re-ranking with a <>.
+
+[source,console]
+------------------------------------------------------------
+PUT _inference/rerank/my-elastic-rerank
+{
+ "service": "elasticsearch",
+ "service_settings": {
+ "model_id": ".rerank-v1", <1>
+ "num_threads": 1,
+ "adaptive_allocations": { <2>
+ "enabled": true,
+ "min_number_of_allocations": 1,
+ "max_number_of_allocations": 4
+ }
+ }
+}
+------------------------------------------------------------
+// TEST[skip:TBD]
+<1> The `model_id` must be the ID of the built-in Elastic Rerank model: `.rerank-v1`.
+<2> {ml-docs}/ml-nlp-auto-scale.html#nlp-model-adaptive-allocations[Adaptive allocations] will be enabled with the minimum of 1 and the maximum of 10 allocations.
[discrete]
[[inference-example-elasticsearch]]
@@ -186,7 +213,7 @@ If using the Python client, you can set the `timeout` parameter to a higher valu
[discrete]
[[inference-example-eland]]
-==== Models uploaded by Eland via the elasticsearch service
+==== Models uploaded by Eland via the `elasticsearch` service
The following example shows how to create an {infer} endpoint called
`my-msmarco-minilm-model` to perform a `text_embedding` task type.
diff --git a/docs/reference/reranking/semantic-reranking.asciidoc b/docs/reference/reranking/semantic-reranking.asciidoc
index 4ebe90e44708e..e1e2abd224a8e 100644
--- a/docs/reference/reranking/semantic-reranking.asciidoc
+++ b/docs/reference/reranking/semantic-reranking.asciidoc
@@ -85,14 +85,16 @@ In {es}, semantic re-rankers are implemented using the {es} <> using the `rerank` task type
-** Integrate directly with the <> using the `rerank` task type
-** Upload a model to {es} from Hugging Face with {eland-docs}/machine-learning.html#ml-nlp-pytorch[Eland]. You'll need to use the `text_similarity` NLP task type when loading the model using Eland. Refer to {ml-docs}/ml-nlp-model-ref.html#ml-nlp-model-ref-text-similarity[the Elastic NLP model reference] for a list of third party text similarity models supported by {es} for semantic re-ranking.
-*** Then set up an <> with the `rerank` task type
-. *Create a `rerank` task using the <>*.
+. *Select and configure a re-ranking model*.
+You have the following options:
+.. Use the <> cross-encoder model via the inference API's {es} service.
+.. Use the <> to create a `rerank` endpoint.
+.. Use the <> to create a `rerank` endpoint.
+.. Upload a model to {es} from Hugging Face with {eland-docs}/machine-learning.html#ml-nlp-pytorch[Eland]. You'll need to use the `text_similarity` NLP task type when loading the model using Eland. Then set up an <> with the `rerank` endpoint type.
++
+Refer to {ml-docs}/ml-nlp-model-ref.html#ml-nlp-model-ref-text-similarity[the Elastic NLP model reference] for a list of third party text similarity models supported by {es} for semantic re-ranking.
+
+. *Create a `rerank` endpoint using the <>*.
The Inference API creates an inference endpoint and configures your chosen machine learning model to perform the re-ranking task.
. *Define a `text_similarity_reranker` retriever in your search request*.
The retriever syntax makes it simple to configure both the retrieval and re-ranking of search results in a single API call.
@@ -117,7 +119,7 @@ POST _search
}
},
"field": "text",
- "inference_id": "my-cohere-rerank-model",
+ "inference_id": "elastic-rerank",
"inference_text": "How often does the moon hide the sun?",
"rank_window_size": 100,
"min_score": 0.5
diff --git a/docs/reference/search/retriever.asciidoc b/docs/reference/search/retriever.asciidoc
index 86a81f1d155d2..b90b7e312c790 100644
--- a/docs/reference/search/retriever.asciidoc
+++ b/docs/reference/search/retriever.asciidoc
@@ -11,6 +11,7 @@ This allows for complex behavior to be depicted in a tree-like structure, called
[TIP]
====
Refer to <> for a high level overview of the retrievers abstraction.
+Refer to <> for additional examples.
====
The following retrievers are available:
@@ -382,16 +383,17 @@ Refer to <> for a high level overview of semantic re-ranking
===== Prerequisites
-To use `text_similarity_reranker` you must first set up a `rerank` task using the <>.
-The `rerank` task should be set up with a machine learning model that can compute text similarity.
+To use `text_similarity_reranker` you must first set up an inference endpoint for the `rerank` task using the <>.
+The endpoint should be set up with a machine learning model that can compute text similarity.
Refer to {ml-docs}/ml-nlp-model-ref.html#ml-nlp-model-ref-text-similarity[the Elastic NLP model reference] for a list of third-party text similarity models supported by {es}.
-Currently you can:
+You have the following options:
-* Integrate directly with the <> using the `rerank` task type
-* Integrate directly with the <> using the `rerank` task type
+* Use the the built-in <> cross-encoder model via the inference API's {es} service.
+* Use the <> with the `rerank` task type.
+* Use the <> with the `rerank` task type.
* Upload a model to {es} with {eland-docs}/machine-learning.html#ml-nlp-pytorch[Eland] using the `text_similarity` NLP task type.
-** Then set up an <> with the `rerank` task type
+** Then set up an <> with the `rerank` task type.
** Refer to the <> on this page for a step-by-step guide.
===== Parameters
@@ -436,13 +438,70 @@ Note that score calculations vary depending on the model used.
Applies the specified <> to the child <>.
If the child retriever already specifies any filters, then this top-level filter is applied in conjuction with the filter defined in the child retriever.
+[discrete]
+[[text-similarity-reranker-retriever-example-elastic-rerank]]
+==== Example: Elastic Rerank
+
+This examples demonstrates how to deploy the Elastic Rerank model and use it to re-rank search results using the `text_similarity_reranker` retriever.
+
+Follow these steps:
+
+. Create an inference endpoint for the `rerank` task using the <>.
++
+[source,console]
+----
+PUT _inference/rerank/my-elastic-rerank
+{
+ "service": "elasticsearch",
+ "service_settings": {
+ "model_id": ".rerank-v1",
+ "num_threads": 1,
+ "adaptive_allocations": { <1>
+ "enabled": true,
+ "min_number_of_allocations": 1,
+ "max_number_of_allocations": 10
+ }
+ }
+}
+----
+// TEST[skip:uses ML]
+<1> {ml-docs}/ml-nlp-auto-scale.html#nlp-model-adaptive-allocations[Adaptive allocations] will be enabled with the minimum of 1 and the maximum of 10 allocations.
++
+. Define a `text_similarity_rerank` retriever:
++
+[source,console]
+----
+POST _search
+{
+ "retriever": {
+ "text_similarity_reranker": {
+ "retriever": {
+ "standard": {
+ "query": {
+ "match": {
+ "text": "How often does the moon hide the sun?"
+ }
+ }
+ }
+ },
+ "field": "text",
+ "inference_id": "my-elastic-rerank",
+ "inference_text": "How often does the moon hide the sun?",
+ "rank_window_size": 100,
+ "min_score": 0.5
+ }
+ }
+}
+----
+// TEST[skip:uses ML]
+
[discrete]
[[text-similarity-reranker-retriever-example-cohere]]
==== Example: Cohere Rerank
This example enables out-of-the-box semantic search by re-ranking top documents using the Cohere Rerank API.
This approach eliminates the need to generate and store embeddings for all indexed documents.
-This requires a <> using the `rerank` task type.
+This requires a <> that is set up for the `rerank` task type.
[source,console]
----
@@ -680,6 +739,12 @@ GET movies/_search
<1> The `rule` retriever is the outermost retriever, applying rules to the search results that were previously reranked using the `rrf` retriever.
<2> The `rrf` retriever returns results from all of its sub-retrievers, and the output of the `rrf` retriever is used as input to the `rule` retriever.
+[discrete]
+[[retriever-common-parameters]]
+=== Common usage guidelines
+
+[discrete]
+[[retriever-size-pagination]]
==== Using `from` and `size` with a retriever tree
The <> and <>
@@ -688,12 +753,16 @@ parameters are provided globally as part of the general
They are applied to all retrievers in a retriever tree, unless a specific retriever overrides the `size` parameter using a different parameter such as `rank_window_size`.
Though, the final search hits are always limited to `size`.
+[discrete]
+[[retriever-aggregations]]
==== Using aggregations with a retriever tree
<> are globally specified as part of a search request.
The query used for an aggregation is the combination of all leaf retrievers as `should`
clauses in a <>.
+[discrete]
+[[retriever-restrictions]]
==== Restrictions on search parameters when specifying a retriever
When a retriever is specified as part of a search, the following elements are not allowed at the top-level.
From 79d70686b3ba86dcab4694d46e5a81de74ba06f8 Mon Sep 17 00:00:00 2001
From: kosabogi <105062005+kosabogi@users.noreply.github.com>
Date: Thu, 28 Nov 2024 09:26:16 +0100
Subject: [PATCH 14/39] Fixes typo (#117684)
---
.../ml/trained-models/apis/get-trained-models-stats.asciidoc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/reference/ml/trained-models/apis/get-trained-models-stats.asciidoc b/docs/reference/ml/trained-models/apis/get-trained-models-stats.asciidoc
index beff87e6ec6e6..b55f022a5d168 100644
--- a/docs/reference/ml/trained-models/apis/get-trained-models-stats.asciidoc
+++ b/docs/reference/ml/trained-models/apis/get-trained-models-stats.asciidoc
@@ -235,7 +235,7 @@ The reason for the current state. Usually only populated when the `routing_state
(string)
The current routing state.
--
-* `starting`: The model is attempting to allocate on this model, inference calls are not yet accepted.
+* `starting`: The model is attempting to allocate on this node, inference calls are not yet accepted.
* `started`: The model is allocated and ready to accept inference requests.
* `stopping`: The model is being deallocated from this node.
* `stopped`: The model is fully deallocated from this node.
From dc7ea9eff9a5897fabc2fb9dd3bb291eee77ca11 Mon Sep 17 00:00:00 2001
From: Alexander Spies
Date: Thu, 28 Nov 2024 09:40:38 +0100
Subject: [PATCH 15/39] ESQL: Fix LookupJoin output (#117639)
* Fix output methods related to LookupJoin
* Add tests with subsequent EVAL
* Fix BinaryPlan.computeReferences
This must not just use the references from its own output. Not only is
this wrong, it also leads to failures when we call the .references()
method on unresolved plans.
---
.../xpack/esql/ccq/MultiClusterSpecIT.java | 4 +-
.../src/main/resources/lookup-join.csv-spec | 67 +++++++++++++++----
.../xpack/esql/action/EsqlCapabilities.java | 2 +-
.../xpack/esql/analysis/Analyzer.java | 15 ++---
.../xpack/esql/plan/QueryPlan.java | 5 ++
.../xpack/esql/plan/logical/BinaryPlan.java | 7 --
.../xpack/esql/plan/logical/join/Join.java | 48 ++++---------
.../esql/plan/logical/join/LookupJoin.java | 43 +++---------
.../xpack/esql/session/EsqlSession.java | 4 --
.../elasticsearch/xpack/esql/CsvTests.java | 2 +-
.../xpack/esql/analysis/AnalyzerTests.java | 5 +-
11 files changed, 91 insertions(+), 111 deletions(-)
diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/MultiClusterSpecIT.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/MultiClusterSpecIT.java
index 5df85d1004dd1..8f4522573f880 100644
--- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/MultiClusterSpecIT.java
+++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/MultiClusterSpecIT.java
@@ -47,7 +47,7 @@
import static org.elasticsearch.xpack.esql.EsqlTestUtils.classpathResources;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.INLINESTATS;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.INLINESTATS_V2;
-import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.JOIN_LOOKUP;
+import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.JOIN_LOOKUP_V2;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.JOIN_PLANNING_V1;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.METADATA_FIELDS_REMOTE_TEST;
import static org.elasticsearch.xpack.esql.qa.rest.EsqlSpecTestCase.Mode.SYNC;
@@ -125,7 +125,7 @@ protected void shouldSkipTest(String testName) throws IOException {
assumeFalse("INLINESTATS not yet supported in CCS", testCase.requiredCapabilities.contains(INLINESTATS.capabilityName()));
assumeFalse("INLINESTATS not yet supported in CCS", testCase.requiredCapabilities.contains(INLINESTATS_V2.capabilityName()));
assumeFalse("INLINESTATS not yet supported in CCS", testCase.requiredCapabilities.contains(JOIN_PLANNING_V1.capabilityName()));
- assumeFalse("LOOKUP JOIN not yet supported in CCS", testCase.requiredCapabilities.contains(JOIN_LOOKUP.capabilityName()));
+ assumeFalse("LOOKUP JOIN not yet supported in CCS", testCase.requiredCapabilities.contains(JOIN_LOOKUP_V2.capabilityName()));
}
private TestFeatureService remoteFeaturesService() throws IOException {
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec
index 605bf78c20a32..11786fb905c60 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec
@@ -3,22 +3,22 @@
// Reuses the sample dataset and commands from enrich.csv-spec
//
-basicOnTheDataNode
-required_capability: join_lookup
+//TODO: this sometimes returns null instead of the looked up value (likely related to the execution order)
+basicOnTheDataNode-Ignore
+required_capability: join_lookup_v2
-//TODO: this returns different results in CI then locally
-// sometimes null, sometimes spanish (likely related to the execution order)
FROM employees
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
-| WHERE emp_no < 500
-| KEEP emp_no, language_name
+| WHERE emp_no >= 10091 AND emp_no < 10094
| SORT emp_no
-| LIMIT 1
+| KEEP emp_no, language_code, language_name
;
-emp_no:integer | language_name:keyword
-//10091 | Spanish
+emp_no:integer | language_code:integer | language_name:keyword
+10091 | 3 | Spanish
+10092 | 1 | English
+10093 | 3 | Spanish
;
basicRow-Ignore
@@ -33,16 +33,55 @@ language_code:keyword | language_name:keyword
;
basicOnTheCoordinator
-required_capability: join_lookup
+required_capability: join_lookup_v2
+
+FROM employees
+| SORT emp_no
+| LIMIT 3
+| EVAL language_code = languages
+| LOOKUP JOIN languages_lookup ON language_code
+| KEEP emp_no, language_code, language_name
+;
+
+emp_no:integer | language_code:integer | language_name:keyword
+10001 | 2 | French
+10002 | 5 | null
+10003 | 4 | German
+;
+
+//TODO: this sometimes returns null instead of the looked up value (likely related to the execution order)
+subsequentEvalOnTheDataNode-Ignore
+required_capability: join_lookup_v2
+
+FROM employees
+| EVAL language_code = languages
+| LOOKUP JOIN languages_lookup ON language_code
+| WHERE emp_no >= 10091 AND emp_no < 10094
+| SORT emp_no
+| KEEP emp_no, language_code, language_name
+| EVAL language_name = TO_LOWER(language_name), language_code_x2 = 2*language_code
+;
+
+emp_no:integer | language_code:integer | language_name:keyword | language_code_x2:integer
+10091 | 3 | spanish | 6
+10092 | 1 | english | 2
+10093 | 3 | spanish | 6
+;
+
+subsequentEvalOnTheCoordinator
+required_capability: join_lookup_v2
FROM employees
| SORT emp_no
-| LIMIT 1
+| LIMIT 3
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
-| KEEP emp_no, language_name
+| KEEP emp_no, language_code, language_name
+| EVAL language_name = TO_LOWER(language_name), language_code_x2 = 2*language_code
;
-emp_no:integer | language_name:keyword
-10001 | French
+emp_no:integer | language_code:integer | language_name:keyword | language_code_x2:integer
+10001 | 2 | french | 4
+10002 | 5 | null | 10
+10003 | 4 | german | 8
;
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
index 58748781d1778..d8004f73f613f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@@ -524,7 +524,7 @@ public enum Cap {
/**
* LOOKUP JOIN
*/
- JOIN_LOOKUP(Build.current().isSnapshot()),
+ JOIN_LOOKUP_V2(Build.current().isSnapshot()),
/**
* Fix for https://github.com/elastic/elasticsearch/issues/117054
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java
index dde7bc09ac615..b847508d2b161 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java
@@ -21,7 +21,6 @@
import org.elasticsearch.xpack.esql.core.capabilities.Resolvables;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
-import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
import org.elasticsearch.xpack.esql.core.expression.EmptyAttribute;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.Expressions;
@@ -609,8 +608,7 @@ private Join resolveLookupJoin(LookupJoin join) {
JoinConfig config = join.config();
// for now, support only (LEFT) USING clauses
JoinType type = config.type();
- // rewrite the join into a equi-join between the field with the same name between left and right
- // per SQL standard, the USING columns are placed first in the output, followed by the rest of left, then right
+ // rewrite the join into an equi-join between the field with the same name between left and right
if (type instanceof UsingJoinType using) {
List cols = using.columns();
// the lookup cannot be resolved, bail out
@@ -632,14 +630,9 @@ private Join resolveLookupJoin(LookupJoin join) {
// resolve the using columns against the left and the right side then assemble the new join config
List leftKeys = resolveUsingColumns(cols, join.left().output(), "left");
List rightKeys = resolveUsingColumns(cols, join.right().output(), "right");
- List output = new ArrayList<>(join.left().output());
- // the order is stable (since the AttributeSet preservers the insertion order)
- output.addAll(join.right().outputSet().subtract(new AttributeSet(rightKeys)));
-
- // update the config - pick the left keys as those in the output
- type = new UsingJoinType(coreJoin, rightKeys);
- config = new JoinConfig(type, leftKeys, leftKeys, rightKeys);
- join = new LookupJoin(join.source(), join.left(), join.right(), config, output);
+
+ config = new JoinConfig(coreJoin, leftKeys, leftKeys, rightKeys);
+ join = new LookupJoin(join.source(), join.left(), join.right(), config);
}
// everything else is unsupported for now
else {
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/QueryPlan.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/QueryPlan.java
index ef8c3983faf2e..02373cc62e81f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/QueryPlan.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/QueryPlan.java
@@ -33,6 +33,10 @@ public QueryPlan(Source source, List children) {
super(source, children);
}
+ /**
+ * The ordered list of attributes (i.e. columns) this plan produces when executed.
+ * Must be called only on resolved plans, otherwise may throw an exception or return wrong results.
+ */
public abstract List output();
public AttributeSet outputSet() {
@@ -87,6 +91,7 @@ public AttributeSet references() {
/**
* This very likely needs to be overridden for {@link QueryPlan#references} to be correct when inheriting.
+ * This can be called on unresolved plans and therefore must not rely on calls to {@link QueryPlan#output()}.
*/
protected AttributeSet computeReferences() {
return Expressions.references(expressions());
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/BinaryPlan.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/BinaryPlan.java
index e65cdda4b6069..91cd7f7a15840 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/BinaryPlan.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/BinaryPlan.java
@@ -6,8 +6,6 @@
*/
package org.elasticsearch.xpack.esql.plan.logical;
-import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
-import org.elasticsearch.xpack.esql.core.expression.Expressions;
import org.elasticsearch.xpack.esql.core.tree.Source;
import java.util.Arrays;
@@ -45,11 +43,6 @@ public final BinaryPlan replaceRight(LogicalPlan newRight) {
return replaceChildren(left, newRight);
}
- protected AttributeSet computeReferences() {
- // TODO: this needs to be driven by the join config
- return Expressions.references(output());
- }
-
public abstract BinaryPlan replaceChildren(LogicalPlan left, LogicalPlan right);
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/Join.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/Join.java
index 0e182646d914a..dd6b3ea3455f7 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/Join.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/Join.java
@@ -10,9 +10,8 @@
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.util.Maps;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
-import org.elasticsearch.xpack.esql.core.expression.Nullability;
+import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute;
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
import org.elasticsearch.xpack.esql.core.tree.Source;
@@ -23,9 +22,11 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import static org.elasticsearch.xpack.esql.expression.NamedExpressions.mergeOutputAttributes;
import static org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes.LEFT;
import static org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes.RIGHT;
@@ -107,37 +108,24 @@ public static List computeOutput(List leftOutput, List output;
// TODO: make the other side nullable
+ Set matchFieldNames = config.matchFields().stream().map(NamedExpression::name).collect(Collectors.toSet());
if (LEFT.equals(joinType)) {
- // right side becomes nullable and overrides left
- // output = merge(leftOutput, makeNullable(rightOutput));
- output = merge(leftOutput, rightOutput);
+ // right side becomes nullable and overrides left except for match fields, which we preserve from the left
+ List rightOutputWithoutMatchFields = rightOutput.stream()
+ .filter(attr -> matchFieldNames.contains(attr.name()) == false)
+ .toList();
+ output = mergeOutputAttributes(rightOutputWithoutMatchFields, leftOutput);
} else if (RIGHT.equals(joinType)) {
- // left side becomes nullable and overrides right
- // output = merge(makeNullable(leftOutput), rightOutput);
- output = merge(leftOutput, rightOutput);
+ List leftOutputWithoutMatchFields = leftOutput.stream()
+ .filter(attr -> matchFieldNames.contains(attr.name()) == false)
+ .toList();
+ output = mergeOutputAttributes(leftOutputWithoutMatchFields, rightOutput);
} else {
throw new IllegalArgumentException(joinType.joinName() + " unsupported");
}
return output;
}
- /**
- * Merge the two lists of attributes into one and preserves order.
- */
- private static List merge(List left, List right) {
- // use linked hash map to preserve order
- Map nameToAttribute = Maps.newLinkedHashMapWithExpectedSize(left.size() + right.size());
- for (Attribute a : left) {
- nameToAttribute.put(a.name(), a);
- }
- for (Attribute a : right) {
- // override the existing entry in place
- nameToAttribute.compute(a.name(), (name, existing) -> a);
- }
-
- return new ArrayList<>(nameToAttribute.values());
- }
-
/**
* Make fields references, so we don't check if they exist in the index.
* We do this for fields that we know don't come from the index.
@@ -161,14 +149,6 @@ public static List makeReference(List output) {
return out;
}
- private static List makeNullable(List output) {
- List out = new ArrayList<>(output.size());
- for (Attribute a : output) {
- out.add(a.withNullability(Nullability.TRUE));
- }
- return out;
- }
-
@Override
public boolean expressionsResolved() {
return config.expressionsResolved();
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/LookupJoin.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/LookupJoin.java
index 2ee9213f45b36..57c8cb00baa32 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/LookupJoin.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/LookupJoin.java
@@ -16,7 +16,6 @@
import org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes.UsingJoinType;
import java.util.List;
-import java.util.Objects;
import static java.util.Collections.emptyList;
import static org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes.LEFT;
@@ -26,10 +25,8 @@
*/
public class LookupJoin extends Join implements SurrogateLogicalPlan {
- private final List output;
-
public LookupJoin(Source source, LogicalPlan left, LogicalPlan right, List joinFields) {
- this(source, left, right, new UsingJoinType(LEFT, joinFields), emptyList(), emptyList(), emptyList(), emptyList());
+ this(source, left, right, new UsingJoinType(LEFT, joinFields), emptyList(), emptyList(), emptyList());
}
public LookupJoin(
@@ -39,15 +36,13 @@ public LookupJoin(
JoinType type,
List joinFields,
List leftFields,
- List rightFields,
- List output
+ List rightFields
) {
- this(source, left, right, new JoinConfig(type, joinFields, leftFields, rightFields), output);
+ this(source, left, right, new JoinConfig(type, joinFields, leftFields, rightFields));
}
- public LookupJoin(Source source, LogicalPlan left, LogicalPlan right, JoinConfig joinConfig, List output) {
+ public LookupJoin(Source source, LogicalPlan left, LogicalPlan right, JoinConfig joinConfig) {
super(source, left, right, joinConfig);
- this.output = output;
}
/**
@@ -55,20 +50,14 @@ public LookupJoin(Source source, LogicalPlan left, LogicalPlan right, JoinConfig
*/
@Override
public LogicalPlan surrogate() {
- JoinConfig cfg = config();
- JoinConfig newConfig = new JoinConfig(LEFT, cfg.matchFields(), cfg.leftFields(), cfg.rightFields());
- Join normalized = new Join(source(), left(), right(), newConfig);
+ Join normalized = new Join(source(), left(), right(), config());
// TODO: decide whether to introduce USING or just basic ON semantics - keep the ordering out for now
- return new Project(source(), normalized, output);
- }
-
- public List output() {
- return output;
+ return new Project(source(), normalized, output());
}
@Override
public Join replaceChildren(LogicalPlan left, LogicalPlan right) {
- return new LookupJoin(source(), left, right, config(), output);
+ return new LookupJoin(source(), left, right, config());
}
@Override
@@ -81,23 +70,7 @@ protected NodeInfo info() {
config().type(),
config().matchFields(),
config().leftFields(),
- config().rightFields(),
- output
+ config().rightFields()
);
}
-
- @Override
- public int hashCode() {
- return Objects.hash(super.hashCode(), output);
- }
-
- @Override
- public boolean equals(Object obj) {
- if (super.equals(obj) == false) {
- return false;
- }
-
- LookupJoin other = (LookupJoin) obj;
- return Objects.equals(output, other.output);
- }
}
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 021596c31f65d..3b0f9ab578df9 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
@@ -79,7 +79,6 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
-import java.util.function.Predicate;
import java.util.stream.Collectors;
import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
@@ -466,8 +465,6 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF
// ie "from test | eval lang = languages + 1 | keep *l" should consider both "languages" and "*l" as valid fields to ask for
AttributeSet keepCommandReferences = new AttributeSet();
AttributeSet keepJoinReferences = new AttributeSet();
- List> keepMatches = new ArrayList<>();
- List keepPatterns = new ArrayList<>();
parsed.forEachDown(p -> {// go over each plan top-down
if (p instanceof RegexExtract re) { // for Grok and Dissect
@@ -501,7 +498,6 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF
references.add(ua);
if (p instanceof Keep) {
keepCommandReferences.add(ua);
- keepMatches.add(up::match);
}
});
if (p instanceof Keep) {
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 c745801bf505f..6763988eac638 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
@@ -263,7 +263,7 @@ public final void test() throws Throwable {
);
assumeFalse(
"lookup join disabled for csv tests",
- testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP.capabilityName())
+ testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP_V2.capabilityName())
);
if (Build.current().isSnapshot()) {
assertThat(
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
index 2770ed1f336ae..e0ebc92afa95d 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
@@ -1945,9 +1945,10 @@ public void testLookup() {
.item(startsWith("job{f}"))
.item(startsWith("job.raw{f}"))
/*
- * Int key is returned as a full field (despite the rename)
+ * Int is a reference here because we renamed it in project.
+ * If we hadn't it'd be a field and that'd be fine.
*/
- .item(containsString("int{f}"))
+ .item(containsString("int{r}"))
.item(startsWith("last_name{f}"))
.item(startsWith("long_noidx{f}"))
.item(startsWith("salary{f}"))
From 11ffe8831793a5cad91b5bb5fb63e2365286451a Mon Sep 17 00:00:00 2001
From: Armin Braun
Date: Thu, 28 Nov 2024 09:54:42 +0100
Subject: [PATCH 16/39] Speedup HealthNodeTaskExecutor CS listener (#113436)
This method was quite slow in tests because there's an expensive
assertion in `ClusterApplierService.state()` that we run when calling
`ClusterService.localNode()`
---
.../selection/HealthNodeTaskExecutor.java | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/server/src/main/java/org/elasticsearch/health/node/selection/HealthNodeTaskExecutor.java b/server/src/main/java/org/elasticsearch/health/node/selection/HealthNodeTaskExecutor.java
index 3efad1aee26b0..5991bc248ba76 100644
--- a/server/src/main/java/org/elasticsearch/health/node/selection/HealthNodeTaskExecutor.java
+++ b/server/src/main/java/org/elasticsearch/health/node/selection/HealthNodeTaskExecutor.java
@@ -182,8 +182,8 @@ void startTask(ClusterChangedEvent event) {
// visible for testing
void shuttingDown(ClusterChangedEvent event) {
- DiscoveryNode node = clusterService.localNode();
- if (isNodeShuttingDown(event, node.getId())) {
+ if (isNodeShuttingDown(event)) {
+ var node = event.state().getNodes().getLocalNode();
abortTaskIfApplicable("node [{" + node.getName() + "}{" + node.getId() + "}] shutting down");
}
}
@@ -198,9 +198,18 @@ void abortTaskIfApplicable(String reason) {
}
}
- private static boolean isNodeShuttingDown(ClusterChangedEvent event, String nodeId) {
- return event.previousState().metadata().nodeShutdowns().contains(nodeId) == false
- && event.state().metadata().nodeShutdowns().contains(nodeId);
+ private static boolean isNodeShuttingDown(ClusterChangedEvent event) {
+ if (event.metadataChanged() == false) {
+ return false;
+ }
+ var shutdownsOld = event.previousState().metadata().nodeShutdowns();
+ var shutdownsNew = event.state().metadata().nodeShutdowns();
+ if (shutdownsNew == shutdownsOld) {
+ return false;
+ }
+ String nodeId = event.state().nodes().getLocalNodeId();
+ return shutdownsOld.contains(nodeId) == false && shutdownsNew.contains(nodeId);
+
}
public static List getNamedXContentParsers() {
From d4bcd979a5b9196f23b00d97cb17aad1679818c8 Mon Sep 17 00:00:00 2001
From: Martijn van Groningen
Date: Thu, 28 Nov 2024 10:05:26 +0100
Subject: [PATCH 17/39] Update synthetic source legacy license cutoff date.
(#117658)
Update default cutoff date from 12-12-2024T00:00 UTC to 01-02-2025T00:00 UTC.
---
.../xpack/logsdb/SyntheticSourceLicenseService.java | 2 +-
.../SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseService.java b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseService.java
index 71de2f7909835..26a672fb1c903 100644
--- a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseService.java
+++ b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseService.java
@@ -29,7 +29,7 @@ final class SyntheticSourceLicenseService {
// You can only override this property if you received explicit approval from Elastic.
static final String CUTOFF_DATE_SYS_PROP_NAME = "es.mapping.synthetic_source_fallback_to_stored_source.cutoff_date_restricted_override";
private static final Logger LOGGER = LogManager.getLogger(SyntheticSourceLicenseService.class);
- static final long DEFAULT_CUTOFF_DATE = LocalDateTime.of(2024, 12, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
+ static final long DEFAULT_CUTOFF_DATE = LocalDateTime.of(2025, 2, 1, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
/**
* A setting that determines whether source mode should always be stored source. Regardless of licence.
diff --git a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java
index 939d7d892a48d..eda0d87868745 100644
--- a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java
+++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java
@@ -98,7 +98,7 @@ public void testGetAdditionalIndexSettingsTsdb() throws IOException {
}
public void testGetAdditionalIndexSettingsTsdbAfterCutoffDate() throws Exception {
- long start = LocalDateTime.of(2024, 12, 20, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
+ long start = LocalDateTime.of(2025, 2, 2, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
License license = createGoldOrPlatinumLicense(start);
long time = LocalDateTime.of(2024, 12, 31, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
var licenseState = new XPackLicenseState(() -> time, new XPackLicenseStatus(license.operationMode(), true, null));
From 5d686973084e926a2dbec96a311a6684807f5406 Mon Sep 17 00:00:00 2001
From: David Kyle
Date: Thu, 28 Nov 2024 09:36:59 +0000
Subject: [PATCH 18/39] [ML] Delete accidental changelog for a non issue
(#117636)
---
docs/changelog/117235.yaml | 5 -----
1 file changed, 5 deletions(-)
delete mode 100644 docs/changelog/117235.yaml
diff --git a/docs/changelog/117235.yaml b/docs/changelog/117235.yaml
deleted file mode 100644
index dbf0b4cc18388..0000000000000
--- a/docs/changelog/117235.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-pr: 117235
-summary: "Deprecate `ChunkingOptions` parameter"
-area: ES|QL
-type: enhancement
-issues: []
From 6a4b68d263fe3533fc44e90d779537b48ffaf5f6 Mon Sep 17 00:00:00 2001
From: Martijn van Groningen
Date: Thu, 28 Nov 2024 10:53:39 +0100
Subject: [PATCH 19/39] Add source mode stats to MappingStats (#117463)
---
docs/reference/cluster/stats.asciidoc | 5 +-
.../test/cluster.stats/40_source_modes.yml | 50 ++++++++++
server/src/main/java/module-info.java | 3 +-
.../org/elasticsearch/TransportVersions.java | 3 +
.../cluster/stats/ClusterStatsFeatures.java | 26 ++++++
.../admin/cluster/stats/MappingStats.java | 55 ++++++++++-
...lasticsearch.features.FeatureSpecification | 1 +
.../cluster/stats/MappingStatsTests.java | 92 ++++++++++++++++++-
.../ClusterStatsMonitoringDocTests.java | 3 +-
9 files changed, 226 insertions(+), 12 deletions(-)
create mode 100644 rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.stats/40_source_modes.yml
create mode 100644 server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsFeatures.java
diff --git a/docs/reference/cluster/stats.asciidoc b/docs/reference/cluster/stats.asciidoc
index bd818a538f78b..d875417bde51a 100644
--- a/docs/reference/cluster/stats.asciidoc
+++ b/docs/reference/cluster/stats.asciidoc
@@ -1644,7 +1644,10 @@ The API returns the following response:
"total_deduplicated_mapping_size": "0b",
"total_deduplicated_mapping_size_in_bytes": 0,
"field_types": [],
- "runtime_field_types": []
+ "runtime_field_types": [],
+ "source_modes" : {
+ "stored": 0
+ }
},
"analysis": {
"char_filter_types": [],
diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.stats/40_source_modes.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.stats/40_source_modes.yml
new file mode 100644
index 0000000000000..64bbad7fb1c6d
--- /dev/null
+++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.stats/40_source_modes.yml
@@ -0,0 +1,50 @@
+---
+test source modes:
+ - requires:
+ cluster_features: ["cluster.stats.source_modes"]
+ reason: requires source modes features
+
+ - do:
+ indices.create:
+ index: test-synthetic
+ body:
+ settings:
+ index:
+ mapping:
+ source.mode: synthetic
+
+ - do:
+ indices.create:
+ index: test-stored
+
+ - do:
+ indices.create:
+ index: test-disabled
+ body:
+ settings:
+ index:
+ mapping:
+ source.mode: disabled
+
+ - do:
+ bulk:
+ refresh: true
+ body:
+ - '{ "create": { "_index": "test-synthetic" } }'
+ - '{ "name": "aaaa", "some_string": "AaAa", "some_int": 1000, "some_double": 123.456789, "some_bool": true }'
+ - '{ "create": { "_index": "test-stored" } }'
+ - '{ "name": "bbbb", "some_string": "BbBb", "some_int": 2000, "some_double": 321.987654, "some_bool": false }'
+ - '{ "create": { "_index": "test-disabled" } }'
+ - '{ "name": "cccc", "some_string": "CcCc", "some_int": 3000, "some_double": 421.484654, "some_bool": false }'
+
+ - do:
+ search:
+ index: test-*
+ - match: { hits.total.value: 3 }
+
+ - do:
+ cluster.stats: { }
+
+ - match: { indices.mappings.source_modes.disabled: 1 }
+ - match: { indices.mappings.source_modes.stored: 1 }
+ - match: { indices.mappings.source_modes.synthetic: 1 }
diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java
index 35d1a44624b0f..63dbac3a72487 100644
--- a/server/src/main/java/module-info.java
+++ b/server/src/main/java/module-info.java
@@ -433,7 +433,8 @@
org.elasticsearch.search.SearchFeatures,
org.elasticsearch.script.ScriptFeatures,
org.elasticsearch.search.retriever.RetrieversFeatures,
- org.elasticsearch.reservedstate.service.FileSettingsFeatures;
+ org.elasticsearch.reservedstate.service.FileSettingsFeatures,
+ org.elasticsearch.action.admin.cluster.stats.ClusterStatsFeatures;
uses org.elasticsearch.plugins.internal.SettingsExtension;
uses RestExtension;
diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java
index dda7d7e5d4c4c..a1315ccf66701 100644
--- a/server/src/main/java/org/elasticsearch/TransportVersions.java
+++ b/server/src/main/java/org/elasticsearch/TransportVersions.java
@@ -205,10 +205,13 @@ static TransportVersion def(int id) {
public static final TransportVersion ESQL_ENRICH_RUNTIME_WARNINGS = def(8_796_00_0);
public static final TransportVersion INGEST_PIPELINE_CONFIGURATION_AS_MAP = def(8_797_00_0);
public static final TransportVersion LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE_FIX_8_17 = def(8_797_00_1);
+ public static final TransportVersion SOURCE_MODE_TELEMETRY_FIX_8_17 = def(8_797_00_2);
public static final TransportVersion INDEXING_PRESSURE_THROTTLING_STATS = def(8_798_00_0);
public static final TransportVersion REINDEX_DATA_STREAMS = def(8_799_00_0);
public static final TransportVersion ESQL_REMOVE_NODE_LEVEL_PLAN = def(8_800_00_0);
public static final TransportVersion LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE = def(8_801_00_0);
+ public static final TransportVersion SOURCE_MODE_TELEMETRY = def(8_802_00_0);
+
/*
* STOP! READ THIS FIRST! No, really,
* ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsFeatures.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsFeatures.java
new file mode 100644
index 0000000000000..6e85093a52cdd
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsFeatures.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.action.admin.cluster.stats;
+
+import org.elasticsearch.features.FeatureSpecification;
+import org.elasticsearch.features.NodeFeature;
+
+import java.util.Set;
+
+/**
+ * Spec for cluster stats features.
+ */
+public class ClusterStatsFeatures implements FeatureSpecification {
+
+ @Override
+ public Set getFeatures() {
+ return Set.of(MappingStats.SOURCE_MODES_FEATURE);
+ }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/MappingStats.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/MappingStats.java
index d2e5973169919..1bc2e1d13c864 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/MappingStats.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/MappingStats.java
@@ -9,6 +9,7 @@
package org.elasticsearch.action.admin.cluster.stats;
+import org.elasticsearch.TransportVersion;
import org.elasticsearch.TransportVersions;
import org.elasticsearch.cluster.metadata.IndexMetadata;
import org.elasticsearch.cluster.metadata.MappingMetadata;
@@ -19,6 +20,8 @@
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.unit.ByteSizeValue;
import org.elasticsearch.core.Nullable;
+import org.elasticsearch.features.NodeFeature;
+import org.elasticsearch.index.mapper.SourceFieldMapper;
import org.elasticsearch.xcontent.ToXContentFragment;
import org.elasticsearch.xcontent.XContentBuilder;
@@ -31,6 +34,7 @@
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalLong;
@@ -44,6 +48,8 @@
*/
public final class MappingStats implements ToXContentFragment, Writeable {
+ static final NodeFeature SOURCE_MODES_FEATURE = new NodeFeature("cluster.stats.source_modes");
+
private static final Pattern DOC_PATTERN = Pattern.compile("doc[\\[.]");
private static final Pattern SOURCE_PATTERN = Pattern.compile("params\\._source");
@@ -53,6 +59,8 @@ public final class MappingStats implements ToXContentFragment, Writeable {
public static MappingStats of(Metadata metadata, Runnable ensureNotCancelled) {
Map fieldTypes = new HashMap<>();
Set concreteFieldNames = new HashSet<>();
+ // Account different source modes based on index.mapping.source.mode setting:
+ Map sourceModeUsageCount = new HashMap<>();
Map runtimeFieldTypes = new HashMap<>();
final Map mappingCounts = new IdentityHashMap<>(metadata.getMappingsByHash().size());
for (IndexMetadata indexMetadata : metadata) {
@@ -62,6 +70,9 @@ public static MappingStats of(Metadata metadata, Runnable ensureNotCancelled) {
continue;
}
AnalysisStats.countMapping(mappingCounts, indexMetadata);
+
+ var sourceMode = SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.get(indexMetadata.getSettings());
+ sourceModeUsageCount.merge(sourceMode.toString().toLowerCase(Locale.ENGLISH), 1, Integer::sum);
}
final AtomicLong totalFieldCount = new AtomicLong();
final AtomicLong totalDeduplicatedFieldCount = new AtomicLong();
@@ -175,12 +186,14 @@ public static MappingStats of(Metadata metadata, Runnable ensureNotCancelled) {
for (MappingMetadata mappingMetadata : metadata.getMappingsByHash().values()) {
totalMappingSizeBytes += mappingMetadata.source().compressed().length;
}
+
return new MappingStats(
totalFieldCount.get(),
totalDeduplicatedFieldCount.get(),
totalMappingSizeBytes,
fieldTypes.values(),
- runtimeFieldTypes.values()
+ runtimeFieldTypes.values(),
+ sourceModeUsageCount
);
}
@@ -215,17 +228,20 @@ private static int countOccurrences(String script, Pattern pattern) {
private final List fieldTypeStats;
private final List runtimeFieldStats;
+ private final Map sourceModeUsageCount;
MappingStats(
long totalFieldCount,
long totalDeduplicatedFieldCount,
long totalMappingSizeBytes,
Collection fieldTypeStats,
- Collection runtimeFieldStats
+ Collection runtimeFieldStats,
+ Map sourceModeUsageCount
) {
this.totalFieldCount = totalFieldCount;
this.totalDeduplicatedFieldCount = totalDeduplicatedFieldCount;
this.totalMappingSizeBytes = totalMappingSizeBytes;
+ this.sourceModeUsageCount = sourceModeUsageCount;
List stats = new ArrayList<>(fieldTypeStats);
stats.sort(Comparator.comparing(IndexFeatureStats::getName));
this.fieldTypeStats = Collections.unmodifiableList(stats);
@@ -246,6 +262,10 @@ private static int countOccurrences(String script, Pattern pattern) {
}
fieldTypeStats = in.readCollectionAsImmutableList(FieldStats::new);
runtimeFieldStats = in.readCollectionAsImmutableList(RuntimeFieldStats::new);
+ var transportVersion = in.getTransportVersion();
+ sourceModeUsageCount = canReadOrWriteSourceModeTelemetry(transportVersion)
+ ? in.readImmutableMap(StreamInput::readString, StreamInput::readVInt)
+ : Map.of();
}
@Override
@@ -257,6 +277,15 @@ public void writeTo(StreamOutput out) throws IOException {
}
out.writeCollection(fieldTypeStats);
out.writeCollection(runtimeFieldStats);
+ var transportVersion = out.getTransportVersion();
+ if (canReadOrWriteSourceModeTelemetry(transportVersion)) {
+ out.writeMap(sourceModeUsageCount, StreamOutput::writeVInt);
+ }
+ }
+
+ private static boolean canReadOrWriteSourceModeTelemetry(TransportVersion version) {
+ return version.isPatchFrom(TransportVersions.SOURCE_MODE_TELEMETRY_FIX_8_17)
+ || version.onOrAfter(TransportVersions.SOURCE_MODE_TELEMETRY);
}
private static OptionalLong ofNullable(Long l) {
@@ -300,6 +329,10 @@ public List getRuntimeFieldStats() {
return runtimeFieldStats;
}
+ public Map getSourceModeUsageCount() {
+ return sourceModeUsageCount;
+ }
+
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject("mappings");
@@ -326,6 +359,12 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
st.toXContent(builder, params);
}
builder.endArray();
+ builder.startObject("source_modes");
+ var entries = sourceModeUsageCount.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList();
+ for (var entry : entries) {
+ builder.field(entry.getKey(), entry.getValue());
+ }
+ builder.endObject();
builder.endObject();
return builder;
}
@@ -344,11 +383,19 @@ public boolean equals(Object o) {
&& Objects.equals(totalDeduplicatedFieldCount, that.totalDeduplicatedFieldCount)
&& Objects.equals(totalMappingSizeBytes, that.totalMappingSizeBytes)
&& fieldTypeStats.equals(that.fieldTypeStats)
- && runtimeFieldStats.equals(that.runtimeFieldStats);
+ && runtimeFieldStats.equals(that.runtimeFieldStats)
+ && sourceModeUsageCount.equals(that.sourceModeUsageCount);
}
@Override
public int hashCode() {
- return Objects.hash(totalFieldCount, totalDeduplicatedFieldCount, totalMappingSizeBytes, fieldTypeStats, runtimeFieldStats);
+ return Objects.hash(
+ totalFieldCount,
+ totalDeduplicatedFieldCount,
+ totalMappingSizeBytes,
+ fieldTypeStats,
+ runtimeFieldStats,
+ sourceModeUsageCount
+ );
}
}
diff --git a/server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification b/server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification
index 3955fc87bf392..12965152f260c 100644
--- a/server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification
+++ b/server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification
@@ -23,3 +23,4 @@ org.elasticsearch.search.retriever.RetrieversFeatures
org.elasticsearch.script.ScriptFeatures
org.elasticsearch.reservedstate.service.FileSettingsFeatures
org.elasticsearch.cluster.routing.RoutingFeatures
+org.elasticsearch.action.admin.cluster.stats.ClusterStatsFeatures
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/MappingStatsTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/MappingStatsTests.java
index 2c374c7d26dee..96954458c18c4 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/MappingStatsTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/MappingStatsTests.java
@@ -18,6 +18,7 @@
import org.elasticsearch.common.io.stream.Writeable.Reader;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.index.IndexVersion;
+import org.elasticsearch.index.mapper.SourceFieldMapper;
import org.elasticsearch.script.Script;
import org.elasticsearch.tasks.TaskCancelledException;
import org.elasticsearch.test.AbstractWireSerializingTestCase;
@@ -29,7 +30,15 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.elasticsearch.index.mapper.SourceFieldMapper.Mode.DISABLED;
+import static org.elasticsearch.index.mapper.SourceFieldMapper.Mode.STORED;
+import static org.elasticsearch.index.mapper.SourceFieldMapper.Mode.SYNTHETIC;
+import static org.hamcrest.Matchers.equalTo;
public class MappingStatsTests extends AbstractWireSerializingTestCase {
@@ -203,7 +212,10 @@ public void testToXContent() {
"doc_max" : 0,
"doc_total" : 0
}
- ]
+ ],
+ "source_modes" : {
+ "stored" : 2
+ }
}
}""", Strings.toString(mappingStats, true, true));
}
@@ -332,7 +344,10 @@ public void testToXContentWithSomeSharedMappings() {
"doc_max" : 0,
"doc_total" : 0
}
- ]
+ ],
+ "source_modes" : {
+ "stored" : 3
+ }
}
}""", Strings.toString(mappingStats, true, true));
}
@@ -362,7 +377,24 @@ protected MappingStats createTestInstance() {
if (randomBoolean()) {
runtimeFieldStats.add(randomRuntimeFieldStats("long"));
}
- return new MappingStats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong(), stats, runtimeFieldStats);
+ Map sourceModeUsageCount = randomBoolean()
+ ? Map.of()
+ : Map.of(
+ STORED.toString().toLowerCase(Locale.ENGLISH),
+ randomNonNegativeInt(),
+ SYNTHETIC.toString().toLowerCase(Locale.ENGLISH),
+ randomNonNegativeInt(),
+ DISABLED.toString().toLowerCase(Locale.ENGLISH),
+ randomNonNegativeInt()
+ );
+ return new MappingStats(
+ randomNonNegativeLong(),
+ randomNonNegativeLong(),
+ randomNonNegativeLong(),
+ stats,
+ runtimeFieldStats,
+ sourceModeUsageCount
+ );
}
private static FieldStats randomFieldStats(String type) {
@@ -410,7 +442,8 @@ protected MappingStats mutateInstance(MappingStats instance) {
long totalFieldCount = instance.getTotalFieldCount().getAsLong();
long totalDeduplicatedFieldCount = instance.getTotalDeduplicatedFieldCount().getAsLong();
long totalMappingSizeBytes = instance.getTotalMappingSizeBytes().getAsLong();
- switch (between(1, 5)) {
+ var sourceModeUsageCount = new HashMap<>(instance.getSourceModeUsageCount());
+ switch (between(1, 6)) {
case 1 -> {
boolean remove = fieldTypes.size() > 0 && randomBoolean();
if (remove) {
@@ -435,8 +468,22 @@ protected MappingStats mutateInstance(MappingStats instance) {
case 3 -> totalFieldCount = randomValueOtherThan(totalFieldCount, ESTestCase::randomNonNegativeLong);
case 4 -> totalDeduplicatedFieldCount = randomValueOtherThan(totalDeduplicatedFieldCount, ESTestCase::randomNonNegativeLong);
case 5 -> totalMappingSizeBytes = randomValueOtherThan(totalMappingSizeBytes, ESTestCase::randomNonNegativeLong);
+ case 6 -> {
+ if (sourceModeUsageCount.isEmpty() == false) {
+ sourceModeUsageCount.remove(sourceModeUsageCount.keySet().stream().findFirst().get());
+ } else {
+ sourceModeUsageCount.put("stored", randomNonNegativeInt());
+ }
+ }
}
- return new MappingStats(totalFieldCount, totalDeduplicatedFieldCount, totalMappingSizeBytes, fieldTypes, runtimeFieldTypes);
+ return new MappingStats(
+ totalFieldCount,
+ totalDeduplicatedFieldCount,
+ totalMappingSizeBytes,
+ fieldTypes,
+ runtimeFieldTypes,
+ sourceModeUsageCount
+ );
}
public void testDenseVectorType() {
@@ -531,4 +578,39 @@ public void testWriteTo() throws IOException {
assertEquals(instance.getFieldTypeStats(), deserialized.getFieldTypeStats());
assertEquals(instance.getRuntimeFieldStats(), deserialized.getRuntimeFieldStats());
}
+
+ public void testSourceModes() {
+ var builder = Metadata.builder();
+ int numStoredIndices = randomIntBetween(1, 5);
+ int numSyntheticIndices = randomIntBetween(1, 5);
+ int numDisabledIndices = randomIntBetween(1, 5);
+ for (int i = 0; i < numSyntheticIndices; i++) {
+ IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo-synthetic-" + i).settings(
+ indexSettings(IndexVersion.current(), 4, 1).put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "synthetic")
+ );
+ builder.put(indexMetadata);
+ }
+ for (int i = 0; i < numStoredIndices; i++) {
+ IndexMetadata.Builder indexMetadata;
+ if (randomBoolean()) {
+ indexMetadata = new IndexMetadata.Builder("foo-stored-" + i).settings(
+ indexSettings(IndexVersion.current(), 4, 1).put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "stored")
+ );
+ } else {
+ indexMetadata = new IndexMetadata.Builder("foo-stored-" + i).settings(indexSettings(IndexVersion.current(), 4, 1));
+ }
+ builder.put(indexMetadata);
+ }
+ for (int i = 0; i < numDisabledIndices; i++) {
+ IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo-disabled-" + i).settings(
+ indexSettings(IndexVersion.current(), 4, 1).put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "disabled")
+ );
+ builder.put(indexMetadata);
+ }
+ var mappingStats = MappingStats.of(builder.build(), () -> {});
+ assertThat(mappingStats.getSourceModeUsageCount().get("synthetic"), equalTo(numSyntheticIndices));
+ assertThat(mappingStats.getSourceModeUsageCount().get("stored"), equalTo(numStoredIndices));
+ assertThat(mappingStats.getSourceModeUsageCount().get("disabled"), equalTo(numDisabledIndices));
+ }
+
}
diff --git a/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java b/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java
index 9458442557694..f4d50df4ff613 100644
--- a/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java
+++ b/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java
@@ -572,7 +572,8 @@ public void testToXContent() throws IOException {
"total_deduplicated_field_count": 0,
"total_deduplicated_mapping_size_in_bytes": 0,
"field_types": [],
- "runtime_field_types": []
+ "runtime_field_types": [],
+ "source_modes": {}
},
"analysis": {
"char_filter_types": [],
From 64dfed4e1f0610014f01fc7285fccac831a62c74 Mon Sep 17 00:00:00 2001
From: Alexander Spies
Date: Thu, 28 Nov 2024 11:01:52 +0100
Subject: [PATCH 20/39] ESQL: Mute CATEGORIZE optimizer tests on release builds
(#117690)
---
.../xpack/esql/optimizer/LogicalPlanOptimizerTests.java | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
index 2b4fb6ad68972..8373528531902 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
@@ -20,6 +20,7 @@
import org.elasticsearch.xpack.esql.EsqlTestUtils;
import org.elasticsearch.xpack.esql.TestBlockFactory;
import org.elasticsearch.xpack.esql.VerificationException;
+import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
import org.elasticsearch.xpack.esql.analysis.Analyzer;
import org.elasticsearch.xpack.esql.analysis.AnalyzerContext;
import org.elasticsearch.xpack.esql.analysis.AnalyzerTestUtils;
@@ -1211,6 +1212,8 @@ public void testCombineProjectionWithAggregationFirstAndAliasedGroupingUsedInAgg
* \_EsRelation[test][_meta_field{f}#23, emp_no{f}#17, first_name{f}#18, ..]
*/
public void testCombineProjectionWithCategorizeGrouping() {
+ assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled());
+
var plan = plan("""
from test
| eval k = first_name, k1 = k
@@ -3946,6 +3949,8 @@ public void testNestedExpressionsInGroups() {
* \_EsRelation[test][_meta_field{f}#14, emp_no{f}#8, first_name{f}#9, ge..]
*/
public void testNestedExpressionsInGroupsWithCategorize() {
+ assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled());
+
var plan = optimizedPlan("""
from test
| stats c = count(salary) by CATEGORIZE(CONCAT(first_name, "abc"))
From 146cb39143f93b6ce453229abf5be08335a75366 Mon Sep 17 00:00:00 2001
From: Tommaso Teofili
Date: Thu, 28 Nov 2024 13:46:24 +0100
Subject: [PATCH 21/39] ESQL - enabling scoring with METADATA _score (#113120)
* ESQL - enabling scoring with METADATA _score
Co-authored-by: ChrisHegarty
---
docs/changelog/113120.yaml | 5 +
muted-tests.yml | 6 +
.../search/sort/SortBuilder.java | 15 +-
.../core/expression/MetadataAttribute.java | 5 +-
.../compute/lucene/LuceneOperator.java | 5 +-
.../compute/lucene/LuceneSourceOperator.java | 96 ++++--
.../lucene/LuceneTopNSourceOperator.java | 141 +++++++--
.../elasticsearch/compute/OperatorTests.java | 3 +-
.../LuceneQueryExpressionEvaluatorTests.java | 33 +-
.../lucene/LuceneSourceOperatorTests.java | 31 +-
.../LuceneTopNSourceOperatorScoringTests.java | 151 +++++++++
.../lucene/LuceneTopNSourceOperatorTests.java | 50 ++-
.../ValueSourceReaderTypeConversionTests.java | 9 +-
.../ValuesSourceReaderOperatorTests.java | 9 +-
.../src/main/resources/qstr-function.csv-spec | 1 -
.../src/main/resources/scoring.csv-spec | 285 +++++++++++++++++
.../xpack/esql/action/EsqlActionTaskIT.java | 7 +-
.../xpack/esql/action/LookupFromIndexIT.java | 3 +-
.../xpack/esql/plugin/MatchFunctionIT.java | 299 ++++++++++++++++++
.../xpack/esql/plugin/MatchOperatorIT.java | 51 +++
.../xpack/esql/plugin/QueryStringIT.java | 96 ++++++
.../xpack/esql/action/EsqlCapabilities.java | 7 +-
.../xpack/esql/analysis/Verifier.java | 9 +
.../local/LucenePushdownPredicates.java | 5 +
.../physical/local/PushTopNToSource.java | 18 +-
.../local/ReplaceSourceAttributes.java | 14 +-
.../xpack/esql/parser/LogicalPlanBuilder.java | 4 +-
.../xpack/esql/plan/physical/EsQueryExec.java | 14 +
.../planner/EsPhysicalOperationProviders.java | 14 +-
.../xpack/esql/analysis/VerifierTests.java | 25 ++
.../optimizer/PhysicalPlanOptimizerTests.java | 62 ++++
.../physical/local/PushTopNToSourceTests.java | 193 ++++++++++-
32 files changed, 1570 insertions(+), 96 deletions(-)
create mode 100644 docs/changelog/113120.yaml
create mode 100644 x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorScoringTests.java
create mode 100644 x-pack/plugin/esql/qa/testFixtures/src/main/resources/scoring.csv-spec
create mode 100644 x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java
diff --git a/docs/changelog/113120.yaml b/docs/changelog/113120.yaml
new file mode 100644
index 0000000000000..801167d61c19c
--- /dev/null
+++ b/docs/changelog/113120.yaml
@@ -0,0 +1,5 @@
+pr: 113120
+summary: ESQL - enabling scoring with METADATA `_score`
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/muted-tests.yml b/muted-tests.yml
index 5cf16fdf3da0a..fdadc747289bb 100644
--- a/muted-tests.yml
+++ b/muted-tests.yml
@@ -224,6 +224,12 @@ tests:
issue: https://github.com/elastic/elasticsearch/issues/117591
- class: org.elasticsearch.repositories.s3.RepositoryS3ClientYamlTestSuiteIT
issue: https://github.com/elastic/elasticsearch/issues/117596
+- class: "org.elasticsearch.xpack.esql.qa.multi_node.EsqlSpecIT"
+ method: "test {scoring.*}"
+ issue: https://github.com/elastic/elasticsearch/issues/117641
+- class: "org.elasticsearch.xpack.esql.qa.single_node.EsqlSpecIT"
+ method: "test {scoring.*}"
+ issue: https://github.com/elastic/elasticsearch/issues/117641
# Examples:
#
diff --git a/server/src/main/java/org/elasticsearch/search/sort/SortBuilder.java b/server/src/main/java/org/elasticsearch/search/sort/SortBuilder.java
index 0ac3b42dd5b10..5832b93b9462f 100644
--- a/server/src/main/java/org/elasticsearch/search/sort/SortBuilder.java
+++ b/server/src/main/java/org/elasticsearch/search/sort/SortBuilder.java
@@ -158,6 +158,11 @@ private static void parseCompoundSortField(XContentParser parser, List buildSort(List> sortBuilders, SearchExecutionContext context) throws IOException {
+ return buildSort(sortBuilders, context, true);
+ }
+
+ public static Optional buildSort(List> sortBuilders, SearchExecutionContext context, boolean optimize)
+ throws IOException {
List sortFields = new ArrayList<>(sortBuilders.size());
List sortFormats = new ArrayList<>(sortBuilders.size());
for (SortBuilder> builder : sortBuilders) {
@@ -172,9 +177,13 @@ public static Optional buildSort(List> sortBuilde
if (sortFields.size() > 1) {
sort = true;
} else {
- SortField sortField = sortFields.get(0);
- if (sortField.getType() == SortField.Type.SCORE && sortField.getReverse() == false) {
- sort = false;
+ if (optimize) {
+ SortField sortField = sortFields.get(0);
+ if (sortField.getType() == SortField.Type.SCORE && sortField.getReverse() == false) {
+ sort = false;
+ } else {
+ sort = true;
+ }
} else {
sort = true;
}
diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MetadataAttribute.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MetadataAttribute.java
index 6e4e9292bfc99..0f1cfbb85039c 100644
--- a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MetadataAttribute.java
+++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MetadataAttribute.java
@@ -31,6 +31,7 @@
public class MetadataAttribute extends TypedAttribute {
public static final String TIMESTAMP_FIELD = "@timestamp";
public static final String TSID_FIELD = "_tsid";
+ public static final String SCORE = "_score";
static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
Attribute.class,
@@ -50,7 +51,9 @@ public class MetadataAttribute extends TypedAttribute {
SourceFieldMapper.NAME,
tuple(DataType.SOURCE, false),
IndexModeFieldMapper.NAME,
- tuple(DataType.KEYWORD, true)
+ tuple(DataType.KEYWORD, true),
+ SCORE,
+ tuple(DataType.DOUBLE, false)
);
private final boolean searchable;
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneOperator.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneOperator.java
index 6f75298e95dd7..bbc3ace3716ba 100644
--- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneOperator.java
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneOperator.java
@@ -79,6 +79,7 @@ public abstract static class Factory implements SourceOperator.SourceOperatorFac
protected final DataPartitioning dataPartitioning;
protected final int taskConcurrency;
protected final int limit;
+ protected final ScoreMode scoreMode;
protected final LuceneSliceQueue sliceQueue;
/**
@@ -95,6 +96,7 @@ protected Factory(
ScoreMode scoreMode
) {
this.limit = limit;
+ this.scoreMode = scoreMode;
this.dataPartitioning = dataPartitioning;
var weightFunction = weightFunction(queryFunction, scoreMode);
this.sliceQueue = LuceneSliceQueue.create(contexts, weightFunction, dataPartitioning, taskConcurrency);
@@ -438,7 +440,8 @@ static Function weightFunction(Function 0) {
- --remainingDocs;
- docsBuilder.appendInt(doc);
- currentPagePos++;
- } else {
- throw new CollectionTerminatedException();
- }
+ class LimitingCollector implements LeafCollector {
+ @Override
+ public void setScorer(Scorable scorer) {}
+
+ @Override
+ public void collect(int doc) throws IOException {
+ if (remainingDocs > 0) {
+ --remainingDocs;
+ docsBuilder.appendInt(doc);
+ currentPagePos++;
+ } else {
+ throw new CollectionTerminatedException();
}
- };
+ }
+ }
+
+ final class ScoringCollector extends LuceneSourceOperator.LimitingCollector {
+ private Scorable scorable;
+
+ @Override
+ public void setScorer(Scorable scorer) {
+ this.scorable = scorer;
+ }
+
+ @Override
+ public void collect(int doc) throws IOException {
+ super.collect(doc);
+ scoreBuilder.appendDouble(scorable.score());
+ }
}
@Override
@@ -139,15 +179,27 @@ public Page getCheckedOutput() throws IOException {
IntBlock shard = null;
IntBlock leaf = null;
IntVector docs = null;
+ DoubleVector scores = null;
+ DocBlock docBlock = null;
try {
shard = blockFactory.newConstantIntBlockWith(scorer.shardContext().index(), currentPagePos);
leaf = blockFactory.newConstantIntBlockWith(scorer.leafReaderContext().ord, currentPagePos);
docs = docsBuilder.build();
docsBuilder = blockFactory.newIntVectorBuilder(Math.min(remainingDocs, maxPageSize));
- page = new Page(currentPagePos, new DocVector(shard.asVector(), leaf.asVector(), docs, true).asBlock());
+ docBlock = new DocVector(shard.asVector(), leaf.asVector(), docs, true).asBlock();
+ shard = null;
+ leaf = null;
+ docs = null;
+ if (scoreBuilder == null) {
+ page = new Page(currentPagePos, docBlock);
+ } else {
+ scores = scoreBuilder.build();
+ scoreBuilder = blockFactory.newDoubleVectorBuilder(Math.min(remainingDocs, maxPageSize));
+ page = new Page(currentPagePos, docBlock, scores.asBlock());
+ }
} finally {
if (page == null) {
- Releasables.closeExpectNoException(shard, leaf, docs);
+ Releasables.closeExpectNoException(shard, leaf, docs, docBlock, scores);
}
}
currentPagePos = 0;
@@ -160,7 +212,7 @@ public Page getCheckedOutput() throws IOException {
@Override
public void close() {
- docsBuilder.close();
+ Releasables.close(docsBuilder, scoreBuilder);
}
@Override
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperator.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperator.java
index 0f600958b93b3..8da62963ffb64 100644
--- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperator.java
+++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperator.java
@@ -10,15 +10,22 @@
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.ReaderUtil;
import org.apache.lucene.search.CollectionTerminatedException;
+import org.apache.lucene.search.FieldDoc;
import org.apache.lucene.search.LeafCollector;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.ScoreMode;
-import org.apache.lucene.search.TopFieldCollector;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopDocsCollector;
import org.apache.lucene.search.TopFieldCollectorManager;
+import org.apache.lucene.search.TopScoreDocCollectorManager;
import org.elasticsearch.common.Strings;
import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.DocBlock;
import org.elasticsearch.compute.data.DocVector;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.DoubleVector;
import org.elasticsearch.compute.data.IntBlock;
import org.elasticsearch.compute.data.IntVector;
import org.elasticsearch.compute.data.Page;
@@ -29,17 +36,21 @@
import org.elasticsearch.search.sort.SortBuilder;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
+import static org.apache.lucene.search.ScoreMode.COMPLETE;
+import static org.apache.lucene.search.ScoreMode.TOP_DOCS;
+
/**
* Source operator that builds Pages out of the output of a TopFieldCollector (aka TopN)
*/
public final class LuceneTopNSourceOperator extends LuceneOperator {
- public static final class Factory extends LuceneOperator.Factory {
+ public static class Factory extends LuceneOperator.Factory {
private final int maxPageSize;
private final List> sorts;
@@ -50,16 +61,17 @@ public Factory(
int taskConcurrency,
int maxPageSize,
int limit,
- List> sorts
+ List> sorts,
+ boolean scoring
) {
- super(contexts, queryFunction, dataPartitioning, taskConcurrency, limit, ScoreMode.TOP_DOCS);
+ super(contexts, queryFunction, dataPartitioning, taskConcurrency, limit, scoring ? COMPLETE : TOP_DOCS);
this.maxPageSize = maxPageSize;
this.sorts = sorts;
}
@Override
public SourceOperator get(DriverContext driverContext) {
- return new LuceneTopNSourceOperator(driverContext.blockFactory(), maxPageSize, sorts, limit, sliceQueue);
+ return new LuceneTopNSourceOperator(driverContext.blockFactory(), maxPageSize, sorts, limit, sliceQueue, scoreMode);
}
public int maxPageSize() {
@@ -75,6 +87,8 @@ public String describe() {
+ maxPageSize
+ ", limit = "
+ limit
+ + ", scoreMode = "
+ + scoreMode
+ ", sorts = ["
+ notPrettySorts
+ "]]";
@@ -93,17 +107,20 @@ public String describe() {
private PerShardCollector perShardCollector;
private final List> sorts;
private final int limit;
+ private final ScoreMode scoreMode;
public LuceneTopNSourceOperator(
BlockFactory blockFactory,
int maxPageSize,
List> sorts,
int limit,
- LuceneSliceQueue sliceQueue
+ LuceneSliceQueue sliceQueue,
+ ScoreMode scoreMode
) {
super(blockFactory, maxPageSize, sliceQueue);
this.sorts = sorts;
this.limit = limit;
+ this.scoreMode = scoreMode;
}
@Override
@@ -145,7 +162,7 @@ private Page collect() throws IOException {
try {
if (perShardCollector == null || perShardCollector.shardContext.index() != scorer.shardContext().index()) {
// TODO: share the bottom between shardCollectors
- perShardCollector = new PerShardCollector(scorer.shardContext(), sorts, limit);
+ perShardCollector = newPerShardCollector(scorer.shardContext(), sorts, limit);
}
var leafCollector = perShardCollector.getLeafCollector(scorer.leafReaderContext());
scorer.scoreNextRange(leafCollector, scorer.leafReaderContext().reader().getLiveDocs(), maxPageSize);
@@ -171,7 +188,7 @@ private Page emit(boolean startEmitting) {
assert isEmitting() == false : "offset=" + offset + " score_docs=" + Arrays.toString(scoreDocs);
offset = 0;
if (perShardCollector != null) {
- scoreDocs = perShardCollector.topFieldCollector.topDocs().scoreDocs;
+ scoreDocs = perShardCollector.collector.topDocs().scoreDocs;
} else {
scoreDocs = new ScoreDoc[0];
}
@@ -183,10 +200,13 @@ private Page emit(boolean startEmitting) {
IntBlock shard = null;
IntVector segments = null;
IntVector docs = null;
+ DocBlock docBlock = null;
+ DoubleBlock scores = null;
Page page = null;
try (
IntVector.Builder currentSegmentBuilder = blockFactory.newIntVectorFixedBuilder(size);
- IntVector.Builder currentDocsBuilder = blockFactory.newIntVectorFixedBuilder(size)
+ IntVector.Builder currentDocsBuilder = blockFactory.newIntVectorFixedBuilder(size);
+ DoubleVector.Builder currentScoresBuilder = scoreVectorOrNull(size);
) {
int start = offset;
offset += size;
@@ -196,53 +216,130 @@ private Page emit(boolean startEmitting) {
int segment = ReaderUtil.subIndex(doc, leafContexts);
currentSegmentBuilder.appendInt(segment);
currentDocsBuilder.appendInt(doc - leafContexts.get(segment).docBase); // the offset inside the segment
+ if (currentScoresBuilder != null) {
+ float score = getScore(scoreDocs[i]);
+ currentScoresBuilder.appendDouble(score);
+ }
}
shard = blockFactory.newConstantIntBlockWith(perShardCollector.shardContext.index(), size);
segments = currentSegmentBuilder.build();
docs = currentDocsBuilder.build();
- page = new Page(size, new DocVector(shard.asVector(), segments, docs, null).asBlock());
+ docBlock = new DocVector(shard.asVector(), segments, docs, null).asBlock();
+ shard = null;
+ segments = null;
+ docs = null;
+ if (currentScoresBuilder == null) {
+ page = new Page(size, docBlock);
+ } else {
+ scores = currentScoresBuilder.build().asBlock();
+ page = new Page(size, docBlock, scores);
+ }
} finally {
if (page == null) {
- Releasables.closeExpectNoException(shard, segments, docs);
+ Releasables.closeExpectNoException(shard, segments, docs, docBlock, scores);
}
}
pagesEmitted++;
return page;
}
+ private float getScore(ScoreDoc scoreDoc) {
+ if (scoreDoc instanceof FieldDoc fieldDoc) {
+ if (Float.isNaN(fieldDoc.score)) {
+ if (sorts != null) {
+ return (Float) fieldDoc.fields[sorts.size() + 1];
+ } else {
+ return (Float) fieldDoc.fields[0];
+ }
+ } else {
+ return fieldDoc.score;
+ }
+ } else {
+ return scoreDoc.score;
+ }
+ }
+
+ private DoubleVector.Builder scoreVectorOrNull(int size) {
+ if (scoreMode.needsScores()) {
+ return blockFactory.newDoubleVectorFixedBuilder(size);
+ } else {
+ return null;
+ }
+ }
+
@Override
protected void describe(StringBuilder sb) {
sb.append(", limit = ").append(limit);
+ sb.append(", scoreMode = ").append(scoreMode);
String notPrettySorts = sorts.stream().map(Strings::toString).collect(Collectors.joining(","));
sb.append(", sorts = [").append(notPrettySorts).append("]");
}
- static final class PerShardCollector {
+ PerShardCollector newPerShardCollector(ShardContext shardContext, List> sorts, int limit) throws IOException {
+ Optional sortAndFormats = shardContext.buildSort(sorts);
+ if (sortAndFormats.isEmpty()) {
+ throw new IllegalStateException("sorts must not be disabled in TopN");
+ }
+ if (scoreMode.needsScores() == false) {
+ return new NonScoringPerShardCollector(shardContext, sortAndFormats.get().sort, limit);
+ } else {
+ SortField[] sortFields = sortAndFormats.get().sort.getSort();
+ if (sortFields != null && sortFields.length == 1 && sortFields[0].needsScores() && sortFields[0].getReverse() == false) {
+ // SORT _score DESC
+ return new ScoringPerShardCollector(
+ shardContext,
+ new TopScoreDocCollectorManager(limit, null, limit, false).newCollector()
+ );
+ } else {
+ // SORT ..., _score, ...
+ var sort = new Sort();
+ if (sortFields != null) {
+ var l = new ArrayList<>(Arrays.asList(sortFields));
+ l.add(SortField.FIELD_DOC);
+ l.add(SortField.FIELD_SCORE);
+ sort = new Sort(l.toArray(SortField[]::new));
+ }
+ return new ScoringPerShardCollector(
+ shardContext,
+ new TopFieldCollectorManager(sort, limit, null, limit, false).newCollector()
+ );
+ }
+ }
+ }
+
+ abstract static class PerShardCollector {
private final ShardContext shardContext;
- private final TopFieldCollector topFieldCollector;
+ private final TopDocsCollector> collector;
private int leafIndex;
private LeafCollector leafCollector;
private Thread currentThread;
- PerShardCollector(ShardContext shardContext, List> sorts, int limit) throws IOException {
+ PerShardCollector(ShardContext shardContext, TopDocsCollector> collector) {
this.shardContext = shardContext;
- Optional sortAndFormats = shardContext.buildSort(sorts);
- if (sortAndFormats.isEmpty()) {
- throw new IllegalStateException("sorts must not be disabled in TopN");
- }
-
- // We don't use CollectorManager here as we don't retrieve the total hits and sort by score.
- this.topFieldCollector = new TopFieldCollectorManager(sortAndFormats.get().sort, limit, null, 0, false).newCollector();
+ this.collector = collector;
}
LeafCollector getLeafCollector(LeafReaderContext leafReaderContext) throws IOException {
if (currentThread != Thread.currentThread() || leafIndex != leafReaderContext.ord) {
- leafCollector = topFieldCollector.getLeafCollector(leafReaderContext);
+ leafCollector = collector.getLeafCollector(leafReaderContext);
leafIndex = leafReaderContext.ord;
currentThread = Thread.currentThread();
}
return leafCollector;
}
}
+
+ static final class NonScoringPerShardCollector extends PerShardCollector {
+ NonScoringPerShardCollector(ShardContext shardContext, Sort sort, int limit) {
+ // We don't use CollectorManager here as we don't retrieve the total hits and sort by score.
+ super(shardContext, new TopFieldCollectorManager(sort, limit, null, 0, false).newCollector());
+ }
+ }
+
+ static final class ScoringPerShardCollector extends PerShardCollector {
+ ScoringPerShardCollector(ShardContext shardContext, TopDocsCollector> topDocsCollector) {
+ super(shardContext, topDocsCollector);
+ }
+ }
}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/OperatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/OperatorTests.java
index 0d39a5bf8227e..e6ef10e53ec7c 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/OperatorTests.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/OperatorTests.java
@@ -394,7 +394,8 @@ static LuceneOperator.Factory luceneOperatorFactory(IndexReader reader, Query qu
randomFrom(DataPartitioning.values()),
randomIntBetween(1, 10),
randomPageSize(),
- limit
+ limit,
+ false // no scoring
);
}
}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java
index beca522878358..ffaee536b443e 100644
--- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java
+++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java
@@ -27,6 +27,8 @@
import org.elasticsearch.compute.data.BooleanVector;
import org.elasticsearch.compute.data.BytesRefBlock;
import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.DocBlock;
+import org.elasticsearch.compute.data.DoubleBlock;
import org.elasticsearch.compute.data.ElementType;
import org.elasticsearch.compute.data.Page;
import org.elasticsearch.compute.lucene.LuceneQueryExpressionEvaluator.DenseCollector;
@@ -120,8 +122,9 @@ public void testTermQueryShuffled() throws IOException {
private void assertTermQuery(String term, List results) {
int matchCount = 0;
for (Page page : results) {
- BytesRefVector terms = page.getBlock(1).asVector();
- BooleanVector matches = page.getBlock(2).asVector();
+ int initialBlockIndex = initialBlockIndex(page);
+ BytesRefVector terms = page.getBlock(initialBlockIndex).asVector();
+ BooleanVector matches = page.