From d22a946fa736f4b08b9e0ec655afff4c5446c4d4 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Sun, 24 Nov 2024 17:39:20 -0800 Subject: [PATCH 001/129] Fix testCancelRequestWhenFailingFetchingPages (#117437) Each data-node request involves two exchange sinks: an external one for fetching pages from the coordinator and an internal one for node-level reduction. Currently, the test selects one of these sinks randomly, leading to assertion failures. This update ensures the test consistently selects the external exchange sink. Closes #117397 --- muted-tests.yml | 3 --- .../xpack/esql/action/EsqlActionTaskIT.java | 24 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/muted-tests.yml b/muted-tests.yml index f33ca972b7d36..0d2e6b991a5c3 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -229,9 +229,6 @@ tests: - class: org.elasticsearch.xpack.inference.DefaultEndPointsIT method: testInferDeploysDefaultElser issue: https://github.com/elastic/elasticsearch/issues/114913 -- class: org.elasticsearch.xpack.esql.action.EsqlActionTaskIT - method: testCancelRequestWhenFailingFetchingPages - issue: https://github.com/elastic/elasticsearch/issues/117397 - class: org.elasticsearch.xpack.security.operator.OperatorPrivilegesIT method: testEveryActionIsEitherOperatorOnlyOrNonOperator issue: https://github.com/elastic/elasticsearch/issues/102992 diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java index 460ab0f5b8b38..56453a291ea81 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java @@ -392,12 +392,13 @@ protected void doRun() throws Exception { .get(); ensureYellowAndNoInitializingShards("test"); request.query("FROM test | LIMIT 10"); - request.pragmas(randomPragmas()); + QueryPragmas pragmas = randomPragmas(); + request.pragmas(pragmas); PlainActionFuture future = new PlainActionFuture<>(); client.execute(EsqlQueryAction.INSTANCE, request, future); ExchangeService exchangeService = internalCluster().getInstance(ExchangeService.class, dataNode); - boolean waitedForPages; - final String sessionId; + final boolean waitedForPages; + final String exchangeId; try { List foundTasks = new ArrayList<>(); assertBusy(() -> { @@ -411,13 +412,22 @@ protected void doRun() throws Exception { assertThat(tasks, hasSize(1)); foundTasks.addAll(tasks); }); - sessionId = foundTasks.get(0).taskId().toString(); + final String sessionId = foundTasks.get(0).taskId().toString(); assertTrue(fetchingStarted.await(1, TimeUnit.MINUTES)); - String exchangeId = exchangeService.sinkKeys().stream().filter(s -> s.startsWith(sessionId)).findFirst().get(); + List sinkKeys = exchangeService.sinkKeys() + .stream() + .filter( + s -> s.startsWith(sessionId) + // exclude the node-level reduction sink + && s.endsWith("[n]") == false + ) + .toList(); + assertThat(sinkKeys.toString(), sinkKeys.size(), equalTo(1)); + exchangeId = sinkKeys.get(0); ExchangeSinkHandler exchangeSink = exchangeService.getSinkHandler(exchangeId); waitedForPages = randomBoolean(); if (waitedForPages) { - // do not fail exchange requests until we have some pages + // do not fail exchange requests until we have some pages. assertBusy(() -> assertThat(exchangeSink.bufferSize(), greaterThan(0))); } } finally { @@ -429,7 +439,7 @@ protected void doRun() throws Exception { // As a result, the exchange sinks on data-nodes won't be removed until the inactive_timeout elapses, which is // longer than the assertBusy timeout. if (waitedForPages == false) { - exchangeService.finishSinkHandler(sessionId, failure); + exchangeService.finishSinkHandler(exchangeId, failure); } } finally { transportService.clearAllRules(); From 2f8bb0b23ce6070335fb750d9e76265f558ea3a9 Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 25 Nov 2024 11:43:36 +0400 Subject: [PATCH 002/129] Add missing async_search query parameters to rest-api-spec (#117312) --- docs/changelog/117312.yaml | 5 +++++ .../rest-api-spec/api/async_search.submit.json | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 docs/changelog/117312.yaml diff --git a/docs/changelog/117312.yaml b/docs/changelog/117312.yaml new file mode 100644 index 0000000000000..302b91388ef2b --- /dev/null +++ b/docs/changelog/117312.yaml @@ -0,0 +1,5 @@ +pr: 117312 +summary: Add missing `async_search` query parameters to rest-api-spec +area: Search +type: bug +issues: [] diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json b/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json index 5cd2b0e26459e..a7a7ebe838eab 100644 --- a/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json +++ b/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json @@ -65,6 +65,11 @@ "type":"boolean", "description":"Specify whether wildcard and prefix queries should be analyzed (default: false)" }, + "ccs_minimize_roundtrips":{ + "type":"boolean", + "default":false, + "description":"When doing a cross-cluster search, setting it to true may improve overall search latency, particularly when searching clusters with a large number of shards. However, when set to true, the progress of searches on the remote clusters will not be received until the search finishes on all clusters." + }, "default_operator":{ "type":"enum", "options":[ @@ -126,6 +131,16 @@ "type":"string", "description":"Specify the node or shard the operation should be performed on (default: random)" }, + "pre_filter_shard_size":{ + "type":"number", + "default": 1, + "description":"Cannot be changed: this is to enforce the execution of a pre-filter roundtrip to retrieve statistics from each shard so that the ones that surely don’t hold any document matching the query get skipped." + }, + "rest_total_hits_as_int":{ + "type":"boolean", + "description":"Indicates whether hits.total should be rendered as an integer or an object in the rest search response", + "default":false + }, "q":{ "type":"string", "description":"Query in the Lucene query string syntax" From b0c49766f6a2301f8938629bfdadf76459329b8d Mon Sep 17 00:00:00 2001 From: David Turner Date: Mon, 25 Nov 2024 08:01:21 +0000 Subject: [PATCH 003/129] Extract IMDS test fixture from S3 fixture (#117324) The S3 and IMDS services are separate things in practice, we shouldn't be conflating them as we do today. This commit introduces a new independent test fixture just for the IMDS endpoint and migrates the relevant tests to use it. Relates ES-9984 --- modules/repository-s3/build.gradle | 1 + .../s3/RepositoryS3ClientYamlTestSuiteIT.java | 42 +++- .../RepositoryS3EcsClientYamlTestSuiteIT.java | 30 ++- settings.gradle | 1 + test/fixtures/ec2-imds-fixture/build.gradle | 19 ++ .../fixture/aws/imds/Ec2ImdsHttpFixture.java | 66 ++++++ .../fixture/aws/imds/Ec2ImdsHttpHandler.java | 98 +++++++++ .../aws/imds/Ec2ImdsHttpHandlerTests.java | 188 ++++++++++++++++++ .../java/fixture/s3/S3HttpFixtureWithEC2.java | 84 -------- .../java/fixture/s3/S3HttpFixtureWithECS.java | 48 ----- .../s3/S3HttpFixtureWithSessionToken.java | 12 +- 11 files changed, 433 insertions(+), 156 deletions(-) create mode 100644 test/fixtures/ec2-imds-fixture/build.gradle create mode 100644 test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java create mode 100644 test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java create mode 100644 test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java delete mode 100644 test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithEC2.java delete mode 100644 test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithECS.java diff --git a/modules/repository-s3/build.gradle b/modules/repository-s3/build.gradle index 1301d17606d63..9a7f0a5994d73 100644 --- a/modules/repository-s3/build.gradle +++ b/modules/repository-s3/build.gradle @@ -45,6 +45,7 @@ dependencies { testImplementation project(':test:fixtures:s3-fixture') yamlRestTestImplementation project(":test:framework") yamlRestTestImplementation project(':test:fixtures:s3-fixture') + yamlRestTestImplementation project(':test:fixtures:ec2-imds-fixture') yamlRestTestImplementation project(':test:fixtures:minio-fixture') internalClusterTestImplementation project(':test:fixtures:minio-fixture') diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java index 0ae8af0989fa6..64cb3c3fd3a69 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java @@ -9,8 +9,8 @@ package org.elasticsearch.repositories.s3; +import fixture.aws.imds.Ec2ImdsHttpFixture; import fixture.s3.S3HttpFixture; -import fixture.s3.S3HttpFixtureWithEC2; import fixture.s3.S3HttpFixtureWithSessionToken; import com.carrotsearch.randomizedtesting.annotations.Name; @@ -18,6 +18,7 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.elasticsearch.cluster.routing.Murmur3HashFunction; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; @@ -25,15 +26,34 @@ import org.junit.rules.RuleChain; import org.junit.rules.TestRule; +import java.util.Set; + @ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) @ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 public class RepositoryS3ClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { - public static final S3HttpFixture s3Fixture = new S3HttpFixture(); - public static final S3HttpFixtureWithSessionToken s3HttpFixtureWithSessionToken = new S3HttpFixtureWithSessionToken(); - public static final S3HttpFixtureWithEC2 s3Ec2 = new S3HttpFixtureWithEC2(); + private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); + private static final String TEMPORARY_SESSION_TOKEN = "session_token-" + HASHED_SEED; + private static final String IMDS_ACCESS_KEY = "imds-access-key-" + HASHED_SEED; + private static final String IMDS_SESSION_TOKEN = "imds-session-token-" + HASHED_SEED; + + private static final S3HttpFixture s3Fixture = new S3HttpFixture(); + + private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithSessionToken = new S3HttpFixtureWithSessionToken( + "session_token_bucket", + "session_token_base_path_integration_tests", + System.getProperty("s3TemporaryAccessKey"), + TEMPORARY_SESSION_TOKEN + ); + + private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithImdsSessionToken = new S3HttpFixtureWithSessionToken( + "ec2_bucket", + "ec2_base_path", + IMDS_ACCESS_KEY, + IMDS_SESSION_TOKEN + ); - private static final String s3TemporarySessionToken = "session_token"; + private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(IMDS_ACCESS_KEY, IMDS_SESSION_TOKEN, Set.of()); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") @@ -41,15 +61,19 @@ public class RepositoryS3ClientYamlTestSuiteIT extends AbstractRepositoryS3Clien .keystore("s3.client.integration_test_permanent.secret_key", System.getProperty("s3PermanentSecretKey")) .keystore("s3.client.integration_test_temporary.access_key", System.getProperty("s3TemporaryAccessKey")) .keystore("s3.client.integration_test_temporary.secret_key", System.getProperty("s3TemporarySecretKey")) - .keystore("s3.client.integration_test_temporary.session_token", s3TemporarySessionToken) + .keystore("s3.client.integration_test_temporary.session_token", TEMPORARY_SESSION_TOKEN) .setting("s3.client.integration_test_permanent.endpoint", s3Fixture::getAddress) .setting("s3.client.integration_test_temporary.endpoint", s3HttpFixtureWithSessionToken::getAddress) - .setting("s3.client.integration_test_ec2.endpoint", s3Ec2::getAddress) - .systemProperty("com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", s3Ec2::getAddress) + .setting("s3.client.integration_test_ec2.endpoint", s3HttpFixtureWithImdsSessionToken::getAddress) + .systemProperty("com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", ec2ImdsHttpFixture::getAddress) .build(); @ClassRule - public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(s3Ec2).around(s3HttpFixtureWithSessionToken).around(cluster); + public static TestRule ruleChain = RuleChain.outerRule(s3Fixture) + .around(s3HttpFixtureWithSessionToken) + .around(s3HttpFixtureWithImdsSessionToken) + .around(ec2ImdsHttpFixture) + .around(cluster); @ParametersFactory public static Iterable parameters() throws Exception { diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java index fa21797540c17..a522c9b17145b 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java @@ -9,28 +9,48 @@ package org.elasticsearch.repositories.s3; -import fixture.s3.S3HttpFixtureWithECS; +import fixture.aws.imds.Ec2ImdsHttpFixture; +import fixture.s3.S3HttpFixtureWithSessionToken; import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import org.elasticsearch.cluster.routing.Murmur3HashFunction; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; import org.junit.ClassRule; import org.junit.rules.RuleChain; import org.junit.rules.TestRule; +import java.util.Set; + public class RepositoryS3EcsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { - private static final S3HttpFixtureWithECS s3Ecs = new S3HttpFixtureWithECS(); + + private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); + private static final String ECS_ACCESS_KEY = "ecs-access-key-" + HASHED_SEED; + private static final String ECS_SESSION_TOKEN = "ecs-session-token-" + HASHED_SEED; + + private static final S3HttpFixtureWithSessionToken s3Fixture = new S3HttpFixtureWithSessionToken( + "ecs_bucket", + "ecs_base_path", + ECS_ACCESS_KEY, + ECS_SESSION_TOKEN + ); + + private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture( + ECS_ACCESS_KEY, + ECS_SESSION_TOKEN, + Set.of("/ecs_credentials_endpoint") + ); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .setting("s3.client.integration_test_ecs.endpoint", s3Ecs::getAddress) - .environment("AWS_CONTAINER_CREDENTIALS_FULL_URI", () -> (s3Ecs.getAddress() + "/ecs_credentials_endpoint")) + .setting("s3.client.integration_test_ecs.endpoint", s3Fixture::getAddress) + .environment("AWS_CONTAINER_CREDENTIALS_FULL_URI", () -> ec2ImdsHttpFixture.getAddress() + "/ecs_credentials_endpoint") .build(); @ClassRule - public static TestRule ruleChain = RuleChain.outerRule(s3Ecs).around(cluster); + public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(ec2ImdsHttpFixture).around(cluster); @ParametersFactory public static Iterable parameters() throws Exception { diff --git a/settings.gradle b/settings.gradle index 333f8272447c2..7bf03263031f1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -87,6 +87,7 @@ List projects = [ 'server', 'test:framework', 'test:fixtures:azure-fixture', + 'test:fixtures:ec2-imds-fixture', 'test:fixtures:gcs-fixture', 'test:fixtures:hdfs-fixture', 'test:fixtures:krb5kdc-fixture', diff --git a/test/fixtures/ec2-imds-fixture/build.gradle b/test/fixtures/ec2-imds-fixture/build.gradle new file mode 100644 index 0000000000000..7ad194acbb8fd --- /dev/null +++ b/test/fixtures/ec2-imds-fixture/build.gradle @@ -0,0 +1,19 @@ +/* + * 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". + */ +apply plugin: 'elasticsearch.java' + +description = 'Fixture for emulating the Instance Metadata Service (IMDS) running in AWS EC2' + +dependencies { + api project(':server') + api("junit:junit:${versions.junit}") { + transitive = false + } + api project(':test:framework') +} diff --git a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java new file mode 100644 index 0000000000000..68f46d778018c --- /dev/null +++ b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java @@ -0,0 +1,66 @@ +/* + * 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 fixture.aws.imds; + +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.rules.ExternalResource; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Objects; +import java.util.Set; + +public class Ec2ImdsHttpFixture extends ExternalResource { + + private HttpServer server; + + private final String accessKey; + private final String sessionToken; + private final Set alternativeCredentialsEndpoints; + + public Ec2ImdsHttpFixture(String accessKey, String sessionToken, Set alternativeCredentialsEndpoints) { + this.accessKey = accessKey; + this.sessionToken = sessionToken; + this.alternativeCredentialsEndpoints = alternativeCredentialsEndpoints; + } + + protected HttpHandler createHandler() { + return new Ec2ImdsHttpHandler(accessKey, sessionToken, alternativeCredentialsEndpoints); + } + + public String getAddress() { + return "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + } + + public void stop(int delay) { + server.stop(delay); + } + + protected void before() throws Throwable { + server = HttpServer.create(resolveAddress(), 0); + server.createContext("/", Objects.requireNonNull(createHandler())); + server.start(); + } + + @Override + protected void after() { + stop(0); + } + + private static InetSocketAddress resolveAddress() { + try { + return new InetSocketAddress(InetAddress.getByName("localhost"), 0); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } +} diff --git a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java new file mode 100644 index 0000000000000..04e5e83bddfa9 --- /dev/null +++ b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java @@ -0,0 +1,98 @@ +/* + * 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 fixture.aws.imds; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.util.concurrent.ConcurrentCollections; +import org.elasticsearch.core.SuppressForbidden; +import org.elasticsearch.rest.RestStatus; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.Objects; +import java.util.Set; + +import static org.elasticsearch.test.ESTestCase.randomIdentifier; + +/** + * Minimal HTTP handler that emulates the EC2 IMDS server + */ +@SuppressForbidden(reason = "this test uses a HttpServer to emulate the EC2 IMDS endpoint") +public class Ec2ImdsHttpHandler implements HttpHandler { + + private static final String IMDS_SECURITY_CREDENTIALS_PATH = "/latest/meta-data/iam/security-credentials/"; + + private final String accessKey; + private final String sessionToken; + private final Set validCredentialsEndpoints = ConcurrentCollections.newConcurrentSet(); + + public Ec2ImdsHttpHandler(String accessKey, String sessionToken, Collection alternativeCredentialsEndpoints) { + this.accessKey = Objects.requireNonNull(accessKey); + this.sessionToken = Objects.requireNonNull(sessionToken); + this.validCredentialsEndpoints.addAll(alternativeCredentialsEndpoints); + } + + @Override + public void handle(final HttpExchange exchange) throws IOException { + // http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html + + try (exchange) { + final var path = exchange.getRequestURI().getPath(); + final var requestMethod = exchange.getRequestMethod(); + + if ("PUT".equals(requestMethod) && "/latest/api/token".equals(path)) { + // Reject IMDSv2 probe + exchange.sendResponseHeaders(RestStatus.METHOD_NOT_ALLOWED.getStatus(), -1); + return; + } + + if ("GET".equals(requestMethod)) { + if (path.equals(IMDS_SECURITY_CREDENTIALS_PATH)) { + final var profileName = randomIdentifier(); + validCredentialsEndpoints.add(IMDS_SECURITY_CREDENTIALS_PATH + profileName); + final byte[] response = profileName.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain"); + exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length); + exchange.getResponseBody().write(response); + return; + } else if (validCredentialsEndpoints.contains(path)) { + final byte[] response = Strings.format( + """ + { + "AccessKeyId": "%s", + "Expiration": "%s", + "RoleArn": "%s", + "SecretAccessKey": "%s", + "Token": "%s" + }""", + accessKey, + ZonedDateTime.now(Clock.systemUTC()).plusDays(1L).format(DateTimeFormatter.ISO_DATE_TIME), + randomIdentifier(), + randomIdentifier(), + sessionToken + ).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length); + exchange.getResponseBody().write(response); + return; + } + } + + ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError("not supported: " + requestMethod + " " + path)); + } + } +} diff --git a/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java b/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java new file mode 100644 index 0000000000000..5d5cbfae3fa60 --- /dev/null +++ b/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java @@ -0,0 +1,188 @@ +/* + * 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 fixture.aws.imds; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xcontent.XContentType; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Set; + +public class Ec2ImdsHttpHandlerTests extends ESTestCase { + + public void testImdsV1() throws IOException { + final var accessKey = randomIdentifier(); + final var sessionToken = randomIdentifier(); + + final var handler = new Ec2ImdsHttpHandler(accessKey, sessionToken, Set.of()); + + final var roleResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/"); + assertEquals(RestStatus.OK, roleResponse.status()); + final var profileName = roleResponse.body().utf8ToString(); + assertTrue(Strings.hasText(profileName)); + + final var credentialsResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/" + profileName); + assertEquals(RestStatus.OK, credentialsResponse.status()); + + final var responseMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), credentialsResponse.body().streamInput(), false); + assertEquals(Set.of("AccessKeyId", "Expiration", "RoleArn", "SecretAccessKey", "Token"), responseMap.keySet()); + assertEquals(accessKey, responseMap.get("AccessKeyId")); + assertEquals(sessionToken, responseMap.get("Token")); + } + + public void testImdsV2Disabled() { + assertEquals( + RestStatus.METHOD_NOT_ALLOWED, + handleRequest(new Ec2ImdsHttpHandler(randomIdentifier(), randomIdentifier(), Set.of()), "PUT", "/latest/api/token").status() + ); + } + + private record TestHttpResponse(RestStatus status, BytesReference body) {} + + private static TestHttpResponse handleRequest(Ec2ImdsHttpHandler handler, String method, String uri) { + final var httpExchange = new TestHttpExchange(method, uri, BytesArray.EMPTY, TestHttpExchange.EMPTY_HEADERS); + try { + handler.handle(httpExchange); + } catch (IOException e) { + fail(e); + } + assertNotEquals(0, httpExchange.getResponseCode()); + return new TestHttpResponse(RestStatus.fromCode(httpExchange.getResponseCode()), httpExchange.getResponseBodyContents()); + } + + private static class TestHttpExchange extends HttpExchange { + + private static final Headers EMPTY_HEADERS = new Headers(); + + private final String method; + private final URI uri; + private final BytesReference requestBody; + private final Headers requestHeaders; + + private final Headers responseHeaders = new Headers(); + private final BytesStreamOutput responseBody = new BytesStreamOutput(); + private int responseCode; + + TestHttpExchange(String method, String uri, BytesReference requestBody, Headers requestHeaders) { + this.method = method; + this.uri = URI.create(uri); + this.requestBody = requestBody; + this.requestHeaders = requestHeaders; + } + + @Override + public Headers getRequestHeaders() { + return requestHeaders; + } + + @Override + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public URI getRequestURI() { + return uri; + } + + @Override + public String getRequestMethod() { + return method; + } + + @Override + public HttpContext getHttpContext() { + return null; + } + + @Override + public void close() {} + + @Override + public InputStream getRequestBody() { + try { + return requestBody.streamInput(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public OutputStream getResponseBody() { + return responseBody; + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) { + this.responseCode = rCode; + } + + @Override + public InetSocketAddress getRemoteAddress() { + return null; + } + + @Override + public int getResponseCode() { + return responseCode; + } + + public BytesReference getResponseBodyContents() { + return responseBody.bytes(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return null; + } + + @Override + public String getProtocol() { + return "HTTP/1.1"; + } + + @Override + public Object getAttribute(String name) { + return null; + } + + @Override + public void setAttribute(String name, Object value) { + fail("setAttribute not implemented"); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + fail("setStreams not implemented"); + } + + @Override + public HttpPrincipal getPrincipal() { + fail("getPrincipal not implemented"); + throw new UnsupportedOperationException("getPrincipal not implemented"); + } + } + +} diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithEC2.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithEC2.java deleted file mode 100644 index d7048cbea6b8a..0000000000000 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithEC2.java +++ /dev/null @@ -1,84 +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", 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 fixture.s3; - -import com.sun.net.httpserver.HttpHandler; - -import org.elasticsearch.rest.RestStatus; - -import java.nio.charset.StandardCharsets; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -public class S3HttpFixtureWithEC2 extends S3HttpFixtureWithSessionToken { - - private static final String EC2_PATH = "/latest/meta-data/iam/security-credentials/"; - private static final String EC2_PROFILE = "ec2Profile"; - - public S3HttpFixtureWithEC2() { - this(true); - } - - public S3HttpFixtureWithEC2(boolean enabled) { - this(enabled, "ec2_bucket", "ec2_base_path", "ec2_access_key", "ec2_session_token"); - } - - public S3HttpFixtureWithEC2(boolean enabled, String bucket, String basePath, String accessKey, String sessionToken) { - super(enabled, bucket, basePath, accessKey, sessionToken); - } - - @Override - protected HttpHandler createHandler() { - final HttpHandler delegate = super.createHandler(); - - return exchange -> { - final String path = exchange.getRequestURI().getPath(); - // http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html - if ("GET".equals(exchange.getRequestMethod()) && path.startsWith(EC2_PATH)) { - if (path.equals(EC2_PATH)) { - final byte[] response = EC2_PROFILE.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "text/plain"); - exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length); - exchange.getResponseBody().write(response); - exchange.close(); - return; - - } else if (path.equals(EC2_PATH + EC2_PROFILE)) { - final byte[] response = buildCredentialResponse(accessKey, sessionToken).getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length); - exchange.getResponseBody().write(response); - exchange.close(); - return; - } - - final byte[] response = "unknown profile".getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "text/plain"); - exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), response.length); - exchange.getResponseBody().write(response); - exchange.close(); - return; - - } - delegate.handle(exchange); - }; - } - - protected static String buildCredentialResponse(final String ec2AccessKey, final String ec2SessionToken) { - return String.format(Locale.ROOT, """ - { - "AccessKeyId": "%s", - "Expiration": "%s", - "RoleArn": "arn", - "SecretAccessKey": "secret_access_key", - "Token": "%s" - }""", ec2AccessKey, ZonedDateTime.now().plusDays(1L).format(DateTimeFormatter.ISO_DATE_TIME), ec2SessionToken); - } -} diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithECS.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithECS.java deleted file mode 100644 index d6266ea75dd3a..0000000000000 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithECS.java +++ /dev/null @@ -1,48 +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", 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 fixture.s3; - -import com.sun.net.httpserver.HttpHandler; - -import org.elasticsearch.rest.RestStatus; - -import java.nio.charset.StandardCharsets; - -public class S3HttpFixtureWithECS extends S3HttpFixtureWithEC2 { - - public S3HttpFixtureWithECS() { - this(true); - } - - public S3HttpFixtureWithECS(boolean enabled) { - this(enabled, "ecs_bucket", "ecs_base_path", "ecs_access_key", "ecs_session_token"); - } - - public S3HttpFixtureWithECS(boolean enabled, String bucket, String basePath, String accessKey, String sessionToken) { - super(enabled, bucket, basePath, accessKey, sessionToken); - } - - @Override - protected HttpHandler createHandler() { - final HttpHandler delegate = super.createHandler(); - - return exchange -> { - // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html - if ("GET".equals(exchange.getRequestMethod()) && exchange.getRequestURI().getPath().equals("/ecs_credentials_endpoint")) { - final byte[] response = buildCredentialResponse(accessKey, sessionToken).getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length); - exchange.getResponseBody().write(response); - exchange.close(); - return; - } - delegate.handle(exchange); - }; - } -} diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java index 1a1cbba651e06..001cc34d9b20d 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java @@ -18,16 +18,8 @@ public class S3HttpFixtureWithSessionToken extends S3HttpFixture { protected final String sessionToken; - public S3HttpFixtureWithSessionToken() { - this(true); - } - - public S3HttpFixtureWithSessionToken(boolean enabled) { - this(enabled, "session_token_bucket", "session_token_base_path_integration_tests", "session_token_access_key", "session_token"); - } - - public S3HttpFixtureWithSessionToken(boolean enabled, String bucket, String basePath, String accessKey, String sessionToken) { - super(enabled, bucket, basePath, accessKey); + public S3HttpFixtureWithSessionToken(String bucket, String basePath, String accessKey, String sessionToken) { + super(true, bucket, basePath, accessKey); this.sessionToken = sessionToken; } From 79d8eb51b4f87276a5c9259af46b635d42a75058 Mon Sep 17 00:00:00 2001 From: David Turner Date: Mon, 25 Nov 2024 09:30:53 +0000 Subject: [PATCH 004/129] Rename `RepositoryS3RestIT` (#117449) This test suite is less generic than its current name suggests. Relates ES-9984 --- ...stIT.java => RepositoryS3RestReloadCredentialsIT.java} | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) rename modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/{RepositoryS3RestIT.java => RepositoryS3RestReloadCredentialsIT.java} (89%) diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java similarity index 89% rename from modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestIT.java rename to modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java index dcd29c6d26c6e..2f3e995b52468 100644 --- a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java @@ -13,6 +13,7 @@ import org.elasticsearch.client.Request; import org.elasticsearch.client.ResponseException; +import org.elasticsearch.cluster.routing.Murmur3HashFunction; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.cluster.ElasticsearchCluster; @@ -28,10 +29,11 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; -public class RepositoryS3RestIT extends ESRestTestCase { +public class RepositoryS3RestReloadCredentialsIT extends ESRestTestCase { - private static final String BUCKET = "RepositoryS3JavaRestTest-bucket"; - private static final String BASE_PATH = "RepositoryS3JavaRestTest-base-path"; + private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); + private static final String BUCKET = "RepositoryS3RestReloadCredentialsIT-bucket-" + HASHED_SEED; + private static final String BASE_PATH = "RepositoryS3RestReloadCredentialsIT-base-path-" + HASHED_SEED; public static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, "ignored"); From fbc6abec0552a29b99675800ed134468910fb9f1 Mon Sep 17 00:00:00 2001 From: Kostas Krikellas <131142368+kkrik-es@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:32:28 +0200 Subject: [PATCH 005/129] [TEST] Unmute randomized logsdb test (#117450) Test fixed in #117228 and here. Fixes #116536 Fixes #117212 --- muted-tests.yml | 6 ------ .../qa/StandardVersusLogsIndexModeChallengeRestIT.java | 10 +++++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/muted-tests.yml b/muted-tests.yml index 0d2e6b991a5c3..d4b77f5269c10 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -168,9 +168,6 @@ tests: - class: org.elasticsearch.xpack.searchablesnapshots.SearchableSnapshotsCanMatchOnCoordinatorIntegTests method: testSearchableSnapshotShardsAreSkippedBySearchRequestWithoutQueryingAnyNodeWhenTheyAreOutsideOfTheQueryRange issue: https://github.com/elastic/elasticsearch/issues/116523 -- class: org.elasticsearch.xpack.logsdb.qa.StandardVersusLogsIndexModeRandomDataDynamicMappingChallengeRestIT - method: testMatchAllQuery - issue: https://github.com/elastic/elasticsearch/issues/116536 - class: org.elasticsearch.xpack.esql.ccq.MultiClusterSpecIT method: test {categorize.Categorize} issue: https://github.com/elastic/elasticsearch/issues/116434 @@ -208,9 +205,6 @@ tests: - class: org.elasticsearch.xpack.inference.DefaultEndPointsIT method: testMultipleInferencesTriggeringDownloadAndDeploy issue: https://github.com/elastic/elasticsearch/issues/117208 -- class: org.elasticsearch.xpack.logsdb.qa.StandardVersusLogsStoredSourceChallengeRestIT - method: testEsqlSource - issue: https://github.com/elastic/elasticsearch/issues/117212 - class: org.elasticsearch.ingest.geoip.EnterpriseGeoIpDownloaderIT method: testEnterpriseDownloaderTask issue: https://github.com/elastic/elasticsearch/issues/115163 diff --git a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/qa/StandardVersusLogsIndexModeChallengeRestIT.java b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/qa/StandardVersusLogsIndexModeChallengeRestIT.java index 8930ff23fb3b0..e411f2f3f314d 100644 --- a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/qa/StandardVersusLogsIndexModeChallengeRestIT.java +++ b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/qa/StandardVersusLogsIndexModeChallengeRestIT.java @@ -181,7 +181,7 @@ protected static void waitForLogs(RestClient client) throws Exception { } public void testMatchAllQuery() throws IOException { - int numberOfDocuments = ESTestCase.randomIntBetween(100, 200); + int numberOfDocuments = ESTestCase.randomIntBetween(20, 100); final List documents = generateDocuments(numberOfDocuments); indexDocuments(documents); @@ -199,7 +199,7 @@ public void testMatchAllQuery() throws IOException { } public void testTermsQuery() throws IOException { - int numberOfDocuments = ESTestCase.randomIntBetween(100, 200); + int numberOfDocuments = ESTestCase.randomIntBetween(20, 100); final List documents = generateDocuments(numberOfDocuments); indexDocuments(documents); @@ -217,7 +217,7 @@ public void testTermsQuery() throws IOException { } public void testHistogramAggregation() throws IOException { - int numberOfDocuments = ESTestCase.randomIntBetween(100, 200); + int numberOfDocuments = ESTestCase.randomIntBetween(20, 100); final List documents = generateDocuments(numberOfDocuments); indexDocuments(documents); @@ -235,7 +235,7 @@ public void testHistogramAggregation() throws IOException { } public void testTermsAggregation() throws IOException { - int numberOfDocuments = ESTestCase.randomIntBetween(100, 200); + int numberOfDocuments = ESTestCase.randomIntBetween(20, 100); final List documents = generateDocuments(numberOfDocuments); indexDocuments(documents); @@ -253,7 +253,7 @@ public void testTermsAggregation() throws IOException { } public void testDateHistogramAggregation() throws IOException { - int numberOfDocuments = ESTestCase.randomIntBetween(100, 200); + int numberOfDocuments = ESTestCase.randomIntBetween(20, 100); final List documents = generateDocuments(numberOfDocuments); indexDocuments(documents); From 1d4c8d85f6641f8f4efa776106392b0eb6980406 Mon Sep 17 00:00:00 2001 From: Luke Whiting Date: Mon, 25 Nov 2024 09:51:11 +0000 Subject: [PATCH 006/129] (#34659) - Add Timezone Configuration to Watcher (#117033) * Add timezone support to Cron objects * Add timezone support to CronnableSchedule * XContent change to support parsing and display of TimeZone fields on schedules * Case insensitive timezone parsing * Doc changes * YAML REST tests * Equals, toString and HashCode now include timezone * Additional random testing for DST transitions * Migrate Cron class to use wrapped LocalDateTime The algorithm depends on some quirks of calendar but LocalDateTime correctly ignores DST during calculations so this uses a LocalDateTime with a wrapper to emulate some of Calendar's behaviours that the Cron algorithm depends on * Additional documentation to explain discontinuity event behaviour * Remove redundant conversions from ZoneId to TimeZone following move to LocalDateTime * Add documentation warning that manual clock changes will cause unpredictable watch execution * Update docs/reference/watcher/trigger/schedule.asciidoc Co-authored-by: Lee Hinman --------- Co-authored-by: Lee Hinman --- .../watching-time-series-data.asciidoc | 8 +- .../watcher/trigger/schedule.asciidoc | 33 +- .../watcher/trigger/schedule/cron.asciidoc | 42 ++- .../watcher/trigger/schedule/daily.asciidoc | 24 ++ .../watcher/trigger/schedule/monthly.asciidoc | 24 +- .../watcher/trigger/schedule/weekly.asciidoc | 24 +- .../watcher/trigger/schedule/yearly.asciidoc | 23 ++ .../xpack/core/scheduler/Cron.java | 287 +++++++++--------- .../scheduler/LocalDateTimeLegacyWrapper.java | 130 ++++++++ .../core/scheduler/CronTimezoneTests.java | 231 ++++++++++++++ .../connector/ConnectorCustomSchedule.java | 2 +- .../connector/ConnectorScheduling.java | 2 +- .../slm/SnapshotLifecyclePolicyTests.java | 4 +- .../put_watch/11_timezoned_schedules.yml | 121 ++++++++ .../trigger/schedule/CronnableSchedule.java | 34 ++- .../trigger/schedule/ScheduleRegistry.java | 40 ++- .../trigger/schedule/ScheduleTrigger.java | 9 +- .../schedule/support/TimezoneUtils.java | 55 ++++ .../schedule/ScheduleRegistryTests.java | 15 +- .../schedule/support/TimezoneUtilsTests.java | 40 +++ 20 files changed, 975 insertions(+), 173 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/LocalDateTimeLegacyWrapper.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/scheduler/CronTimezoneTests.java create mode 100644 x-pack/plugin/watcher/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/watcher/put_watch/11_timezoned_schedules.yml create mode 100644 x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtils.java create mode 100644 x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtilsTests.java diff --git a/docs/reference/watcher/example-watches/watching-time-series-data.asciidoc b/docs/reference/watcher/example-watches/watching-time-series-data.asciidoc index 421c69619cfea..b1c776baae1de 100644 --- a/docs/reference/watcher/example-watches/watching-time-series-data.asciidoc +++ b/docs/reference/watcher/example-watches/watching-time-series-data.asciidoc @@ -62,20 +62,20 @@ contain the words "error" or "problem". To set up the watch: -. Define the watch trigger--a daily schedule that runs at 12:00 UTC: +. Define the watch trigger--a daily schedule that runs at 12:00 Australian Eastern Standard Time (UTC+10:00): + [source,js] -------------------------------------------------- "trigger" : { "schedule" : { + "timezone": "Australia/Brisbane", "daily" : { "at" : "12:00" } } } -------------------------------------------------- + -NOTE: In {watcher}, you specify times in UTC time. Don't forget to do the - conversion from your local time so the schedule triggers at the time - you intend. +NOTE: In {watcher}, if the timezone is omitted then schedules default to UTC. `timezone` can be specified either +as a +/-HH:mm offset from UTC or as a timezone name from the machines local IANA Time Zone Database. . Define the watch input--a search that uses a filter to constrain the results to the past day. diff --git a/docs/reference/watcher/trigger/schedule.asciidoc b/docs/reference/watcher/trigger/schedule.asciidoc index fa389409d15c4..d2bf466644e10 100644 --- a/docs/reference/watcher/trigger/schedule.asciidoc +++ b/docs/reference/watcher/trigger/schedule.asciidoc @@ -6,12 +6,42 @@ ++++ Schedule <> define when the watch execution should start based -on date and time. All times are specified in UTC time. +on date and time. All times are in UTC time unless a timezone is explicitly specified +in the schedule. {watcher} uses the system clock to determine the current time. To ensure schedules are triggered when expected, you should synchronize the clocks of all nodes in the cluster using a time service such as http://www.ntp.org/[NTP]. +NOTE: {watcher} can't correct for manual adjustments to the system clock. Be aware when making +such changes that watch execution may be affected with watches being skipped or repeated if the +adjustment covers their target execution time. This applies to changes made via NTP as well. + +When specifying a timezone for a watch, keep in mind the effect daylight savings time +transitions may have on the schedule, especially if the watch is scheduled to run +during the transition. Here's how {watcher} handles watches scheduled during discontinuities: + +==== Gap Transitions +These occur when the clock moves forward, such as when daylight savings time starts +and cause certain hours or minutes to be skipped. If your watch is scheduled to run +during a gap transition, the watch is executed at the same time as before the transition. + +Example: If a watch is scheduled to run daily at 1:30AM in the `Europe/London` time zone and +the clock moves forward one hour from 1:00AM (GMT+0) to 2:00AM (GMT+1), the watch is executed +at 2:30AM (GMT+1) which would have been 1:30AM before the transition. Subsequent executions +happen at 1:30AM (GMT+1). + +==== Overlap Transitions +These occur when the clock moves backward, such as when daylight savings time ends +and cause certain hours or minutes to be repeated. If your watch is scheduled to run +during an overlap transition, only the first occurrence of the time causes to the watch +to execute with the second being skipped. + +Example: If a watch is scheduled to run at 1:30 AM and the clock moves backward one hour +from 2:00AM to 1:00AM, the watch is executed at 1:30AM and the second occurrence after the +change is skipped. + +=== Throttling Keep in mind that the throttle period can affect when a watch is actually executed. The default throttle period is five seconds (5000 ms). If you configure a schedule that's more frequent than the throttle period, the throttle period overrides the @@ -20,6 +50,7 @@ and set the schedule to every 10 seconds, the watch is executed no more than once per minute. For more information about throttling, see <>. +=== Schedule Types {watcher} provides several types of schedule triggers: * <> diff --git a/docs/reference/watcher/trigger/schedule/cron.asciidoc b/docs/reference/watcher/trigger/schedule/cron.asciidoc index 673f350435c5f..c33bf524a8737 100644 --- a/docs/reference/watcher/trigger/schedule/cron.asciidoc +++ b/docs/reference/watcher/trigger/schedule/cron.asciidoc @@ -5,14 +5,14 @@ ++++ -Defines a <> using a <> +Defines a <> using a <> that specifiues when to execute a watch. -TIP: While cron expressions are powerful, a regularly occurring schedule -is easier to configure with the other schedule types. -If you must use a cron schedule, make sure you verify it with -<> . +TIP: While cron expressions are powerful, a regularly occurring schedule +is easier to configure with the other schedule types. +If you must use a cron schedule, make sure you verify it with +<> . ===== Configure a cron schedule with one time @@ -60,16 +60,40 @@ minute during the weekend: -------------------------------------------------- // NOTCONSOLE +[[configue_cron_time-zone]] +==== Use a different time zone for a cron schedule +By default, cron expressions are evaluated in the UTC time zone. To use a different time zone, +you can specify the `timezone` parameter in the schedule. For example, the following +`cron` schedule triggers at 6:00 AM and 6:00 PM during weekends in the `America/Los_Angeles` time zone: + + +[source,js] +-------------------------------------------------- +{ + ... + "trigger" : { + "schedule" : { + "timezone" : "America/Los_Angeles", + "cron" : [ + "0 6,18 * * * SAT-SUN", + ] + } + } + ... +} +-------------------------------------------------- +// NOTCONSOLE + [[croneval]] ===== Use croneval to validate cron expressions -{es} provides a <> command line tool -in the `$ES_HOME/bin` directory that you can use to check that your cron expressions +{es} provides a <> command line tool +in the `$ES_HOME/bin` directory that you can use to check that your cron expressions are valid and produce the expected results. -To validate a cron expression, pass it in as a parameter to `elasticsearch-croneval`: +To validate a cron expression, pass it in as a parameter to `elasticsearch-croneval`: [source,bash] -------------------------------------------------- bin/elasticsearch-croneval "0 0/1 * * * ?" --------------------------------------------------- +-------------------------------------------------- diff --git a/docs/reference/watcher/trigger/schedule/daily.asciidoc b/docs/reference/watcher/trigger/schedule/daily.asciidoc index cea2b8316e02f..d258d9c612350 100644 --- a/docs/reference/watcher/trigger/schedule/daily.asciidoc +++ b/docs/reference/watcher/trigger/schedule/daily.asciidoc @@ -97,3 +97,27 @@ or minutes as an array. For example, following `daily` schedule triggers at } -------------------------------------------------- // NOTCONSOLE + +[[specifying-time-zone-for-daily-schedule]] +===== Specifying a time zone for a daily schedule +By default, daily schedules are evaluated in the UTC time zone. To use a different time zone, +you can specify the `timezone` parameter in the schedule. For example, the following +`daily` schedule triggers at 6:00 AM and 6:00 PM in the `Pacific/Galapagos` time zone: + +[source,js] +-------------------------------------------------- +{ + "trigger" : { + "schedule" : { + "timezone" : "Pacific/Galapagos", + "daily" : { + "at" : { + "hour" : [ 6, 18 ], + "minute" : 0 + } + } + } + } +} +-------------------------------------------------- +// NOTCONSOLE diff --git a/docs/reference/watcher/trigger/schedule/monthly.asciidoc b/docs/reference/watcher/trigger/schedule/monthly.asciidoc index 7d13262ed2fa8..694c76aaee23a 100644 --- a/docs/reference/watcher/trigger/schedule/monthly.asciidoc +++ b/docs/reference/watcher/trigger/schedule/monthly.asciidoc @@ -74,4 +74,26 @@ schedule triggers at 12:00 AM and 12:00 PM on the 10th and 20th of each month. } } -------------------------------------------------- -// NOTCONSOLE \ No newline at end of file +// NOTCONSOLE + +==== Configuring time zones for monthly schedules +By default, monthly schedules are evaluated in the UTC time zone. To use a different +time zone, you can specify the `timezone` parameter in the schedule. For example, +the following `monthly` schedule triggers at 6:00 AM and 6:00 PM on the 15th of each month in +the `Asia/Tokyo` time zone: + +[source,js] +-------------------------------------------------- +{ + "trigger" : { + "schedule" : { + "timezone" : "Asia/Tokyo", + "monthly" : { + "on" : [ 15 ], + "at" : [ 6:00, 18:00 ] + } + } + } +} +-------------------------------------------------- +// NOTCONSOLE diff --git a/docs/reference/watcher/trigger/schedule/weekly.asciidoc b/docs/reference/watcher/trigger/schedule/weekly.asciidoc index 5b43de019ad25..53bd2f3167b21 100644 --- a/docs/reference/watcher/trigger/schedule/weekly.asciidoc +++ b/docs/reference/watcher/trigger/schedule/weekly.asciidoc @@ -79,4 +79,26 @@ Alternatively, you can specify days and times in an object that has `on` and } } -------------------------------------------------- -// NOTCONSOLE \ No newline at end of file +// NOTCONSOLE + +==== Use a different time zone for a weekly schedule +By default, weekly schedules are evaluated in the UTC time zone. To use a different time zone, +you can specify the `timezone` parameter in the schedule. For example, the following +`weekly` schedule triggers at 6:00 AM and 6:00 PM on Tuesdays and Fridays in the +`America/Buenos_Aires` time zone: + +[source,js] +-------------------------------------------------- +{ + "trigger" : { + "schedule" : { + "timezone" : "America/Buenos_Aires", + "weekly" : { + "on" : [ "tuesday", "friday" ], + "at" : [ "6:00", "18:00" ] + } + } + } +} +-------------------------------------------------- +// NOTCONSOLE diff --git a/docs/reference/watcher/trigger/schedule/yearly.asciidoc b/docs/reference/watcher/trigger/schedule/yearly.asciidoc index 8fce024bf9f4a..c33321ef5a7dc 100644 --- a/docs/reference/watcher/trigger/schedule/yearly.asciidoc +++ b/docs/reference/watcher/trigger/schedule/yearly.asciidoc @@ -88,3 +88,26 @@ January 20th, December 10th, and December 20th. } -------------------------------------------------- // NOTCONSOLE + +==== Configuring a yearly schedule with a different time zone +By default, the `yearly` schedule is evaluated in the UTC time zone. To use a +different time zone, you can specify the `timezone` parameter in the schedule. +For example, the following `yearly` schedule triggers at 3:30 PM and 8:30 PM +on June 4th in the `Antarctica/Troll` time zone: + +[source,js] +-------------------------------------------------- +{ + "trigger" : { + "schedule" : { + "timezone" : "Antarctica/Troll", + "yearly" : { + "in" : "june", + "on" : 4, + "at" : [ 15:30, 20:30 ] + } + } + } +} +-------------------------------------------------- +// NOTCONSOLE diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/Cron.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/Cron.java index b9d39aa665848..c94b90b6c0c23 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/Cron.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/Cron.java @@ -8,11 +8,15 @@ import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.common.time.DateFormatter; +import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.xcontent.ToXContentFragment; import org.elasticsearch.xcontent.XContentBuilder; import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.temporal.ChronoField; import java.util.Calendar; import java.util.Iterator; import java.util.Locale; @@ -232,6 +236,8 @@ public class Cron implements ToXContentFragment { private final String expression; + private ZoneId timeZone; + private transient TreeSet seconds; private transient TreeSet minutes; private transient TreeSet hours; @@ -246,7 +252,20 @@ public class Cron implements ToXContentFragment { private transient boolean nearestWeekday = false; private transient int lastdayOffset = 0; - public static final int MAX_YEAR = Calendar.getInstance(UTC, Locale.ROOT).get(Calendar.YEAR) + 100; + // Restricted to 50 years as the tzdb only has correct DST transition information for countries using a lunar calendar + // for the next ~60 years + public static final int MAX_YEAR = Calendar.getInstance(UTC, Locale.ROOT).get(Calendar.YEAR) + 50; + + public Cron(String expression, ZoneId timeZone) { + this.timeZone = timeZone; + assert expression != null : "cron expression cannot be null"; + this.expression = expression.toUpperCase(Locale.ROOT); + try { + buildExpression(this.expression); + } catch (Exception e) { + throw illegalArgument("invalid cron expression [{}]", e, expression); + } + } /** * Constructs a new CronExpression based on the specified @@ -259,13 +278,7 @@ public class Cron implements ToXContentFragment { * CronExpression */ public Cron(String expression) { - assert expression != null : "cron expression cannot be null"; - this.expression = expression.toUpperCase(Locale.ROOT); - try { - buildExpression(this.expression); - } catch (Exception e) { - throw illegalArgument("invalid cron expression [{}]", e, expression); - } + this(expression, UTC.toZoneId()); } /** @@ -275,7 +288,11 @@ public Cron(String expression) { * @param cron The existing cron expression to be copied */ public Cron(Cron cron) { - this(cron.expression); + this(cron.expression, cron.timeZone); + } + + public void setTimeZone(ZoneId timeZone) { + this.timeZone = timeZone; } /** @@ -286,31 +303,25 @@ public Cron(Cron cron) { * a time that is previous to the given time) * @return the next valid time (since the epoch) */ + @SuppressForbidden(reason = "In this case, the DST ambiguity of the atZone method is desired, understood and tested") public long getNextValidTimeAfter(final long time) { - // Computation is based on Gregorian year only. - Calendar cl = new java.util.GregorianCalendar(UTC, Locale.ROOT); - - // move ahead one second, since we're computing the time *after* the - // given time - final long afterTime = time + 1000; - // CronTrigger does not deal with milliseconds - cl.setTimeInMillis(afterTime); - cl.set(Calendar.MILLISECOND, 0); + LocalDateTime afterTimeLdt = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(time), timeZone).plusSeconds(1); + LocalDateTimeLegacyWrapper cl = new LocalDateTimeLegacyWrapper(afterTimeLdt.with(ChronoField.MILLI_OF_SECOND, 0)); boolean gotOne = false; // loop until we've computed the next time, or we've past the endTime while (gotOne == false) { - if (cl.get(Calendar.YEAR) > 2999) { // prevent endless loop... + if (cl.getYear() > 2999) { // prevent endless loop... return -1; } SortedSet st = null; int t = 0; - int sec = cl.get(Calendar.SECOND); - int min = cl.get(Calendar.MINUTE); + int sec = cl.getSecond(); + int min = cl.getMinute(); // get second................................................. st = seconds.tailSet(sec); @@ -319,12 +330,12 @@ public long getNextValidTimeAfter(final long time) { } else { sec = seconds.first(); min++; - cl.set(Calendar.MINUTE, min); + cl.setMinute(min); } - cl.set(Calendar.SECOND, sec); + cl.setSecond(sec); - min = cl.get(Calendar.MINUTE); - int hr = cl.get(Calendar.HOUR_OF_DAY); + min = cl.getMinute(); + int hr = cl.getHour(); t = -1; // get minute................................................. @@ -337,15 +348,15 @@ public long getNextValidTimeAfter(final long time) { hr++; } if (min != t) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, min); - setCalendarHour(cl, hr); + cl.setSecond(0); + cl.setMinute(min); + cl.setHour(hr); continue; } - cl.set(Calendar.MINUTE, min); + cl.setMinute(min); - hr = cl.get(Calendar.HOUR_OF_DAY); - int day = cl.get(Calendar.DAY_OF_MONTH); + hr = cl.getHour(); + int day = cl.getDayOfMonth(); t = -1; // get hour................................................... @@ -358,16 +369,16 @@ public long getNextValidTimeAfter(final long time) { day++; } if (hr != t) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.DAY_OF_MONTH, day); - setCalendarHour(cl, hr); + cl.setSecond(0); + cl.setMinute(0); + cl.setDayOfMonth(day); + cl.setHour(hr); continue; } - cl.set(Calendar.HOUR_OF_DAY, hr); + cl.setHour(hr); - day = cl.get(Calendar.DAY_OF_MONTH); - int mon = cl.get(Calendar.MONTH) + 1; + day = cl.getDayOfMonth(); + int mon = cl.getMonth() + 1; // '+ 1' because calendar is 0-based for this field, and we are // 1-based t = -1; @@ -381,32 +392,32 @@ public long getNextValidTimeAfter(final long time) { if (lastdayOfMonth) { if (nearestWeekday == false) { t = day; - day = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); + day = getLastDayOfMonth(mon, cl.getYear()); day -= lastdayOffset; if (t > day) { mon++; if (mon > 12) { mon = 1; tmon = 3333; // ensure test of mon != tmon further below fails - cl.add(Calendar.YEAR, 1); + cl.plusYears(1); } day = 1; } } else { t = day; - day = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); + day = getLastDayOfMonth(mon, cl.getYear()); day -= lastdayOffset; - Calendar tcal = Calendar.getInstance(UTC, Locale.ROOT); - tcal.set(Calendar.SECOND, 0); - tcal.set(Calendar.MINUTE, 0); - tcal.set(Calendar.HOUR_OF_DAY, 0); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - tcal.set(Calendar.YEAR, cl.get(Calendar.YEAR)); + LocalDateTimeLegacyWrapper tcal = new LocalDateTimeLegacyWrapper(LocalDateTime.now(timeZone)); + tcal.setSecond(0); + tcal.setMinute(0); + tcal.setHour(0); + tcal.setDayOfMonth(day); + tcal.setMonth(mon - 1); + tcal.setYear(cl.getYear()); - int ldom = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); - int dow = tcal.get(Calendar.DAY_OF_WEEK); + int ldom = getLastDayOfMonth(mon, cl.getYear()); + int dow = tcal.getDayOfWeek(); if (dow == Calendar.SATURDAY && day == 1) { day += 2; @@ -418,13 +429,12 @@ public long getNextValidTimeAfter(final long time) { day += 1; } - tcal.set(Calendar.SECOND, sec); - tcal.set(Calendar.MINUTE, min); - tcal.set(Calendar.HOUR_OF_DAY, hr); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - long nTime = tcal.getTimeInMillis(); - if (nTime < afterTime) { + tcal.setSecond(sec); + tcal.setMinute(min); + tcal.setHour(hr); + tcal.setDayOfMonth(day); + tcal.setMonth(mon - 1); + if (tcal.isBefore(afterTimeLdt)) { day = 1; mon++; } @@ -433,16 +443,16 @@ public long getNextValidTimeAfter(final long time) { t = day; day = daysOfMonth.first(); - Calendar tcal = Calendar.getInstance(UTC, Locale.ROOT); - tcal.set(Calendar.SECOND, 0); - tcal.set(Calendar.MINUTE, 0); - tcal.set(Calendar.HOUR_OF_DAY, 0); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - tcal.set(Calendar.YEAR, cl.get(Calendar.YEAR)); + LocalDateTimeLegacyWrapper tcal = new LocalDateTimeLegacyWrapper(LocalDateTime.now(timeZone)); + tcal.setSecond(0); + tcal.setMinute(0); + tcal.setHour(0); + tcal.setDayOfMonth(day); + tcal.setMonth(mon - 1); + tcal.setYear(cl.getYear()); - int ldom = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); - int dow = tcal.get(Calendar.DAY_OF_WEEK); + int ldom = getLastDayOfMonth(mon, cl.getYear()); + int dow = tcal.getDayOfWeek(); if (dow == Calendar.SATURDAY && day == 1) { day += 2; @@ -454,13 +464,12 @@ public long getNextValidTimeAfter(final long time) { day += 1; } - tcal.set(Calendar.SECOND, sec); - tcal.set(Calendar.MINUTE, min); - tcal.set(Calendar.HOUR_OF_DAY, hr); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - long nTime = tcal.getTimeInMillis(); - if (nTime < afterTime) { + tcal.setSecond(sec); + tcal.setMinute(min); + tcal.setHour(hr); + tcal.setDayOfMonth(day); + tcal.setMonth(mon - 1); + if (tcal.isAfter(afterTimeLdt)) { day = daysOfMonth.first(); mon++; } @@ -468,7 +477,7 @@ public long getNextValidTimeAfter(final long time) { t = day; day = st.first(); // make sure we don't over-run a short month, such as february - int lastDay = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); + int lastDay = getLastDayOfMonth(mon, cl.getYear()); if (day > lastDay) { day = daysOfMonth.first(); mon++; @@ -479,11 +488,11 @@ public long getNextValidTimeAfter(final long time) { } if (day != t || mon != tmon) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, day); - cl.set(Calendar.MONTH, mon - 1); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(day); + cl.setMonth(mon - 1); // '- 1' because calendar is 0-based for this field, and we // are 1-based continue; @@ -493,7 +502,7 @@ public long getNextValidTimeAfter(final long time) { // the month? int dow = daysOfWeek.first(); // desired // d-o-w - int cDow = cl.get(Calendar.DAY_OF_WEEK); // current d-o-w + int cDow = cl.getDayOfWeek(); // current d-o-w int daysToAdd = 0; if (cDow < dow) { daysToAdd = dow - cDow; @@ -502,15 +511,15 @@ public long getNextValidTimeAfter(final long time) { daysToAdd = dow + (7 - cDow); } - int lDay = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); + int lDay = getLastDayOfMonth(mon, cl.getYear()); if (day + daysToAdd > lDay) { // did we already miss the // last one? - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, 1); - cl.set(Calendar.MONTH, mon); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(1); + cl.setMonth(mon); // no '- 1' here because we are promoting the month continue; } @@ -523,11 +532,11 @@ public long getNextValidTimeAfter(final long time) { day += daysToAdd; if (daysToAdd > 0) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, day); - cl.set(Calendar.MONTH, mon - 1); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(day); + cl.setMonth(mon - 1); // '- 1' here because we are not promoting the month continue; } @@ -536,7 +545,7 @@ public long getNextValidTimeAfter(final long time) { // are we looking for the Nth XXX day in the month? int dow = daysOfWeek.first(); // desired // d-o-w - int cDow = cl.get(Calendar.DAY_OF_WEEK); // current d-o-w + int cDow = cl.getDayOfWeek(); // current d-o-w int daysToAdd = 0; if (cDow < dow) { daysToAdd = dow - cDow; @@ -557,25 +566,25 @@ public long getNextValidTimeAfter(final long time) { daysToAdd = (nthdayOfWeek - weekOfMonth) * 7; day += daysToAdd; - if (daysToAdd < 0 || day > getLastDayOfMonth(mon, cl.get(Calendar.YEAR))) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, 1); - cl.set(Calendar.MONTH, mon); + if (daysToAdd < 0 || day > getLastDayOfMonth(mon, cl.getYear())) { + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(1); + cl.setMonth(mon); // no '- 1' here because we are promoting the month continue; } else if (daysToAdd > 0 || dayShifted) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, day); - cl.set(Calendar.MONTH, mon - 1); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(day); + cl.setMonth(mon - 1); // '- 1' here because we are NOT promoting the month continue; } } else { - int cDow = cl.get(Calendar.DAY_OF_WEEK); // current d-o-w + int cDow = cl.getDayOfWeek(); // current d-o-w int dow = daysOfWeek.first(); // desired // d-o-w st = daysOfWeek.tailSet(cDow); @@ -591,23 +600,23 @@ public long getNextValidTimeAfter(final long time) { daysToAdd = dow + (7 - cDow); } - int lDay = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); + int lDay = getLastDayOfMonth(mon, cl.getYear()); if (day + daysToAdd > lDay) { // will we pass the end of // the month? - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, 1); - cl.set(Calendar.MONTH, mon); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(1); + cl.setMonth(mon); // no '- 1' here because we are promoting the month continue; } else if (daysToAdd > 0) { // are we swithing days? - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, day + daysToAdd); - cl.set(Calendar.MONTH, mon - 1); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(day + daysToAdd); + cl.setMonth(mon - 1); // '- 1' because calendar is 0-based for this field, // and we are 1-based continue; @@ -618,12 +627,12 @@ public long getNextValidTimeAfter(final long time) { // throw new UnsupportedOperationException( // "Support for specifying both a day-of-week AND a day-of-month parameter is not implemented."); } - cl.set(Calendar.DAY_OF_MONTH, day); + cl.setDayOfMonth(day); - mon = cl.get(Calendar.MONTH) + 1; + mon = cl.getMonth() + 1; // '+ 1' because calendar is 0-based for this field, and we are // 1-based - int year = cl.get(Calendar.YEAR); + int year = cl.getYear(); t = -1; // test for expressions that never generate a valid fire date, @@ -643,21 +652,21 @@ public long getNextValidTimeAfter(final long time) { year++; } if (mon != t) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, 1); - cl.set(Calendar.MONTH, mon - 1); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(1); + cl.setMonth(mon - 1); // '- 1' because calendar is 0-based for this field, and we are // 1-based - cl.set(Calendar.YEAR, year); + cl.setYear(year); continue; } - cl.set(Calendar.MONTH, mon - 1); + cl.setMonth(mon - 1); // '- 1' because calendar is 0-based for this field, and we are // 1-based - year = cl.get(Calendar.YEAR); + year = cl.getYear(); t = -1; // get year................................................... @@ -671,22 +680,24 @@ public long getNextValidTimeAfter(final long time) { } if (year != t) { - cl.set(Calendar.SECOND, 0); - cl.set(Calendar.MINUTE, 0); - cl.set(Calendar.HOUR_OF_DAY, 0); - cl.set(Calendar.DAY_OF_MONTH, 1); - cl.set(Calendar.MONTH, 0); + cl.setSecond(0); + cl.setMinute(0); + cl.setHour(0); + cl.setDayOfMonth(1); + cl.setMonth(0); // '- 1' because calendar is 0-based for this field, and we are // 1-based - cl.set(Calendar.YEAR, year); + cl.setYear(year); continue; } - cl.set(Calendar.YEAR, year); + cl.setYear(year); gotOne = true; } // while( done == false ) - return cl.getTimeInMillis(); + LocalDateTime nextRuntime = cl.getLocalDateTime(); + + return nextRuntime.atZone(timeZone).toInstant().toEpochMilli(); } public String expression() { @@ -735,7 +746,7 @@ public String getExpressionSummary() { @Override public int hashCode() { - return Objects.hash(expression); + return Objects.hash(expression, timeZone); } @Override @@ -747,7 +758,7 @@ public boolean equals(Object obj) { return false; } final Cron other = (Cron) obj; - return Objects.equals(this.expression, other.expression); + return Objects.equals(this.expression, other.expression) && Objects.equals(this.timeZone, other.timeZone); } /** @@ -757,7 +768,7 @@ public boolean equals(Object obj) { */ @Override public String toString() { - return expression; + return "Cron{" + "timeZone=" + timeZone + ", expression='" + expression + '\'' + '}'; } /** @@ -1430,7 +1441,7 @@ private static int getLastDayOfMonth(int monthNum, int year) { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - return builder.value(toString()); + return builder.value(expression); } private static class ValueSet { diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/LocalDateTimeLegacyWrapper.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/LocalDateTimeLegacyWrapper.java new file mode 100644 index 0000000000000..e540acc8042eb --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/scheduler/LocalDateTimeLegacyWrapper.java @@ -0,0 +1,130 @@ +/* + * 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.core.scheduler; + +import java.time.LocalDateTime; +import java.time.chrono.ChronoLocalDateTime; + +/** + * This class is designed to wrap the LocalDateTime class in order to make it behave, in terms of mutation, like a legacy Calendar class. + * This is to provide compatibility with the existing Cron next runtime calculation algorithm which relies on certain quirks of the Calendar + * such as days of the week being numbered starting on Sunday==1 and being able to set the current hour to 24 and have it roll over to + * midnight the next day. + */ +public class LocalDateTimeLegacyWrapper { + + private LocalDateTime ldt; + + public LocalDateTimeLegacyWrapper(LocalDateTime ldt) { + this.ldt = ldt; + } + + public int getYear() { + return ldt.getYear(); + } + + public int getDayOfMonth() { + return ldt.getDayOfMonth(); + } + + public int getHour() { + return ldt.getHour(); + } + + public int getMinute() { + return ldt.getMinute(); + } + + public int getSecond() { + return ldt.getSecond(); + } + + public int getDayOfWeek() { + return (ldt.getDayOfWeek().getValue() % 7) + 1; + } + + public int getMonth() { + return ldt.getMonthValue() - 1; + } + + public void setYear(int year) { + ldt = ldt.withYear(year); + } + + public void setDayOfMonth(int dayOfMonth) { + var lengthOfMonth = ldt.getMonth().length(ldt.toLocalDate().isLeapYear()); + if (dayOfMonth <= lengthOfMonth) { + ldt = ldt.withDayOfMonth(dayOfMonth); + } else { + var months = dayOfMonth / lengthOfMonth; + var day = dayOfMonth % lengthOfMonth; + ldt = ldt.plusMonths(months).withDayOfMonth(day); + } + } + + public void setMonth(int month) { + month++; // Months are 0-based in Calendar + if (month <= 12) { + ldt = ldt.withMonth(month); + } else { + var years = month / 12; + var monthOfYear = month % 12; + ldt = ldt.plusYears(years).withMonth(monthOfYear); + } + } + + public void setHour(int hour) { + if (hour < 24) { + ldt = ldt.withHour(hour); + } else { + var days = hour / 24; + var hourOfDay = hour % 24; + ldt = ldt.plusDays(days).withHour(hourOfDay); + } + } + + public void setMinute(int minute) { + if (minute < 60) { + ldt = ldt.withMinute(minute); + } else { + var hours = minute / 60; + var minuteOfHour = minute % 60; + ldt = ldt.plusHours(hours).withMinute(minuteOfHour); + } + } + + public void setSecond(int second) { + if (second < 60) { + ldt = ldt.withSecond(second); + } else { + var minutes = second / 60; + var secondOfMinute = second % 60; + ldt = ldt.plusMinutes(minutes).withSecond(secondOfMinute); + } + } + + public void plusYears(long years) { + ldt = ldt.plusYears(years); + } + + public void plusSeconds(long seconds) { + ldt = ldt.plusSeconds(seconds); + } + + public boolean isAfter(ChronoLocalDateTime other) { + return ldt.isAfter(other); + } + + public boolean isBefore(ChronoLocalDateTime other) { + return ldt.isBefore(other); + } + + public LocalDateTime getLocalDateTime() { + return ldt; + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/scheduler/CronTimezoneTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/scheduler/CronTimezoneTests.java new file mode 100644 index 0000000000000..1e469002457d8 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/scheduler/CronTimezoneTests.java @@ -0,0 +1,231 @@ +/* + * 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.core.scheduler; + +import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matchers; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.zone.ZoneOffsetTransition; +import java.time.zone.ZoneRules; + +import static java.time.Instant.ofEpochMilli; +import static java.util.TimeZone.getTimeZone; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; + +public class CronTimezoneTests extends ESTestCase { + + public void testForFixedOffsetCorrectlyCalculateNextRuntime() { + Cron cron = new Cron("0 0 2 * * ?", ZoneOffset.of("+1")); + long midnightUTC = Instant.parse("2020-01-01T00:00:00Z").toEpochMilli(); + long nextValidTimeAfter = cron.getNextValidTimeAfter(midnightUTC); + assertThat(Instant.ofEpochMilli(nextValidTimeAfter), equalTo(Instant.parse("2020-01-01T01:00:00Z"))); + } + + public void testForLondonFixedDSTTransitionCheckCorrectSchedule() { + ZoneId londonZone = getTimeZone("Europe/London").toZoneId(); + + Cron cron = new Cron("0 0 2 * * ?", londonZone); + ZoneRules londonZoneRules = londonZone.getRules(); + Instant springMidnight = Instant.parse("2020-03-01T00:00:00Z"); + long timeBeforeDST = springMidnight.toEpochMilli(); + + assertThat(cron.getNextValidTimeAfter(timeBeforeDST), equalTo(Instant.parse("2020-03-01T02:00:00Z").toEpochMilli())); + + ZoneOffsetTransition zoneOffsetTransition = londonZoneRules.nextTransition(springMidnight); + + Instant timeAfterDST = zoneOffsetTransition.getDateTimeBefore() + .plusDays(1) + .atZone(ZoneOffset.UTC) + .withHour(0) + .withMinute(0) + .toInstant(); + + assertThat(cron.getNextValidTimeAfter(timeAfterDST.toEpochMilli()), equalTo(Instant.parse("2020-03-30T01:00:00Z").toEpochMilli())); + } + + public void testRandomDSTTransitionCalculateNextTimeCorrectlyRelativeToUTC() { + ZoneId timeZone = generateRandomDSTZone(); + + logger.info("Testing for timezone {}", timeZone); + + ZoneOffsetTransition zoneOffsetTransition = timeZone.getRules().nextTransition(Instant.now()); + + ZonedDateTime midnightBefore = zoneOffsetTransition.getDateTimeBefore().atZone(timeZone).minusDays(2).withHour(0).withMinute(0); + ZonedDateTime midnightAfter = zoneOffsetTransition.getDateTimeAfter().atZone(timeZone).plusDays(2).withHour(0).withMinute(0); + + long epochBefore = midnightBefore.toInstant().toEpochMilli(); + long epochAfter = midnightAfter.toInstant().toEpochMilli(); + + Cron cron = new Cron("0 0 2 * * ?", timeZone); + + long nextScheduleBefore = cron.getNextValidTimeAfter(epochBefore); + long nextScheduleAfter = cron.getNextValidTimeAfter(epochAfter); + + assertThat(nextScheduleBefore - epochBefore, equalTo(2 * 60 * 60 * 1000L)); // 2 hours + assertThat(nextScheduleAfter - epochAfter, equalTo(2 * 60 * 60 * 1000L)); // 2 hours + + ZonedDateTime utcMidnightBefore = zoneOffsetTransition.getDateTimeBefore() + .atZone(ZoneOffset.UTC) + .minusDays(2) + .withHour(0) + .withMinute(0); + + ZonedDateTime utcMidnightAfter = zoneOffsetTransition.getDateTimeAfter() + .atZone(ZoneOffset.UTC) + .plusDays(2) + .withHour(0) + .withMinute(0); + + long utcEpochBefore = utcMidnightBefore.toInstant().toEpochMilli(); + long utcEpochAfter = utcMidnightAfter.toInstant().toEpochMilli(); + + long nextUtcScheduleBefore = cron.getNextValidTimeAfter(utcEpochBefore); + long nextUtcScheduleAfter = cron.getNextValidTimeAfter(utcEpochAfter); + + assertThat(nextUtcScheduleBefore - utcEpochBefore, not(equalTo(nextUtcScheduleAfter - utcEpochAfter))); + + } + + private ZoneId generateRandomDSTZone() { + ZoneId timeZone; + int i = 0; + boolean found; + do { + timeZone = randomZone(); + found = getTimeZone(timeZone).useDaylightTime(); + i++; + } while (found == false && i <= 500); // Infinite loop prevention + + if (found == false) { + fail("Could not find a timezone with DST"); + } + + logger.debug("Testing for timezone {} after {} iterations", timeZone, i); + return timeZone; + } + + public void testForGMTGapTransitionTriggerTimeIsAsIfTransitionHasntHappenedYet() { + ZoneId london = ZoneId.of("Europe/London"); + Cron cron = new Cron("0 30 1 * * ?", london); // Every day at 1:30 + + Instant beforeTransition = Instant.parse("2025-03-30T00:00:00Z"); + long beforeTransitionEpoch = beforeTransition.toEpochMilli(); + + long nextValidTimeAfter = cron.getNextValidTimeAfter(beforeTransitionEpoch); + assertThat(ofEpochMilli(nextValidTimeAfter), equalTo(Instant.parse("2025-03-30T01:30:00Z"))); + } + + public void testForGMTOverlapTransitionTriggerSkipSecondExecution() { + ZoneId london = ZoneId.of("Europe/London"); + Cron cron = new Cron("0 30 1 * * ?", london); // Every day at 01:30 + + Instant beforeTransition = Instant.parse("2024-10-27T00:00:00Z"); + long beforeTransitionEpoch = beforeTransition.toEpochMilli(); + + long firstValidTimeAfter = cron.getNextValidTimeAfter(beforeTransitionEpoch); + assertThat(ofEpochMilli(firstValidTimeAfter), equalTo(Instant.parse("2024-10-27T00:30:00Z"))); + + long nextValidTimeAfter = cron.getNextValidTimeAfter(firstValidTimeAfter); + assertThat(ofEpochMilli(nextValidTimeAfter), equalTo(Instant.parse("2024-10-28T01:30:00Z"))); + } + + // This test checks that once per minute crons will be unaffected by a DST transition + public void testDiscontinuityResolutionForNonHourCronInRandomTimezone() { + var timezone = generateRandomDSTZone(); + + var cron = new Cron("0 * * * * ?", timezone); // Once per minute + + Instant referenceTime = randomInstantBetween(Instant.now(), Instant.now().plus(1826, ChronoUnit.DAYS)); // ~5 years + ZoneOffsetTransition transition1 = timezone.getRules().nextTransition(referenceTime); + + // Currently there are no known timezones with DST transitions shorter than 10 minutes but this guards against future changes + if (Math.abs(transition1.getOffsetBefore().getTotalSeconds() - transition1.getOffsetAfter().getTotalSeconds()) < 600) { + fail("Transition is not long enough to test"); + } + + testNonHourCronTransition(transition1, cron); + + var transition2 = timezone.getRules().nextTransition(transition1.getInstant().plus(1, ChronoUnit.DAYS)); + + testNonHourCronTransition(transition2, cron); + + } + + private static void testNonHourCronTransition(ZoneOffsetTransition transition, Cron cron) { + Instant insideTransition; + if (transition.isGap()) { + insideTransition = transition.getInstant().plus(10, ChronoUnit.MINUTES); + Instant nextTrigger = ofEpochMilli(cron.getNextValidTimeAfter(insideTransition.toEpochMilli())); + assertThat(nextTrigger, equalTo(insideTransition.plus(1, ChronoUnit.MINUTES))); + } else { + insideTransition = transition.getInstant().minus(10, ChronoUnit.MINUTES); + Instant nextTrigger = ofEpochMilli(cron.getNextValidTimeAfter(insideTransition.toEpochMilli())); + assertThat(nextTrigger, equalTo(insideTransition.plus(1, ChronoUnit.MINUTES))); + + insideTransition = insideTransition.plus(transition.getDuration()); + nextTrigger = ofEpochMilli(cron.getNextValidTimeAfter(insideTransition.toEpochMilli())); + assertThat(nextTrigger, equalTo(insideTransition.plus(1, ChronoUnit.MINUTES))); + } + } + + // This test checks that once per day crons will behave correctly during a DST transition + public void testDiscontinuityResolutionForCronInRandomTimezone() { + var timezone = generateRandomDSTZone(); + + Instant referenceTime = randomInstantBetween(Instant.now(), Instant.now().plus(1826, ChronoUnit.DAYS)); // ~5 years + ZoneOffsetTransition transition1 = timezone.getRules().nextTransition(referenceTime); + + // Currently there are no known timezones with DST transitions shorter than 10 minutes but this guards against future changes + if (Math.abs(transition1.getOffsetBefore().getTotalSeconds() - transition1.getOffsetAfter().getTotalSeconds()) < 600) { + fail("Transition is not long enough to test"); + } + + testHourCronTransition(transition1, timezone); + + var transition2 = timezone.getRules().nextTransition(transition1.getInstant().plus(1, ChronoUnit.DAYS)); + + testHourCronTransition(transition2, timezone); + } + + private static void testHourCronTransition(ZoneOffsetTransition transition, ZoneId timezone) { + if (transition.isGap()) { + LocalDateTime targetTime = transition.getDateTimeBefore().plusMinutes(10); + + var cron = new Cron("0 " + targetTime.getMinute() + " " + targetTime.getHour() + " * * ?", timezone); + + long nextTrigger = cron.getNextValidTimeAfter(transition.getInstant().minus(10, ChronoUnit.MINUTES).toEpochMilli()); + + assertThat(ofEpochMilli(nextTrigger), equalTo(transition.getInstant().plus(10, ChronoUnit.MINUTES))); + } else { + LocalDateTime targetTime = transition.getDateTimeAfter().plusMinutes(10); + var cron = new Cron("0 " + targetTime.getMinute() + " " + targetTime.getHour() + " * * ?", timezone); + + long transitionLength = Math.abs(transition.getDuration().toSeconds()); + long firstTrigger = cron.getNextValidTimeAfter( + transition.getInstant().minusSeconds(transitionLength).minus(10, ChronoUnit.MINUTES).toEpochMilli() + ); + + assertThat( + ofEpochMilli(firstTrigger), + equalTo(transition.getInstant().minusSeconds(transitionLength).plus(10, ChronoUnit.MINUTES)) + ); + + var repeatTrigger = cron.getNextValidTimeAfter(firstTrigger + (1000 * 60L)); // 1 minute + + assertThat(repeatTrigger - firstTrigger, Matchers.greaterThan(24 * 60 * 60 * 1000L)); // 24 hours + } + } + +} diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorCustomSchedule.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorCustomSchedule.java index 7badf6926c574..387224408a14b 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorCustomSchedule.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorCustomSchedule.java @@ -140,7 +140,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws public void writeTo(StreamOutput out) throws IOException { out.writeWriteable(configurationOverrides); out.writeBoolean(enabled); - out.writeString(interval.toString()); + out.writeString(interval.expression()); out.writeOptionalInstant(lastSynced); out.writeString(name); } diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorScheduling.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorScheduling.java index 3c08a5ac1e218..008cbca0cd5ea 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorScheduling.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/ConnectorScheduling.java @@ -222,7 +222,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws @Override public void writeTo(StreamOutput out) throws IOException { out.writeBoolean(enabled); - out.writeString(interval.toString()); + out.writeString(interval.expression()); } @Override diff --git a/x-pack/plugin/slm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecyclePolicyTests.java b/x-pack/plugin/slm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecyclePolicyTests.java index b7674a2d60bff..0ab3e99e1efc9 100644 --- a/x-pack/plugin/slm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecyclePolicyTests.java +++ b/x-pack/plugin/slm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecyclePolicyTests.java @@ -64,12 +64,12 @@ public void testNextExecutionTimeSchedule() { SnapshotLifecyclePolicy p = new SnapshotLifecyclePolicy( "id", "name", - "0 1 2 3 4 ? 2099", + "0 1 2 3 4 ? 2049", "repo", Collections.emptyMap(), SnapshotRetentionConfiguration.EMPTY ); - assertThat(p.calculateNextExecution(-1, Clock.systemUTC()), equalTo(4078864860000L)); + assertThat(p.calculateNextExecution(-1, Clock.systemUTC()), equalTo(2501028060000L)); } public void testNextExecutionTimeInterval() { diff --git a/x-pack/plugin/watcher/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/watcher/put_watch/11_timezoned_schedules.yml b/x-pack/plugin/watcher/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/watcher/put_watch/11_timezoned_schedules.yml new file mode 100644 index 0000000000000..0371443367603 --- /dev/null +++ b/x-pack/plugin/watcher/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/watcher/put_watch/11_timezoned_schedules.yml @@ -0,0 +1,121 @@ +--- +setup: + - do: + cluster.health: + wait_for_status: yellow + +--- +"Test put watch api with timezone": + - do: + watcher.put_watch: + id: "my_watch" + body: > + { + "trigger": { + "schedule": { + "timezone": "America/Los_Angeles", + "hourly": { + "minute": [ 0, 5 ] + } + } + }, + "input": { + "simple": { + "payload": { + "send": "yes" + } + } + }, + "condition": { + "always": {} + }, + "actions": { + "test_index": { + "index": { + "index": "test" + } + } + } + } + - match: { _id: "my_watch" } + - do: + watcher.get_watch: + id: "my_watch" + - match: { watch.trigger.schedule.timezone: "America/Los_Angeles" } + +--- +"Test put watch api without timezone": + - do: + watcher.put_watch: + id: "my_watch" + body: > + { + "trigger": { + "schedule": { + "hourly": { + "minute": [ 0, 5 ] + } + } + }, + "input": { + "simple": { + "payload": { + "send": "yes" + } + } + }, + "condition": { + "always": {} + }, + "actions": { + "test_index": { + "index": { + "index": "test" + } + } + } + } + - match: { _id: "my_watch" } + - do: + watcher.get_watch: + id: "my_watch" + - is_false: watch.trigger.schedule.timezone + +--- +"Reject put watch with invalid timezone": + - do: + watcher.put_watch: + id: "my_watch" + body: > + { + "trigger": { + "schedule": { + "timezone": "Pangea/Tethys", + "hourly": { + "minute": [ 0, 5 ] + } + } + }, + "input": { + "simple": { + "payload": { + "send": "yes" + } + } + }, + "condition": { + "always": {} + }, + "actions": { + "test_index": { + "index": { + "index": "test" + } + } + } + } + catch: bad_request + - match: { error.type: "parse_exception" } + - match: { error.reason: "could not parse schedule. invalid timezone [Pangea/Tethys]" } + - match: { error.caused_by.type: "zone_rules_exception" } + - match: { error.caused_by.reason: "Unknown time-zone ID: Pangea/Tethys" } diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/CronnableSchedule.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/CronnableSchedule.java index 63e9dae88de41..0db99af9b3fc2 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/CronnableSchedule.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/CronnableSchedule.java @@ -8,6 +8,7 @@ import org.elasticsearch.xpack.core.scheduler.Cron; +import java.time.ZoneId; import java.util.Arrays; import java.util.Comparator; import java.util.Objects; @@ -17,6 +18,7 @@ public abstract class CronnableSchedule implements Schedule { private static final Comparator CRON_COMPARATOR = Comparator.comparing(Cron::expression); protected final Cron[] crons; + private ZoneId timeZone; CronnableSchedule(String... expressions) { this(crons(expressions)); @@ -28,6 +30,17 @@ private CronnableSchedule(Cron... crons) { Arrays.sort(crons, CRON_COMPARATOR); } + protected void setTimeZone(ZoneId timeZone) { + this.timeZone = timeZone; + for (Cron cron : crons) { + cron.setTimeZone(timeZone); + } + } + + public ZoneId getTimeZone() { + return timeZone; + } + @Override public long nextScheduledTimeAfter(long startTime, long time) { assert time >= startTime; @@ -45,21 +58,22 @@ public Cron[] crons() { return crons; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CronnableSchedule that = (CronnableSchedule) o; + return Objects.deepEquals(crons, that.crons) && Objects.equals(timeZone, that.timeZone); + } + @Override public int hashCode() { - return Objects.hash((Object[]) crons); + return Objects.hash(Arrays.hashCode(crons), timeZone); } @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - final CronnableSchedule other = (CronnableSchedule) obj; - return Objects.deepEquals(this.crons, other.crons); + public String toString() { + return "CronnableSchedule{" + "crons=" + Arrays.toString(crons) + ", timeZone=" + timeZone + '}'; } static Cron[] crons(String... expressions) { diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistry.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistry.java index 31cf46f8abaac..5d2259db71f77 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistry.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistry.java @@ -8,8 +8,11 @@ import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xpack.watcher.trigger.schedule.support.TimezoneUtils; import java.io.IOException; +import java.time.DateTimeException; +import java.time.ZoneId; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -29,9 +32,15 @@ public Schedule parse(String context, XContentParser parser) throws IOException String type = null; XContentParser.Token token; Schedule schedule = null; + ZoneId timeZone = null; // Default to UTC while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { if (token == XContentParser.Token.FIELD_NAME) { - type = parser.currentName(); + var fieldName = parser.currentName(); + if (fieldName.equals(ScheduleTrigger.TIMEZONE_FIELD)) { + timeZone = parseTimezone(parser); + } else { + type = parser.currentName(); + } } else if (type != null) { schedule = parse(context, type, parser); } else { @@ -44,9 +53,38 @@ public Schedule parse(String context, XContentParser parser) throws IOException if (schedule == null) { throw new ElasticsearchParseException("could not parse schedule. expected a schedule type field, but no fields were found"); } + + if (timeZone != null && schedule instanceof CronnableSchedule cronnableSchedule) { + cronnableSchedule.setTimeZone(timeZone); + } else if (timeZone != null) { + throw new ElasticsearchParseException( + "could not parse schedule. Timezone is not supported for schedule type [{}]", + schedule.type() + ); + } + return schedule; } + private static ZoneId parseTimezone(XContentParser parser) throws IOException { + ZoneId timeZone; + XContentParser.Token token = parser.nextToken(); + if (token == XContentParser.Token.VALUE_STRING) { + String text = parser.text(); + try { + timeZone = TimezoneUtils.parse(text); + } catch (DateTimeException e) { + throw new ElasticsearchParseException("could not parse schedule. invalid timezone [{}]", e, text); + } + } else { + throw new ElasticsearchParseException( + "could not parse schedule. expected a string value for timezone, but found [{}] instead", + token + ); + } + return timeZone; + } + public Schedule parse(String context, String type, XContentParser parser) throws IOException { Schedule.Parser scheduleParser = parsers.get(type); if (scheduleParser == null) { diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleTrigger.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleTrigger.java index 4a67841e6c88e..cc6ec8f5aaa57 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleTrigger.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleTrigger.java @@ -14,6 +14,7 @@ public class ScheduleTrigger implements Trigger { public static final String TYPE = "schedule"; + public static final String TIMEZONE_FIELD = "timezone"; private final Schedule schedule; @@ -49,7 +50,13 @@ public int hashCode() { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - return builder.startObject().field(schedule.type(), schedule, params).endObject(); + builder.startObject(); + if (schedule instanceof CronnableSchedule cronnableSchedule && cronnableSchedule.getTimeZone() != null) { + builder.field(TIMEZONE_FIELD, cronnableSchedule.getTimeZone().getId()); + } + + builder.field(schedule.type(), schedule, params); + return builder.endObject(); } public static Builder builder(Schedule schedule) { diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtils.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtils.java new file mode 100644 index 0000000000000..c77fdda803bec --- /dev/null +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtils.java @@ -0,0 +1,55 @@ +/* + * 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.watcher.trigger.schedule.support; + +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.Locale; +import java.util.Map; + +import static java.util.stream.Collectors.toMap; + +/** + * Utility class for dealing with Timezone related operations. + */ +public class TimezoneUtils { + + private static final Map caseInsensitiveTZLookup; + + static { + caseInsensitiveTZLookup = ZoneId.getAvailableZoneIds() + .stream() + .collect(toMap(zoneId -> zoneId.toLowerCase(Locale.ROOT), ZoneId::of)); + } + + /** + * Parses a timezone string into a {@link ZoneId} object. The timezone string can be a valid timezone ID, or a + * timezone offset string and is case-insensitive. + * + * @param timezoneString The timezone string to parse + * @return The parsed {@link ZoneId} object + * @throws DateTimeException If the timezone string is not a valid timezone ID or offset + */ + public static ZoneId parse(String timezoneString) throws DateTimeException { + try { + return ZoneId.of(timezoneString); + } catch (DateTimeException e) { + ZoneId timeZone = caseInsensitiveTZLookup.get(timezoneString.toLowerCase(Locale.ROOT)); + if (timeZone != null) { + return timeZone; + } + try { + return ZoneId.of(timezoneString.toUpperCase(Locale.ROOT)); + } catch (DateTimeException ignored) { + // ignore + } + throw e; + } + } + +} diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistryTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistryTests.java index 7fc4739c342f1..aa39701d207c3 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistryTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/ScheduleRegistryTests.java @@ -12,6 +12,7 @@ import org.elasticsearch.xcontent.json.JsonXContent; import org.junit.Before; +import java.time.ZoneId; import java.util.HashSet; import java.util.Set; @@ -49,15 +50,23 @@ public void testParserInterval() throws Exception { } public void testParseCron() throws Exception { - Object cron = randomBoolean() ? Schedules.cron("* 0/5 * * * ?") : Schedules.cron("* 0/2 * * * ?", "* 0/3 * * * ?", "* 0/5 * * * ?"); - XContentBuilder builder = jsonBuilder().startObject().field(CronSchedule.TYPE, cron).endObject(); + var cron = randomBoolean() ? Schedules.cron("* 0/5 * * * ?") : Schedules.cron("* 0/2 * * * ?", "* 0/3 * * * ?", "* 0/5 * * * ?"); + ZoneId timeZone = null; + XContentBuilder builder = jsonBuilder().startObject().field(CronSchedule.TYPE, cron); + if (randomBoolean()) { + timeZone = randomTimeZone().toZoneId(); + cron.setTimeZone(timeZone); + builder.field(ScheduleTrigger.TIMEZONE_FIELD, timeZone.getId()); + } + builder.endObject(); BytesReference bytes = BytesReference.bytes(builder); XContentParser parser = createParser(JsonXContent.jsonXContent, bytes); parser.nextToken(); - Schedule schedule = registry.parse("ctx", parser); + CronnableSchedule schedule = (CronnableSchedule) registry.parse("ctx", parser); assertThat(schedule, notNullValue()); assertThat(schedule, instanceOf(CronSchedule.class)); assertThat(schedule, is(cron)); + assertThat(schedule.getTimeZone(), equalTo(timeZone)); } public void testParseHourly() throws Exception { diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtilsTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtilsTests.java new file mode 100644 index 0000000000000..aa797ec610eca --- /dev/null +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/trigger/schedule/support/TimezoneUtilsTests.java @@ -0,0 +1,40 @@ +/* + * 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.watcher.trigger.schedule.support; + +import org.elasticsearch.test.ESTestCase; + +import java.time.ZoneId; +import java.util.Locale; + +import static org.hamcrest.Matchers.equalTo; + +public class TimezoneUtilsTests extends ESTestCase { + + public void testExpectedFormatParsing() { + assertThat(TimezoneUtils.parse("Europe/London").getId(), equalTo("Europe/London")); + assertThat(TimezoneUtils.parse("+1").getId(), equalTo("+01:00")); + assertThat(TimezoneUtils.parse("GMT+01:00").getId(), equalTo("GMT+01:00")); + } + + public void testParsingIsCaseInsensitive() { + ZoneId timeZone = randomTimeZone().toZoneId(); + assertThat(TimezoneUtils.parse(timeZone.getId()), equalTo(timeZone)); + assertThat(TimezoneUtils.parse(timeZone.getId().toLowerCase(Locale.ROOT)), equalTo(timeZone)); + assertThat(TimezoneUtils.parse(timeZone.getId().toUpperCase(Locale.ROOT)), equalTo(timeZone)); + } + + public void testParsingOffsets() { + ZoneId timeZone = ZoneId.of("GMT+01:00"); + assertThat(TimezoneUtils.parse("GMT+01:00"), equalTo(timeZone)); + assertThat(TimezoneUtils.parse("gmt+01:00"), equalTo(timeZone)); + assertThat(TimezoneUtils.parse("GMT+1"), equalTo(timeZone)); + + assertThat(TimezoneUtils.parse("+1"), equalTo(ZoneId.of("+01:00"))); + } +} From 97296598c3e8df2af53c2d49d8de97d16ef1dc56 Mon Sep 17 00:00:00 2001 From: Rene Groeschke Date: Mon, 25 Nov 2024 11:28:15 +0100 Subject: [PATCH 007/129] [Build] Tweak BWC tasks caching (#117423) * do not track certain env vars for LoggedExec * Fix some more tasks on build cacheability * Some more cleanup on task inputs * Mark more tasks as cacheable --- .../conventions/precommit/PomValidationTask.java | 3 +++ .../gradle/internal/BwcSetupExtension.java | 6 +++--- .../InternalDistributionBwcSetupPlugin.java | 3 ++- .../precommit/CheckstylePrecommitPlugin.java | 12 +++++++++--- .../internal/precommit/FilePermissionsTask.java | 3 +++ .../internal/test/rest/CopyRestTestsTask.java | 3 +++ .../java/org/elasticsearch/gradle/LoggedExec.java | 15 +++++++++++++-- .../plugin/GeneratePluginPropertiesTask.java | 5 +++++ 8 files changed, 41 insertions(+), 9 deletions(-) diff --git a/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/PomValidationTask.java b/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/PomValidationTask.java index 9d06e632ec928..89bab313a0069 100644 --- a/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/PomValidationTask.java +++ b/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/PomValidationTask.java @@ -16,6 +16,8 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.model.ObjectFactory; import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.TaskAction; import java.io.FileReader; @@ -37,6 +39,7 @@ public PomValidationTask(ObjectFactory objects) { } @InputFile + @PathSensitive(PathSensitivity.RELATIVE) public RegularFileProperty getPomFile() { return pomFile; } diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/BwcSetupExtension.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/BwcSetupExtension.java index d7bf839817e12..5992a40275b46 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/BwcSetupExtension.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/BwcSetupExtension.java @@ -115,9 +115,9 @@ private static TaskProvider createRunBwcGradleTask( if (OS.current() == OS.WINDOWS) { loggedExec.getExecutable().set("cmd"); - loggedExec.args("/C", "call", new File(checkoutDir.get(), "gradlew").toString()); + loggedExec.args("/C", "call", "gradlew"); } else { - loggedExec.getExecutable().set(new File(checkoutDir.get(), "gradlew").toString()); + loggedExec.getExecutable().set("./gradlew"); } if (useUniqueUserHome) { @@ -177,7 +177,7 @@ private static String readFromFile(File file) { } } - public static abstract class JavaHomeValueSource implements ValueSource { + public abstract static class JavaHomeValueSource implements ValueSource { private String minimumCompilerVersionPath(Version bwcVersion) { return (bwcVersion.onOrAfter(BUILD_TOOL_MINIMUM_VERSION)) diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionBwcSetupPlugin.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionBwcSetupPlugin.java index 80fd6db59cf9f..c17127f9bbfcf 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionBwcSetupPlugin.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionBwcSetupPlugin.java @@ -23,6 +23,7 @@ import org.gradle.api.provider.Provider; import org.gradle.api.provider.ProviderFactory; import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.TaskProvider; import org.gradle.jvm.toolchain.JavaToolchainService; import org.gradle.language.base.plugins.LifecycleBasePlugin; @@ -322,7 +323,7 @@ static void createBuildBwcTask( File expectedOutputFile = useNativeExpanded ? new File(projectArtifact.expandedDistDir, "elasticsearch-" + bwcVersion.get() + "-SNAPSHOT") : projectArtifact.distFile; - c.getInputs().file(new File(project.getBuildDir(), "refspec")); + c.getInputs().file(new File(project.getBuildDir(), "refspec")).withPathSensitivity(PathSensitivity.RELATIVE); if (useNativeExpanded) { c.getOutputs().dir(expectedOutputFile); } else { diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/CheckstylePrecommitPlugin.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/CheckstylePrecommitPlugin.java index 81ff081ffa82b..dbbe35905d208 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/CheckstylePrecommitPlugin.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/CheckstylePrecommitPlugin.java @@ -19,6 +19,7 @@ import org.gradle.api.plugins.quality.Checkstyle; import org.gradle.api.plugins.quality.CheckstyleExtension; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.TaskProvider; @@ -42,18 +43,23 @@ public TaskProvider createTask(Project project) { File checkstyleSuppressions = new File(checkstyleDir, "checkstyle_suppressions.xml"); File checkstyleConf = new File(checkstyleDir, "checkstyle.xml"); TaskProvider copyCheckstyleConf = project.getTasks().register("copyCheckstyleConf"); - // configure inputs and outputs so up to date works properly copyCheckstyleConf.configure(t -> t.getOutputs().files(checkstyleSuppressions, checkstyleConf)); if ("jar".equals(checkstyleConfUrl.getProtocol())) { try { JarURLConnection jarURLConnection = (JarURLConnection) checkstyleConfUrl.openConnection(); - copyCheckstyleConf.configure(t -> t.getInputs().file(jarURLConnection.getJarFileURL())); + copyCheckstyleConf.configure( + t -> t.getInputs().file(jarURLConnection.getJarFileURL()).withPathSensitivity(PathSensitivity.RELATIVE) + ); } catch (IOException e) { throw new UncheckedIOException(e); } } else if ("file".equals(checkstyleConfUrl.getProtocol())) { - copyCheckstyleConf.configure(t -> t.getInputs().files(checkstyleConfUrl.getFile(), checkstyleSuppressionsUrl.getFile())); + copyCheckstyleConf.configure( + t -> t.getInputs() + .files(checkstyleConfUrl.getFile(), checkstyleSuppressionsUrl.getFile()) + .withPathSensitivity(PathSensitivity.RELATIVE) + ); } // Explicitly using an Action interface as java lambdas diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/FilePermissionsTask.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/FilePermissionsTask.java index a198034c3c09b..479b6f431b867 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/FilePermissionsTask.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/precommit/FilePermissionsTask.java @@ -19,6 +19,8 @@ import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.SkipWhenEmpty; import org.gradle.api.tasks.StopExecutionException; import org.gradle.api.tasks.TaskAction; @@ -79,6 +81,7 @@ private static boolean isExecutableFile(File file) { @InputFiles @IgnoreEmptyDirectories @SkipWhenEmpty + @PathSensitive(PathSensitivity.RELATIVE) public FileCollection getFiles() { return getSources().get() .stream() diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/rest/CopyRestTestsTask.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/rest/CopyRestTestsTask.java index 02309bb9c1811..6890cfb652952 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/rest/CopyRestTestsTask.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/rest/CopyRestTestsTask.java @@ -24,6 +24,8 @@ import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.SkipWhenEmpty; import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.util.PatternFilterable; @@ -106,6 +108,7 @@ public Map getSubstitutions() { @SkipWhenEmpty @IgnoreEmptyDirectories @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) public FileTree getInputDir() { FileTree coreFileTree = null; FileTree xpackFileTree = null; diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/LoggedExec.java b/build-tools/src/main/java/org/elasticsearch/gradle/LoggedExec.java index 505e9a5b114d1..28018b4c50abe 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/LoggedExec.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/LoggedExec.java @@ -20,6 +20,7 @@ import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; import org.gradle.api.provider.ProviderFactory; +import org.gradle.api.tasks.CacheableTask; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; @@ -53,6 +54,7 @@ * Exec task implementation. */ @SuppressWarnings("unchecked") +@CacheableTask public abstract class LoggedExec extends DefaultTask implements FileSystemOperationsAware { private static final Logger LOGGER = Logging.getLogger(LoggedExec.class); @@ -87,6 +89,14 @@ public abstract class LoggedExec extends DefaultTask implements FileSystemOperat abstract public Property getCaptureOutput(); @Input + public Provider getWorkingDirPath() { + return getWorkingDir().map(file -> { + String relativeWorkingDir = projectLayout.getProjectDirectory().getAsFile().toPath().relativize(file.toPath()).toString(); + return relativeWorkingDir; + }); + } + + @Internal abstract public Property getWorkingDir(); @Internal @@ -117,9 +127,10 @@ public LoggedExec( * can be reused across different build invocations. * */ private void setupDefaultEnvironment(ProviderFactory providerFactory) { - getEnvironment().putAll(providerFactory.environmentVariablesPrefixedBy("BUILDKITE")); getEnvironment().putAll(providerFactory.environmentVariablesPrefixedBy("GRADLE_BUILD_CACHE")); - getEnvironment().putAll(providerFactory.environmentVariablesPrefixedBy("VAULT")); + + getNonTrackedEnvironment().putAll(providerFactory.environmentVariablesPrefixedBy("BUILDKITE")); + getNonTrackedEnvironment().putAll(providerFactory.environmentVariablesPrefixedBy("VAULT")); Provider javaToolchainHome = providerFactory.environmentVariable("JAVA_TOOLCHAIN_HOME"); if (javaToolchainHome.isPresent()) { getEnvironment().put("JAVA_TOOLCHAIN_HOME", javaToolchainHome); diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GeneratePluginPropertiesTask.java b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GeneratePluginPropertiesTask.java index e144122f97770..6cf01814a45ef 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GeneratePluginPropertiesTask.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GeneratePluginPropertiesTask.java @@ -19,10 +19,13 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.tasks.CacheableTask; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.TaskAction; import org.objectweb.asm.ClassReader; import org.objectweb.asm.tree.ClassNode; @@ -39,6 +42,7 @@ import javax.inject.Inject; +@CacheableTask public abstract class GeneratePluginPropertiesTask extends DefaultTask { public static final String PROPERTIES_FILENAME = "plugin-descriptor.properties"; @@ -82,6 +86,7 @@ public GeneratePluginPropertiesTask(ProjectLayout projectLayout) { public abstract Property getIsLicensed(); @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) public abstract ConfigurableFileCollection getModuleInfoFile(); @OutputFile From 32aaacbd7b383188f2b5eb64fce69a09d11bfc94 Mon Sep 17 00:00:00 2001 From: Craig Taverner Date: Mon, 25 Nov 2024 12:09:30 +0100 Subject: [PATCH 008/129] LOOKUP JOIN using field-caps for field mapping (#117246) * LOOKUP JOIN using field-caps for field mapping Removes the hard-coded hack for languages_lookup, and instead does a field-caps check for the real join index. * Update docs/changelog/117246.yaml * Some code review comments --- docs/changelog/117246.yaml | 5 + .../xpack/esql/CsvTestsDataLoader.java | 9 +- .../resources/languages_lookup-settings.json | 5 + .../xpack/esql/analysis/Analyzer.java | 37 ++--- .../xpack/esql/analysis/AnalyzerContext.java | 14 +- .../xpack/esql/session/EsqlSession.java | 144 ++++++++++++------ 6 files changed, 135 insertions(+), 79 deletions(-) create mode 100644 docs/changelog/117246.yaml create mode 100644 x-pack/plugin/esql/qa/testFixtures/src/main/resources/languages_lookup-settings.json diff --git a/docs/changelog/117246.yaml b/docs/changelog/117246.yaml new file mode 100644 index 0000000000000..29c4464855967 --- /dev/null +++ b/docs/changelog/117246.yaml @@ -0,0 +1,5 @@ +pr: 117246 +summary: LOOKUP JOIN using field-caps for field mapping +area: ES|QL +type: enhancement +issues: [] 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 0d6659ad37a27..ffbac2829ea4a 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 @@ -56,6 +56,8 @@ public class CsvTestsDataLoader { private static final TestsDataset APPS = new TestsDataset("apps"); private static final TestsDataset APPS_SHORT = APPS.withIndex("apps_short").withTypeMapping(Map.of("id", "short")); private static final TestsDataset LANGUAGES = new TestsDataset("languages"); + private static final TestsDataset LANGUAGES_LOOKUP = LANGUAGES.withIndex("languages_lookup") + .withSetting("languages_lookup-settings.json"); 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"); @@ -93,14 +95,13 @@ public class CsvTestsDataLoader { private static final TestsDataset BOOKS = new TestsDataset("books"); private static final TestsDataset SEMANTIC_TEXT = new TestsDataset("semantic_text").withInferenceEndpoint(true); - private static final String LOOKUP_INDEX_SUFFIX = "_lookup"; - public static final Map CSV_DATASET_MAP = Map.ofEntries( Map.entry(EMPLOYEES.indexName, EMPLOYEES), Map.entry(HOSTS.indexName, HOSTS), Map.entry(APPS.indexName, APPS), Map.entry(APPS_SHORT.indexName, APPS_SHORT), Map.entry(LANGUAGES.indexName, LANGUAGES), + Map.entry(LANGUAGES_LOOKUP.indexName, LANGUAGES_LOOKUP), Map.entry(UL_LOGS.indexName, UL_LOGS), Map.entry(SAMPLE_DATA.indexName, SAMPLE_DATA), Map.entry(ALERTS.indexName, ALERTS), @@ -130,9 +131,7 @@ public class CsvTestsDataLoader { Map.entry(DISTANCES.indexName, DISTANCES), Map.entry(ADDRESSES.indexName, ADDRESSES), Map.entry(BOOKS.indexName, BOOKS), - Map.entry(SEMANTIC_TEXT.indexName, SEMANTIC_TEXT), - // JOIN LOOKUP alias - Map.entry(LANGUAGES.indexName + LOOKUP_INDEX_SUFFIX, LANGUAGES.withIndex(LANGUAGES.indexName + LOOKUP_INDEX_SUFFIX)) + Map.entry(SEMANTIC_TEXT.indexName, SEMANTIC_TEXT) ); private static final EnrichConfig LANGUAGES_ENRICH = new EnrichConfig("languages_policy", "enrich-policy-languages.json"); diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/languages_lookup-settings.json b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/languages_lookup-settings.json new file mode 100644 index 0000000000000..b73d1f9accf92 --- /dev/null +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/languages_lookup-settings.json @@ -0,0 +1,5 @@ +{ + "index": { + "mode": "lookup" + } +} 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 7ad4c3d3e644d..dde7bc09ac615 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 @@ -62,6 +62,7 @@ import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.EsqlArithmeticOperation; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.In; import org.elasticsearch.xpack.esql.index.EsIndex; +import org.elasticsearch.xpack.esql.index.IndexResolution; import org.elasticsearch.xpack.esql.parser.ParsingException; import org.elasticsearch.xpack.esql.plan.TableIdentifier; import org.elasticsearch.xpack.esql.plan.logical.Aggregate; @@ -106,7 +107,6 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.function.Function; @@ -199,11 +199,12 @@ private static class ResolveTable extends ParameterizedAnalyzerRule"), enrichResolution); + } +} 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 9630a520e8654..25bb6d80d0dd0 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 @@ -12,6 +12,7 @@ import org.elasticsearch.action.search.ShardSearchFailure; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.common.Strings; +import org.elasticsearch.common.TriFunction; import org.elasticsearch.common.collect.Iterators; import org.elasticsearch.common.regex.Regex; import org.elasticsearch.compute.data.Block; @@ -62,6 +63,8 @@ import org.elasticsearch.xpack.esql.plan.logical.RegexExtract; import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation; import org.elasticsearch.xpack.esql.plan.logical.join.InlineJoin; +import org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes; +import org.elasticsearch.xpack.esql.plan.logical.join.LookupJoin; import org.elasticsearch.xpack.esql.plan.logical.local.LocalRelation; import org.elasticsearch.xpack.esql.plan.logical.local.LocalSupplier; import org.elasticsearch.xpack.esql.plan.physical.EstimatesRowSize; @@ -76,7 +79,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -272,9 +274,12 @@ public void analyzedPlan(LogicalPlan parsed, EsqlExecutionInfo executionInfo, Ac return; } - preAnalyze(parsed, executionInfo, (indices, policies) -> { + preAnalyze(parsed, executionInfo, (indices, lookupIndices, policies) -> { planningMetrics.gatherPreAnalysisMetrics(parsed); - Analyzer analyzer = new Analyzer(new AnalyzerContext(configuration, functionRegistry, indices, policies), verifier); + Analyzer analyzer = new Analyzer( + new AnalyzerContext(configuration, functionRegistry, indices, lookupIndices, policies), + verifier + ); var plan = analyzer.analyze(parsed); plan.setAnalyzed(); LOGGER.debug("Analyzed plan:\n{}", plan); @@ -285,7 +290,7 @@ public void analyzedPlan(LogicalPlan parsed, EsqlExecutionInfo executionInfo, Ac private void preAnalyze( LogicalPlan parsed, EsqlExecutionInfo executionInfo, - BiFunction action, + TriFunction action, ActionListener listener ) { PreAnalyzer.PreAnalysis preAnalysis = preAnalyzer.preAnalyze(parsed); @@ -299,63 +304,81 @@ private void preAnalyze( ).keySet(); enrichPolicyResolver.resolvePolicies(targetClusters, unresolvedPolicies, listener.delegateFailureAndWrap((l, enrichResolution) -> { // first we need the match_fields names from enrich policies and THEN, with an updated list of fields, we call field_caps API - var matchFields = enrichResolution.resolvedEnrichPolicies() + var enrichMatchFields = enrichResolution.resolvedEnrichPolicies() .stream() .map(ResolvedEnrichPolicy::matchField) .collect(Collectors.toSet()); - Map unavailableClusters = enrichResolution.getUnavailableClusters(); - preAnalyzeIndices(parsed, executionInfo, unavailableClusters, l.delegateFailureAndWrap((ll, indexResolution) -> { - // TODO in follow-PR (for skip_unavailble handling of missing concrete indexes) add some tests for invalid index - // resolution to updateExecutionInfo - if (indexResolution.isValid()) { - EsqlSessionCCSUtils.updateExecutionInfoWithClustersWithNoMatchingIndices(executionInfo, indexResolution); - EsqlSessionCCSUtils.updateExecutionInfoWithUnavailableClusters(executionInfo, indexResolution.unavailableClusters()); - if (executionInfo.isCrossClusterSearch() - && executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.RUNNING) == 0) { - // for a CCS, if all clusters have been marked as SKIPPED, nothing to search so send a sentinel - // Exception to let the LogicalPlanActionListener decide how to proceed - ll.onFailure(new NoClustersToSearchException()); - return; - } - - Set newClusters = enrichPolicyResolver.groupIndicesPerCluster( - indexResolution.get().concreteIndices().toArray(String[]::new) - ).keySet(); - // If new clusters appear when resolving the main indices, we need to resolve the enrich policies again - // or exclude main concrete indices. Since this is rare, it's simpler to resolve the enrich policies again. - // TODO: add a test for this - if (targetClusters.containsAll(newClusters) == false - // do not bother with a re-resolution if only remotes were requested and all were offline - && executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.RUNNING) > 0) { - enrichPolicyResolver.resolvePolicies( - newClusters, - unresolvedPolicies, - ll.map(newEnrichResolution -> action.apply(indexResolution, newEnrichResolution)) - ); - return; - } - } - ll.onResponse(action.apply(indexResolution, enrichResolution)); - }), matchFields); + // get the field names from the parsed plan combined with the ENRICH match fields from the ENRICH policy + var fieldNames = fieldNames(parsed, enrichMatchFields); + // First resolve the lookup indices, then the main indices + preAnalyzeLookupIndices( + preAnalysis.lookupIndices, + fieldNames, + l.delegateFailureAndWrap( + (lx, lookupIndexResolution) -> preAnalyzeIndices( + indices, + executionInfo, + enrichResolution.getUnavailableClusters(), + fieldNames, + lx.delegateFailureAndWrap((ll, indexResolution) -> { + // TODO in follow-PR (for skip_unavailble handling of missing concrete indexes) add some tests for invalid + // index resolution to updateExecutionInfo + if (indexResolution.isValid()) { + EsqlSessionCCSUtils.updateExecutionInfoWithClustersWithNoMatchingIndices(executionInfo, indexResolution); + EsqlSessionCCSUtils.updateExecutionInfoWithUnavailableClusters( + executionInfo, + indexResolution.unavailableClusters() + ); + if (executionInfo.isCrossClusterSearch() + && executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.RUNNING) == 0) { + // for a CCS, if all clusters have been marked as SKIPPED, nothing to search so send a sentinel + // Exception to let the LogicalPlanActionListener decide how to proceed + ll.onFailure(new NoClustersToSearchException()); + return; + } + + Set newClusters = enrichPolicyResolver.groupIndicesPerCluster( + indexResolution.get().concreteIndices().toArray(String[]::new) + ).keySet(); + // If new clusters appear when resolving the main indices, we need to resolve the enrich policies again + // or exclude main concrete indices. Since this is rare, it's simpler to resolve the enrich policies + // again. + // TODO: add a test for this + if (targetClusters.containsAll(newClusters) == false + // do not bother with a re-resolution if only remotes were requested and all were offline + && executionInfo.getClusterStateCount(EsqlExecutionInfo.Cluster.Status.RUNNING) > 0) { + enrichPolicyResolver.resolvePolicies( + newClusters, + unresolvedPolicies, + ll.map( + newEnrichResolution -> action.apply(indexResolution, lookupIndexResolution, newEnrichResolution) + ) + ); + return; + } + } + ll.onResponse(action.apply(indexResolution, lookupIndexResolution, enrichResolution)); + }) + ) + ) + ); })); } private void preAnalyzeIndices( - LogicalPlan parsed, + List indices, EsqlExecutionInfo executionInfo, Map unavailableClusters, // known to be unavailable from the enrich policy API call - ActionListener listener, - Set enrichPolicyMatchFields + Set fieldNames, + ActionListener listener ) { - PreAnalyzer.PreAnalysis preAnalysis = new PreAnalyzer().preAnalyze(parsed); // TODO we plan to support joins in the future when possible, but for now we'll just fail early if we see one - if (preAnalysis.indices.size() > 1) { + if (indices.size() > 1) { // Note: JOINs are not supported but we detect them when listener.onFailure(new MappingException("Queries with multiple indices are not supported")); - } else if (preAnalysis.indices.size() == 1) { - TableInfo tableInfo = preAnalysis.indices.get(0); + } else if (indices.size() == 1) { + TableInfo tableInfo = indices.get(0); TableIdentifier table = tableInfo.id(); - var fieldNames = fieldNames(parsed, enrichPolicyMatchFields); Map clusterIndices = indicesExpressionGrouper.groupIndices(IndicesOptions.DEFAULT, table.index()); for (Map.Entry entry : clusterIndices.entrySet()) { @@ -401,6 +424,25 @@ private void preAnalyzeIndices( } } + private void preAnalyzeLookupIndices(List indices, Set fieldNames, ActionListener listener) { + if (indices.size() > 1) { + // Note: JOINs on more than one index are not yet supported + listener.onFailure(new MappingException("More than one LOOKUP JOIN is not supported")); + } else if (indices.size() == 1) { + TableInfo tableInfo = indices.get(0); + TableIdentifier table = tableInfo.id(); + // call the EsqlResolveFieldsAction (field-caps) to resolve indices and get field types + indexResolver.resolveAsMergedMapping(table.index(), fieldNames, listener); + } else { + try { + // No lookup indices specified + listener.onResponse(IndexResolution.invalid("[none specified]")); + } catch (Exception ex) { + listener.onFailure(ex); + } + } + } + static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchFields) { if (false == parsed.anyMatch(plan -> plan instanceof Aggregate || plan instanceof Project)) { // no explicit columns selection, for example "from employees" @@ -422,6 +464,7 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF // "keep" attributes are special whenever a wildcard is used in their name // 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<>(); @@ -440,6 +483,11 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF // The exact name of the field will be added later as part of enrichPolicyMatchFields Set enrichRefs.removeIf(attr -> attr instanceof EmptyAttribute); references.addAll(enrichRefs); + } else if (p instanceof LookupJoin join) { + keepJoinReferences.addAll(join.config().matchFields()); // TODO: why is this empty + if (join.config().type() instanceof JoinTypes.UsingJoinType usingJoinType) { + keepJoinReferences.addAll(usingJoinType.columns()); + } } else { references.addAll(p.references()); if (p instanceof UnresolvedRelation ur && ur.indexMode() == IndexMode.TIME_SERIES) { @@ -473,6 +521,8 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF references.removeIf(attr -> matchByName(attr, alias.name(), keepCommandReferences.contains(attr))); }); }); + // Add JOIN ON column references afterward to avoid Alias removal + references.addAll(keepJoinReferences); // remove valid metadata attributes because they will be filtered out by the IndexResolver anyway // otherwise, in some edge cases, we will fail to ask for "*" (all fields) instead From 3896de6639f1807676345d71b70b4cc218f9f37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Mon, 25 Nov 2024 12:34:20 +0100 Subject: [PATCH 009/129] ESQL: Fix AttributeSet#add() returning the opposite expected value (#117367) Set/Collection#add() is supposed to return `true` if the collection changed (If it actually added something). In this case, it must return if the old value is null. Extracted from https://github.com/elastic/elasticsearch/pull/114317 (Where it's being used) --- .../elasticsearch/xpack/esql/core/expression/AttributeSet.java | 2 +- .../org/elasticsearch/xpack/ql/expression/AttributeSet.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/AttributeSet.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/AttributeSet.java index e3eac60703915..a092e17931237 100644 --- a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/AttributeSet.java +++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/AttributeSet.java @@ -113,7 +113,7 @@ public T[] toArray(T[] a) { @Override public boolean add(Attribute e) { - return delegate.put(e, PRESENT) != null; + return delegate.put(e, PRESENT) == null; } @Override diff --git a/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/expression/AttributeSet.java b/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/expression/AttributeSet.java index 0ee291af29ae1..a44764dab2a38 100644 --- a/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/expression/AttributeSet.java +++ b/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/expression/AttributeSet.java @@ -113,7 +113,7 @@ public T[] toArray(T[] a) { @Override public boolean add(Attribute e) { - return delegate.put(e, PRESENT) != null; + return delegate.put(e, PRESENT) == null; } @Override From e319875d7e69d241283fc2e762cd6d8424886715 Mon Sep 17 00:00:00 2001 From: Ignacio Vera Date: Mon, 25 Nov 2024 13:27:38 +0100 Subject: [PATCH 010/129] Make InternalComposite.InternalBucket leaner (#117368) This commit removes reverseMuls and missingOrder from InternalComposite.InternalBucket . --- .../bucket/composite/CompositeAggregator.java | 10 +---- .../bucket/composite/InternalComposite.java | 45 ++++--------------- .../composite/InternalCompositeTests.java | 14 +----- 3 files changed, 11 insertions(+), 58 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/CompositeAggregator.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/CompositeAggregator.java index 0baecf6e3f92b..441b30f872a35 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/CompositeAggregator.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/CompositeAggregator.java @@ -205,15 +205,7 @@ public InternalAggregation[] buildAggregations(LongArray owningBucketOrds) throw CompositeKey key = queue.toCompositeKey(slot); InternalAggregations aggs = subAggsForBuckets.apply(slot); long docCount = queue.getDocCount(slot); - buckets[(int) queue.size()] = new InternalComposite.InternalBucket( - sourceNames, - formats, - key, - reverseMuls, - missingOrders, - docCount, - aggs - ); + buckets[(int) queue.size()] = new InternalComposite.InternalBucket(sourceNames, formats, key, docCount, aggs); } CompositeKey lastBucket = num > 0 ? buckets[num - 1].getRawKey() : null; return new InternalAggregation[] { diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java index 8b3253418bc23..faa953e77edd8 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java @@ -19,7 +19,6 @@ import org.elasticsearch.search.aggregations.InternalAggregation; import org.elasticsearch.search.aggregations.InternalAggregations; import org.elasticsearch.search.aggregations.InternalMultiBucketAggregation; -import org.elasticsearch.search.aggregations.KeyComparable; import org.elasticsearch.search.aggregations.bucket.BucketReducer; import org.elasticsearch.search.aggregations.bucket.IteratorAndCurrent; import org.elasticsearch.search.aggregations.support.SamplingContext; @@ -103,7 +102,7 @@ public InternalComposite(StreamInput in) throws IOException { } this.reverseMuls = in.readIntArray(); this.missingOrders = in.readArray(MissingOrder::readFromStream, MissingOrder[]::new); - this.buckets = in.readCollectionAsList((input) -> new InternalBucket(input, sourceNames, formats, reverseMuls, missingOrders)); + this.buckets = in.readCollectionAsList((input) -> new InternalBucket(input, sourceNames, formats)); this.afterKey = in.readOptionalWriteable(CompositeKey::new); this.earlyTerminated = in.readBoolean(); } @@ -155,15 +154,7 @@ public InternalComposite create(List newBuckets) { @Override public InternalBucket createBucket(InternalAggregations aggregations, InternalBucket prototype) { - return new InternalBucket( - prototype.sourceNames, - prototype.formats, - prototype.key, - prototype.reverseMuls, - prototype.missingOrders, - prototype.docCount, - aggregations - ); + return new InternalBucket(prototype.sourceNames, prototype.formats, prototype.key, prototype.docCount, aggregations); } public int getSize() { @@ -206,7 +197,7 @@ protected AggregatorReducer getLeaderReducer(AggregationReduceContext reduceCont private final PriorityQueue> pq = new PriorityQueue<>(size) { @Override protected boolean lessThan(IteratorAndCurrent a, IteratorAndCurrent b) { - return a.current().compareKey(b.current()) < 0; + return a.current().compareKey(b.current(), reverseMuls, missingOrders) < 0; } }; private boolean earlyTerminated = false; @@ -227,7 +218,7 @@ public InternalAggregation get() { final List result = new ArrayList<>(); while (pq.size() > 0) { IteratorAndCurrent top = pq.top(); - if (lastBucket != null && top.current().compareKey(lastBucket) != 0) { + if (lastBucket != null && top.current().compareKey(lastBucket, reverseMuls, missingOrders) != 0) { InternalBucket reduceBucket = reduceBucket(buckets, reduceContext); buckets.clear(); result.add(reduceBucket); @@ -306,7 +297,7 @@ private InternalBucket reduceBucket(List buckets, AggregationRed final var reducedFormats = reducer.getProto().formats; final long docCount = reducer.getDocCount(); final InternalAggregations aggs = reducer.getAggregations(); - return new InternalBucket(sourceNames, reducedFormats, reducer.getProto().key, reverseMuls, missingOrders, docCount, aggs); + return new InternalBucket(sourceNames, reducedFormats, reducer.getProto().key, docCount, aggs); } } @@ -329,16 +320,11 @@ public int hashCode() { return Objects.hash(super.hashCode(), size, buckets, afterKey, Arrays.hashCode(reverseMuls), Arrays.hashCode(missingOrders)); } - public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucket - implements - CompositeAggregation.Bucket, - KeyComparable { + public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucket implements CompositeAggregation.Bucket { private final CompositeKey key; private final long docCount; private final InternalAggregations aggregations; - private final transient int[] reverseMuls; - private final transient MissingOrder[] missingOrders; private final transient List sourceNames; private final transient List formats; @@ -346,32 +332,20 @@ public static class InternalBucket extends InternalMultiBucketAggregation.Intern List sourceNames, List formats, CompositeKey key, - int[] reverseMuls, - MissingOrder[] missingOrders, long docCount, InternalAggregations aggregations ) { this.key = key; this.docCount = docCount; this.aggregations = aggregations; - this.reverseMuls = reverseMuls; - this.missingOrders = missingOrders; this.sourceNames = sourceNames; this.formats = formats; } - InternalBucket( - StreamInput in, - List sourceNames, - List formats, - int[] reverseMuls, - MissingOrder[] missingOrders - ) throws IOException { + InternalBucket(StreamInput in, List sourceNames, List formats) throws IOException { this.key = new CompositeKey(in); this.docCount = in.readVLong(); this.aggregations = InternalAggregations.readFrom(in); - this.reverseMuls = reverseMuls; - this.missingOrders = missingOrders; this.sourceNames = sourceNames; this.formats = formats; } @@ -444,8 +418,7 @@ List getFormats() { return formats; } - @Override - public int compareKey(InternalBucket other) { + int compareKey(InternalBucket other, int[] reverseMuls, MissingOrder[] missingOrders) { for (int i = 0; i < key.size(); i++) { if (key.get(i) == null) { if (other.key.get(i) == null) { @@ -470,8 +443,6 @@ InternalBucket finalizeSampling(SamplingContext samplingContext) { sourceNames, formats, key, - reverseMuls, - missingOrders, samplingContext.scaleUp(docCount), InternalAggregations.finalizeSampling(aggregations, samplingContext) ); diff --git a/server/src/test/java/org/elasticsearch/search/aggregations/bucket/composite/InternalCompositeTests.java b/server/src/test/java/org/elasticsearch/search/aggregations/bucket/composite/InternalCompositeTests.java index 5fb1d0e760afa..7e7ccb1d72e80 100644 --- a/server/src/test/java/org/elasticsearch/search/aggregations/bucket/composite/InternalCompositeTests.java +++ b/server/src/test/java/org/elasticsearch/search/aggregations/bucket/composite/InternalCompositeTests.java @@ -143,18 +143,10 @@ protected InternalComposite createTestInstance(String name, Map continue; } keys.add(key); - InternalComposite.InternalBucket bucket = new InternalComposite.InternalBucket( - sourceNames, - formats, - key, - reverseMuls, - missingOrders, - 1L, - aggregations - ); + InternalComposite.InternalBucket bucket = new InternalComposite.InternalBucket(sourceNames, formats, key, 1L, aggregations); buckets.add(bucket); } - Collections.sort(buckets, (o1, o2) -> o1.compareKey(o2)); + Collections.sort(buckets, (o1, o2) -> o1.compareKey(o2, reverseMuls, missingOrders)); CompositeKey lastBucket = buckets.size() > 0 ? buckets.get(buckets.size() - 1).getRawKey() : null; return new InternalComposite( name, @@ -191,8 +183,6 @@ protected InternalComposite mutateInstance(InternalComposite instance) { sourceNames, formats, createCompositeKey(), - reverseMuls, - missingOrders, randomLongBetween(1, 100), InternalAggregations.EMPTY ) From 339e4310814cd545339d5a8380f3d3de3479b461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Mon, 25 Nov 2024 14:07:30 +0100 Subject: [PATCH 011/129] [DOCS] Documents that ELSER is the default service for `semantic_text` (#115769) --- .../mapping/types/semantic-text.asciidoc | 24 +++++++- .../semantic-search-semantic-text.asciidoc | 59 +++---------------- 2 files changed, 31 insertions(+), 52 deletions(-) diff --git a/docs/reference/mapping/types/semantic-text.asciidoc b/docs/reference/mapping/types/semantic-text.asciidoc index 684ad7c369e7d..f76a9352c2fe8 100644 --- a/docs/reference/mapping/types/semantic-text.asciidoc +++ b/docs/reference/mapping/types/semantic-text.asciidoc @@ -13,25 +13,45 @@ Long passages are <> to smaller secti The `semantic_text` field type specifies an inference endpoint identifier that will be used to generate embeddings. You can create the inference endpoint by using the <>. This field type and the <> type make it simpler to perform semantic search on your data. +If you don't specify an inference endpoint, the <> is used by default. Using `semantic_text`, you won't need to specify how to generate embeddings for your data, or how to index it. The {infer} endpoint automatically determines the embedding generation, indexing, and query to use. +If you use the ELSER service, you can set up `semantic_text` with the following API request: + [source,console] ------------------------------------------------------------ PUT my-index-000001 +{ + "mappings": { + "properties": { + "inference_field": { + "type": "semantic_text" + } + } + } +} +------------------------------------------------------------ + +If you use a service other than ELSER, you must create an {infer} endpoint using the <> and reference it when setting up `semantic_text` as the following example demonstrates: + +[source,console] +------------------------------------------------------------ +PUT my-index-000002 { "mappings": { "properties": { "inference_field": { "type": "semantic_text", - "inference_id": "my-elser-endpoint" + "inference_id": "my-openai-endpoint" <1> } } } } ------------------------------------------------------------ // TEST[skip:Requires inference endpoint] +<1> The `inference_id` of the {infer} endpoint to use to generate embeddings. The recommended way to use semantic_text is by having dedicated {infer} endpoints for ingestion and search. @@ -40,7 +60,7 @@ After creating dedicated {infer} endpoints for both, you can reference them usin [source,console] ------------------------------------------------------------ -PUT my-index-000002 +PUT my-index-000003 { "mappings": { "properties": { diff --git a/docs/reference/search/search-your-data/semantic-search-semantic-text.asciidoc b/docs/reference/search/search-your-data/semantic-search-semantic-text.asciidoc index 60692c19c184a..ba9c81db21384 100644 --- a/docs/reference/search/search-your-data/semantic-search-semantic-text.asciidoc +++ b/docs/reference/search/search-your-data/semantic-search-semantic-text.asciidoc @@ -21,45 +21,9 @@ This tutorial uses the <> for demonstra [[semantic-text-requirements]] ==== Requirements -To use the `semantic_text` field type, you must have an {infer} endpoint deployed in -your cluster using the <>. +This tutorial uses the <> for demonstration, which is created automatically as needed. +To use the `semantic_text` field type with an {infer} service other than ELSER, you must create an inference endpoint using the <>. -[discrete] -[[semantic-text-infer-endpoint]] -==== Create the {infer} endpoint - -Create an inference endpoint by using the <>: - -[source,console] ------------------------------------------------------------- -PUT _inference/sparse_embedding/my-elser-endpoint <1> -{ - "service": "elser", <2> - "service_settings": { - "adaptive_allocations": { <3> - "enabled": true, - "min_number_of_allocations": 3, - "max_number_of_allocations": 10 - }, - "num_threads": 1 - } -} ------------------------------------------------------------- -// TEST[skip:TBD] -<1> The task type is `sparse_embedding` in the path as the `elser` service will -be used and ELSER creates sparse vectors. The `inference_id` is -`my-elser-endpoint`. -<2> The `elser` service is used in this example. -<3> This setting enables and configures {ml-docs}/ml-nlp-auto-scale.html#nlp-model-adaptive-allocations[adaptive allocations]. -Adaptive allocations make it possible for ELSER to automatically scale up or down resources based on the current load on the process. - -[NOTE] -==== -You might see a 502 bad gateway error in the response when using the {kib} Console. -This error usually just reflects a timeout, while the model downloads in the background. -You can check the download progress in the {ml-app} UI. -If using the Python client, you can set the `timeout` parameter to a higher value. -==== [discrete] [[semantic-text-index-mapping]] @@ -75,8 +39,7 @@ PUT semantic-embeddings "mappings": { "properties": { "content": { <1> - "type": "semantic_text", <2> - "inference_id": "my-elser-endpoint" <3> + "type": "semantic_text" <2> } } } @@ -85,18 +48,14 @@ PUT semantic-embeddings // TEST[skip:TBD] <1> The name of the field to contain the generated embeddings. <2> The field to contain the embeddings is a `semantic_text` field. -<3> The `inference_id` is the inference endpoint you created in the previous step. -It will be used to generate the embeddings based on the input text. -Every time you ingest data into the related `semantic_text` field, this endpoint will be used for creating the vector representation of the text. +Since no `inference_id` is provided, the <> is used by default. +To use a different {infer} service, you must create an {infer} endpoint first using the <> and then specify it in the `semantic_text` field mapping using the `inference_id` parameter. [NOTE] ==== -If you're using web crawlers or connectors to generate indices, you have to -<> for these indices to -include the `semantic_text` field. Once the mapping is updated, you'll need to run -a full web crawl or a full connector sync. This ensures that all existing -documents are reprocessed and updated with the new semantic embeddings, -enabling semantic search on the updated data. +If you're using web crawlers or connectors to generate indices, you have to <> for these indices to include the `semantic_text` field. +Once the mapping is updated, you'll need to run a full web crawl or a full connector sync. +This ensures that all existing documents are reprocessed and updated with the new semantic embeddings, enabling semantic search on the updated data. ==== @@ -288,4 +247,4 @@ query from the `semantic-embedding` index: * If you want to use `semantic_text` in hybrid search, refer to https://colab.research.google.com/github/elastic/elasticsearch-labs/blob/main/notebooks/search/09-semantic-text.ipynb[this notebook] for a step-by-step guide. * For more information on how to optimize your ELSER endpoints, refer to {ml-docs}/ml-nlp-elser.html#elser-recommendations[the ELSER recommendations] section in the model documentation. -* To learn more about model autoscaling, refer to the {ml-docs}/ml-nlp-auto-scale.html[trained model autoscaling] page. \ No newline at end of file +* To learn more about model autoscaling, refer to the {ml-docs}/ml-nlp-auto-scale.html[trained model autoscaling] page. From 86098f8c7f83368a1b6f50ffdef4e9c8eb8583b0 Mon Sep 17 00:00:00 2001 From: Mike Pellegrini Date: Mon, 25 Nov 2024 08:09:37 -0500 Subject: [PATCH 012/129] Mute default ELSER tests (#117390) --- muted-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index d4b77f5269c10..ff19d7d59da75 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -223,6 +223,12 @@ tests: - class: org.elasticsearch.xpack.inference.DefaultEndPointsIT method: testInferDeploysDefaultElser issue: https://github.com/elastic/elasticsearch/issues/114913 +- class: org.elasticsearch.xpack.inference.InferenceRestIT + method: test {p0=inference/40_semantic_text_query/Query a field that uses the default ELSER 2 endpoint} + issue: https://github.com/elastic/elasticsearch/issues/117027 +- class: org.elasticsearch.xpack.inference.InferenceRestIT + method: test {p0=inference/30_semantic_text_inference/Calculates embeddings using the default ELSER 2 endpoint} + issue: https://github.com/elastic/elasticsearch/issues/117349 - class: org.elasticsearch.xpack.security.operator.OperatorPrivilegesIT method: testEveryActionIsEitherOperatorOnlyOrNonOperator issue: https://github.com/elastic/elasticsearch/issues/102992 From 105d4f89a6e0f3b663c2fbb99939408e53e9c1c0 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Tue, 26 Nov 2024 00:19:00 +1100 Subject: [PATCH 013/129] Mute org.elasticsearch.xpack.test.rest.XPackRestIT test {p0=transform/transforms_reset/Test reset running transform} #117473 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index ff19d7d59da75..da8a093ebe674 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -232,6 +232,9 @@ tests: - class: org.elasticsearch.xpack.security.operator.OperatorPrivilegesIT method: testEveryActionIsEitherOperatorOnlyOrNonOperator issue: https://github.com/elastic/elasticsearch/issues/102992 +- 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 # Examples: # From ff58d891a168078c99334204081ef95607f8d48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20FOUCRET?= Date: Mon, 25 Nov 2024 14:22:11 +0100 Subject: [PATCH 014/129] ES|QL kql function. (#116764) --- .../esql/functions/description/kql.asciidoc | 5 + .../esql/functions/examples/kql.asciidoc | 13 ++ .../esql/functions/kibana/definition/kql.json | 37 ++++ .../esql/functions/kibana/docs/kql.md | 14 ++ .../esql/functions/layout/kql.asciidoc | 17 ++ .../esql/functions/parameters/kql.asciidoc | 6 + .../esql/functions/signature/kql.svg | 1 + .../esql/functions/types/kql.asciidoc | 10 + x-pack/plugin/esql/build.gradle | 2 + .../src/main/resources/kql-function.csv-spec | 153 +++++++++++++++ .../src/main/resources/qstr-function.csv-spec | 10 +- .../xpack/esql/plugin/KqlFunctionIT.java | 144 ++++++++++++++ .../xpack/esql/action/EsqlCapabilities.java | 5 + .../xpack/esql/analysis/Verifier.java | 22 ++- .../function/EsqlFunctionRegistry.java | 2 + .../function/fulltext/FullTextWritables.java | 16 +- .../expression/function/fulltext/Kql.java | 73 +++++++ .../physical/local/PushFiltersToSource.java | 6 +- .../planner/EsqlExpressionTranslators.java | 10 + .../xpack/esql/querydsl/query/KqlQuery.java | 85 ++++++++ .../elasticsearch/xpack/esql/CsvTests.java | 4 + .../xpack/esql/analysis/VerifierTests.java | 89 +++++++++ .../function/fulltext/KqlTests.java | 41 ++++ .../NoneFieldFullTextFunctionTestCase.java | 62 ++++++ .../function/fulltext/QueryStringTests.java | 43 +--- .../LocalPhysicalPlanOptimizerTests.java | 184 +++++++++++++++++- .../esql/querydsl/query/KqlQueryTests.java | 139 +++++++++++++ .../xpack/kql/query/KqlQueryBuilder.java | 20 +- .../rest-api-spec/test/esql/60_usage.yml | 2 +- 29 files changed, 1149 insertions(+), 66 deletions(-) create mode 100644 docs/reference/esql/functions/description/kql.asciidoc create mode 100644 docs/reference/esql/functions/examples/kql.asciidoc create mode 100644 docs/reference/esql/functions/kibana/definition/kql.json create mode 100644 docs/reference/esql/functions/kibana/docs/kql.md create mode 100644 docs/reference/esql/functions/layout/kql.asciidoc create mode 100644 docs/reference/esql/functions/parameters/kql.asciidoc create mode 100644 docs/reference/esql/functions/signature/kql.svg create mode 100644 docs/reference/esql/functions/types/kql.asciidoc create mode 100644 x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec create mode 100644 x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java create mode 100644 x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java create mode 100644 x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java create mode 100644 x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java create mode 100644 x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java create mode 100644 x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQueryTests.java diff --git a/docs/reference/esql/functions/description/kql.asciidoc b/docs/reference/esql/functions/description/kql.asciidoc new file mode 100644 index 0000000000000..e1fe411e6689c --- /dev/null +++ b/docs/reference/esql/functions/description/kql.asciidoc @@ -0,0 +1,5 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Description* + +Performs a KQL query. Returns true if the provided KQL query string matches the row. diff --git a/docs/reference/esql/functions/examples/kql.asciidoc b/docs/reference/esql/functions/examples/kql.asciidoc new file mode 100644 index 0000000000000..1f8518aeec394 --- /dev/null +++ b/docs/reference/esql/functions/examples/kql.asciidoc @@ -0,0 +1,13 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Example* + +[source.merge.styled,esql] +---- +include::{esql-specs}/kql-function.csv-spec[tag=kql-with-field] +---- +[%header.monospaced.styled,format=dsv,separator=|] +|=== +include::{esql-specs}/kql-function.csv-spec[tag=kql-with-field-result] +|=== + diff --git a/docs/reference/esql/functions/kibana/definition/kql.json b/docs/reference/esql/functions/kibana/definition/kql.json new file mode 100644 index 0000000000000..6960681fbbf0d --- /dev/null +++ b/docs/reference/esql/functions/kibana/definition/kql.json @@ -0,0 +1,37 @@ +{ + "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.", + "type" : "eval", + "name" : "kql", + "description" : "Performs a KQL query. Returns true if the provided KQL query string matches the row.", + "signatures" : [ + { + "params" : [ + { + "name" : "query", + "type" : "keyword", + "optional" : false, + "description" : "Query string in KQL query string format." + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "query", + "type" : "text", + "optional" : false, + "description" : "Query string in KQL query string format." + } + ], + "variadic" : false, + "returnType" : "boolean" + } + ], + "examples" : [ + "FROM books \n| WHERE KQL(\"author: Faulkner\")\n| KEEP book_no, author \n| SORT book_no \n| LIMIT 5;" + ], + "preview" : true, + "snapshot_only" : true +} diff --git a/docs/reference/esql/functions/kibana/docs/kql.md b/docs/reference/esql/functions/kibana/docs/kql.md new file mode 100644 index 0000000000000..0ba419c1cd032 --- /dev/null +++ b/docs/reference/esql/functions/kibana/docs/kql.md @@ -0,0 +1,14 @@ + + +### KQL +Performs a KQL query. Returns true if the provided KQL query string matches the row. + +``` +FROM books +| WHERE KQL("author: Faulkner") +| KEEP book_no, author +| SORT book_no +| LIMIT 5; +``` diff --git a/docs/reference/esql/functions/layout/kql.asciidoc b/docs/reference/esql/functions/layout/kql.asciidoc new file mode 100644 index 0000000000000..8cf2687b240c1 --- /dev/null +++ b/docs/reference/esql/functions/layout/kql.asciidoc @@ -0,0 +1,17 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +[discrete] +[[esql-kql]] +=== `KQL` + +preview::["Do not use on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."] + +*Syntax* + +[.text-center] +image::esql/functions/signature/kql.svg[Embedded,opts=inline] + +include::../parameters/kql.asciidoc[] +include::../description/kql.asciidoc[] +include::../types/kql.asciidoc[] +include::../examples/kql.asciidoc[] diff --git a/docs/reference/esql/functions/parameters/kql.asciidoc b/docs/reference/esql/functions/parameters/kql.asciidoc new file mode 100644 index 0000000000000..6fb69323ff73c --- /dev/null +++ b/docs/reference/esql/functions/parameters/kql.asciidoc @@ -0,0 +1,6 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Parameters* + +`query`:: +Query string in KQL query string format. diff --git a/docs/reference/esql/functions/signature/kql.svg b/docs/reference/esql/functions/signature/kql.svg new file mode 100644 index 0000000000000..3f550f27ccdff --- /dev/null +++ b/docs/reference/esql/functions/signature/kql.svg @@ -0,0 +1 @@ +KQL(query) \ No newline at end of file diff --git a/docs/reference/esql/functions/types/kql.asciidoc b/docs/reference/esql/functions/types/kql.asciidoc new file mode 100644 index 0000000000000..866a39e925665 --- /dev/null +++ b/docs/reference/esql/functions/types/kql.asciidoc @@ -0,0 +1,10 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Supported types* + +[%header.monospaced.styled,format=dsv,separator=|] +|=== +query | result +keyword | boolean +text | boolean +|=== diff --git a/x-pack/plugin/esql/build.gradle b/x-pack/plugin/esql/build.gradle index f92c895cc5b7b..02f9752d21e09 100644 --- a/x-pack/plugin/esql/build.gradle +++ b/x-pack/plugin/esql/build.gradle @@ -34,6 +34,7 @@ dependencies { compileOnly project(':modules:lang-painless:spi') compileOnly project(xpackModule('esql-core')) compileOnly project(xpackModule('ml')) + implementation project(xpackModule('kql')) implementation project('compute') implementation project('compute:ann') implementation project(':libs:dissect') @@ -50,6 +51,7 @@ dependencies { testImplementation(testArtifact(project(xpackModule('core')))) testImplementation project(path: xpackModule('enrich')) testImplementation project(path: xpackModule('spatial')) + testImplementation project(path: xpackModule('kql')) testImplementation project(path: ':modules:reindex') testImplementation project(path: ':modules:parent-join') diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec new file mode 100644 index 0000000000000..02be58efac774 --- /dev/null +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec @@ -0,0 +1,153 @@ +############################################### +# Tests for KQL function +# + +kqlWithField +required_capability: kql_function + +// tag::kql-with-field[] +FROM books +| WHERE KQL("author: Faulkner") +| KEEP book_no, author +| SORT book_no +| LIMIT 5; +// end::kql-with-field[] + +// tag::kql-with-field-result[] +book_no:keyword | author:text +2378 | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott] +2713 | William Faulkner +2847 | Colleen Faulkner +2883 | William Faulkner +3293 | Danny Faulkner +; +// end::kql-with-field-result[] + +kqlWithMultipleFields +required_capability: kql_function + +from books +| where kql("title:Return* AND author:*Tolkien") +| keep book_no, title; +ignoreOrder:true + +book_no:keyword | title:text +2714 | Return of the King Being the Third Part of The Lord of the Rings +7350 | Return of the Shadow +; + +kqlWithQueryExpressions +required_capability: kql_function + +from books +| where kql(CONCAT("title:Return*", " AND author:*Tolkien")) +| keep book_no, title; +ignoreOrder:true + +book_no:keyword | title:text +2714 | Return of the King Being the Third Part of The Lord of the Rings +7350 | Return of the Shadow +; + +kqlWithConjunction +required_capability: kql_function + +from books +| where kql("title: Rings") and ratings > 4.6 +| keep book_no, title; +ignoreOrder:true + +book_no:keyword | title:text +4023 | A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings +7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1) +; + +kqlWithFunctionPushedToLucene +required_capability: kql_function + +from hosts +| where kql("host: beta") and cidr_match(ip1, "127.0.0.2/32", "127.0.0.3/32") +| keep card, host, ip0, ip1; +ignoreOrder:true + +card:keyword |host:keyword |ip0:ip |ip1:ip +eth1 |beta |127.0.0.1 |127.0.0.2 +; + +kqlWithNonPushableConjunction +required_capability: kql_function + +from books +| where kql("title: Rings") and length(title) > 75 +| keep book_no, title; +ignoreOrder:true + +book_no:keyword | title:text +4023 |A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings +; + +kqlWithMultipleWhereClauses +required_capability: kql_function + +from books +| where kql("title: rings") +| where kql("year > 1 AND year < 2005") +| keep book_no, title; +ignoreOrder:true + +book_no:keyword | title:text +4023 | A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings +7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1) +; + + +kqlWithMultivaluedTextField +required_capability: kql_function + +from employees +| where kql("job_positions: Tech Lead AND job_positions:(Reporting Analyst)") +| keep emp_no, first_name, last_name; +ignoreOrder:true + +emp_no:integer | first_name:keyword | last_name:keyword +10004 | Chirstian | Koblick +10010 | Duangkaew | Piveteau +10011 | Mary | Sluis +10088 | Jungsoon | Syrzycki +10093 | Sailaja | Desikan +10097 | Remzi | Waschkowski +; + +kqlWithMultivaluedNumericField +required_capability: kql_function + +from employees +| where kql("salary_change > 14") +| keep emp_no, first_name, last_name, salary_change; +ignoreOrder:true + +emp_no:integer | first_name:keyword | last_name:keyword | salary_change:double +10003 | Parto | Bamford | [12.82, 14.68] +10015 | Guoxiang | Nooteboom | [12.4, 14.25] +10023 | Bojan | Montemayor | [0.8, 14.63] +10040 | Weiyi | Meriste | [-8.94, 1.92, 6.97, 14.74] +10061 | Tse | Herber | [-2.58, -0.95, 14.39] +10065 | Satosi | Awdeh | [-9.81, -1.47, 14.44] +10099 | Valter | Sullins | [-8.78, -3.98, 10.71, 14.26] +; + +testMultiValuedFieldWithConjunction +required_capability: kql_function + +from employees +| where (kql("job_positions: (Data Scientist) OR job_positions:(Support Engineer)")) and gender == "F" +| keep emp_no, first_name, last_name; +ignoreOrder:true + +emp_no:integer | first_name:keyword | last_name:keyword +10023 | Bojan | Montemayor +10041 | Uri | Lenart +10044 | Mingsen | Casley +10053 | Sanjiv | Zschoche +10069 | Margareta | Bierman +; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec index 3e92e55928d64..6039dc05b6c44 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec @@ -101,8 +101,8 @@ book_no:keyword | title:text ; -matchMultivaluedTextField -required_capability: match_function +qstrWithMultivaluedTextField +required_capability: qstr_function from employees | where qstr("job_positions: (Tech Lead) AND job_positions:(Reporting Analyst)") @@ -118,8 +118,8 @@ emp_no:integer | first_name:keyword | last_name:keyword 10097 | Remzi | Waschkowski ; -matchMultivaluedNumericField -required_capability: match_function +qstrWithMultivaluedNumericField +required_capability: qstr_function from employees | where qstr("salary_change: [14 TO *]") @@ -137,7 +137,7 @@ emp_no:integer | first_name:keyword | last_name:keyword | salary_change:double ; testMultiValuedFieldWithConjunction -required_capability: match_function +required_capability: qstr_function from employees | where (qstr("job_positions: (Data Scientist) OR job_positions:(Support Engineer)")) and gender == "F" diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java new file mode 100644 index 0000000000000..d58637ab52c86 --- /dev/null +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java @@ -0,0 +1,144 @@ +/* + * 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.plugin; + +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.index.query.QueryShardException; +import org.elasticsearch.xpack.esql.VerificationException; +import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.util.List; + +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; +import static org.hamcrest.CoreMatchers.containsString; + +public class KqlFunctionIT extends AbstractEsqlIntegTestCase { + + @BeforeClass + protected static void ensureKqlFunctionEnabled() { + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + } + + @Before + public void setupIndex() { + createAndPopulateIndex(); + } + + public void testSimpleKqlQuery() { + var query = """ + FROM test + | WHERE kql("content: dog") + | KEEP id + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValues(resp.values(), List.of(List.of(1), List.of(3), List.of(4), List.of(5))); + } + } + + public void testMultiFieldKqlQuery() { + var query = """ + FROM test + | WHERE kql("dog OR canine") + | KEEP id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValuesInAnyOrder(resp.values(), List.of(List.of(1), List.of(2), List.of(3), List.of(4), List.of(5))); + } + } + + public void testKqlQueryWithinEval() { + var query = """ + FROM test + | EVAL matches_query = kql("title: fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[KQL] function is only supported in WHERE commands")); + } + + public void testInvalidKqlQueryEof() { + var query = """ + FROM test + | WHERE kql("content: ((((dog") + """; + + var error = expectThrows(QueryShardException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("Failed to parse KQL query [content: ((((dog]")); + assertThat(error.getRootCause().getMessage(), containsString("line 1:11: mismatched input '('")); + } + + public void testInvalidKqlQueryLexicalError() { + var query = """ + FROM test + | WHERE kql(":") + """; + + var error = expectThrows(QueryShardException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("Failed to parse KQL query [:]")); + assertThat(error.getRootCause().getMessage(), containsString("line 1:1: extraneous input ':' ")); + } + + private void createAndPopulateIndex() { + var indexName = "test"; + var client = client().admin().indices(); + var CreateRequest = client.prepareCreate(indexName) + .setSettings(Settings.builder().put("index.number_of_shards", 1)) + .setMapping("id", "type=integer", "content", "type=text"); + assertAcked(CreateRequest); + client().prepareBulk() + .add( + new IndexRequest(indexName).id("1") + .source("id", 1, "content", "The quick brown animal swiftly jumps over a lazy dog", "title", "A Swift Fox's Journey") + ) + .add( + new IndexRequest(indexName).id("2") + .source("id", 2, "content", "A speedy brown fox hops effortlessly over a sluggish canine", "title", "The Fox's Leap") + ) + .add( + new IndexRequest(indexName).id("3") + .source("id", 3, "content", "Quick and nimble, the fox vaults over the lazy dog", "title", "Brown Fox in Action") + ) + .add( + new IndexRequest(indexName).id("4") + .source( + "id", + 4, + "content", + "A fox that is quick and brown jumps over a dog that is quite lazy", + "title", + "Speedy Animals" + ) + ) + .add( + new IndexRequest(indexName).id("5") + .source( + "id", + 5, + "content", + "With agility, a quick brown fox bounds over a slow-moving dog", + "title", + "Foxes and Canines" + ) + ) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .get(); + ensureYellow(indexName); + } +} 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 d675f772b5a3b..d9ce7fca312b3 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 @@ -415,6 +415,11 @@ public enum Cap { */ MATCH_FUNCTION, + /** + * KQL function + */ + KQL_FUNCTION(Build.current().isSnapshot()), + /** * Don't optimize CASE IS NOT NULL function by not requiring the fields to be not null as well. * https://github.com/elastic/elasticsearch/issues/112704 diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java index 3ebb52641232e..2be13398dab2f 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java @@ -34,6 +34,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.FilteredExpression; import org.elasticsearch.xpack.esql.expression.function.aggregate.Rate; import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextFunction; +import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize; @@ -793,14 +794,19 @@ private static void checkNotPresentInDisjunctions( private static void checkFullTextQueryFunctions(LogicalPlan plan, Set failures) { if (plan instanceof Filter f) { Expression condition = f.condition(); - checkCommandsBeforeExpression( - plan, - condition, - QueryString.class, - lp -> (lp instanceof Filter || lp instanceof OrderBy || lp instanceof EsRelation), - qsf -> "[" + qsf.functionName() + "] " + qsf.functionType(), - failures - ); + + List.of(QueryString.class, Kql.class).forEach(functionClass -> { + // Check for limitations of QSTR and KQL function. + checkCommandsBeforeExpression( + plan, + condition, + functionClass, + lp -> (lp instanceof Filter || lp instanceof OrderBy || lp instanceof EsRelation), + fullTextFunction -> "[" + fullTextFunction.functionName() + "] " + fullTextFunction.functionType(), + failures + ); + }); + checkCommandsBeforeExpression( plan, condition, diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java index ea1669ccc7a4f..3d26bc170b723 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java @@ -33,6 +33,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.Top; import org.elasticsearch.xpack.esql.expression.function.aggregate.Values; import org.elasticsearch.xpack.esql.expression.function.aggregate.WeightedAvg; +import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.expression.function.grouping.Bucket; @@ -411,6 +412,7 @@ private static FunctionDefinition[][] snapshotFunctions() { // This is an experimental function and can be removed without notice. def(Delay.class, Delay::new, "delay"), def(Categorize.class, Categorize::new, "categorize"), + def(Kql.class, Kql::new, "kql"), def(Rate.class, Rate::withUnresolvedTimestamp, "rate") } }; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java index d59c736783172..8804a031de78c 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java @@ -8,14 +8,28 @@ package org.elasticsearch.xpack.esql.expression.function.fulltext; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; import org.elasticsearch.xpack.esql.core.expression.predicate.fulltext.MatchQueryPredicate; import org.elasticsearch.xpack.esql.core.expression.predicate.fulltext.MultiMatchQueryPredicate; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; public class FullTextWritables { public static List getNamedWriteables() { - return List.of(MatchQueryPredicate.ENTRY, MultiMatchQueryPredicate.ENTRY, QueryString.ENTRY, Match.ENTRY); + List entries = new ArrayList<>(); + + entries.add(MatchQueryPredicate.ENTRY); + entries.add(MultiMatchQueryPredicate.ENTRY); + entries.add(QueryString.ENTRY); + entries.add(Match.ENTRY); + + if (EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()) { + entries.add(Kql.ENTRY); + } + + return Collections.unmodifiableList(entries); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java new file mode 100644 index 0000000000000..c03902373c02e --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java @@ -0,0 +1,73 @@ +/* + * 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.fulltext; + +import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.NodeInfo; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.function.Example; +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.esql.querydsl.query.KqlQuery; + +import java.io.IOException; +import java.util.List; + +/** + * Full text function that performs a {@link KqlQuery} . + */ +public class Kql extends FullTextFunction { + public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Kql", Kql::new); + + @FunctionInfo( + returnType = "boolean", + preview = true, + description = "Performs a KQL query. Returns true if the provided KQL query string matches the row.", + examples = { @Example(file = "kql-function", tag = "kql-with-field") } + ) + public Kql( + Source source, + @Param( + name = "query", + type = { "keyword", "text" }, + description = "Query string in KQL query string format." + ) Expression queryString + ) { + super(source, queryString, List.of(queryString)); + } + + private Kql(StreamInput in) throws IOException { + this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + source().writeTo(out); + out.writeNamedWriteable(query()); + } + + @Override + public String getWriteableName() { + return ENTRY.name; + } + + @Override + public Expression replaceChildren(List newChildren) { + return new Kql(source(), newChildren.get(0)); + } + + @Override + protected NodeInfo info() { + return NodeInfo.create(this, Kql::new, query()); + } + +} diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java index 9f574ee8005b2..3d6c35e914294 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java @@ -30,8 +30,8 @@ import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.util.CollectionUtils; import org.elasticsearch.xpack.esql.core.util.Queries; +import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextFunction; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; -import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch; import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.BinarySpatialFunction; import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialRelatesFunction; @@ -252,10 +252,10 @@ static boolean canPushToSource(Expression exp, LucenePushdownPredicates lucenePu && Expressions.foldable(cidrMatch.matches()); } else if (exp instanceof SpatialRelatesFunction spatial) { return canPushSpatialFunctionToSource(spatial, lucenePushdownPredicates); - } else if (exp instanceof QueryString) { - return true; } else if (exp instanceof Match mf) { return mf.field() instanceof FieldAttribute && DataType.isString(mf.field().dataType()); + } else if (exp instanceof FullTextFunction) { + return true; } return false; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java index 6fac7bab2bd80..1580b77931240 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java @@ -34,6 +34,7 @@ import org.elasticsearch.xpack.esql.core.tree.Source; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.util.Check; +import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch; @@ -47,6 +48,7 @@ import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThan; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.NotEquals; +import org.elasticsearch.xpack.esql.querydsl.query.KqlQuery; import org.elasticsearch.xpack.esql.querydsl.query.SpatialRelatesQuery; import org.elasticsearch.xpack.versionfield.Version; @@ -89,6 +91,7 @@ public final class EsqlExpressionTranslators { new ExpressionTranslators.MultiMatches(), new MatchFunctionTranslator(), new QueryStringFunctionTranslator(), + new KqlFunctionTranslator(), new Scalars() ); @@ -538,4 +541,11 @@ protected Query asQuery(QueryString queryString, TranslatorHandler handler) { return new QueryStringQuery(queryString.source(), queryString.queryAsText(), Map.of(), Map.of()); } } + + public static class KqlFunctionTranslator extends ExpressionTranslator { + @Override + protected Query asQuery(Kql kqlFunction, TranslatorHandler handler) { + return new KqlQuery(kqlFunction.source(), kqlFunction.queryAsText()); + } + } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java new file mode 100644 index 0000000000000..c388a131b9ab6 --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java @@ -0,0 +1,85 @@ +/* + * 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.querydsl.query; + +import org.elasticsearch.core.Booleans; +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.xpack.esql.core.querydsl.query.Query; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.kql.query.KqlQueryBuilder; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; + +import static java.util.Map.entry; + +public class KqlQuery extends Query { + + private static final Map> BUILDER_APPLIERS = Map.ofEntries( + entry(KqlQueryBuilder.TIME_ZONE_FIELD.getPreferredName(), KqlQueryBuilder::timeZone), + entry(KqlQueryBuilder.DEFAULT_FIELD_FIELD.getPreferredName(), KqlQueryBuilder::defaultField), + entry(KqlQueryBuilder.CASE_INSENSITIVE_FIELD.getPreferredName(), (qb, s) -> qb.caseInsensitive(Booleans.parseBoolean(s))) + ); + + private final String query; + + private final Map options; + + // dedicated constructor for QueryTranslator + public KqlQuery(Source source, String query) { + this(source, query, null); + } + + public KqlQuery(Source source, String query, Map options) { + super(source); + this.query = query; + this.options = options == null ? Collections.emptyMap() : options; + } + + @Override + public QueryBuilder asBuilder() { + final KqlQueryBuilder queryBuilder = new KqlQueryBuilder(query); + options.forEach((k, v) -> { + if (BUILDER_APPLIERS.containsKey(k)) { + BUILDER_APPLIERS.get(k).accept(queryBuilder, v); + } else { + throw new IllegalArgumentException("illegal kql query option [" + k + "]"); + } + }); + return queryBuilder; + } + + public String query() { + return query; + } + + public Map options() { + return options; + } + + @Override + public int hashCode() { + return Objects.hash(query, options); + } + + @Override + public boolean equals(Object obj) { + if (false == super.equals(obj)) { + return false; + } + + KqlQuery other = (KqlQuery) obj; + return Objects.equals(query, other.query) && Objects.equals(options, other.options); + } + + @Override + protected String innerToString() { + return query; + } +} 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 012720db9efd9..010a60ef7da15 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 @@ -257,6 +257,10 @@ public final void test() throws Throwable { "can't use MATCH function in csv tests", testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.MATCH_FUNCTION.capabilityName()) ); + assumeFalse( + "can't use KQL function in csv tests", + testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.KQL_FUNCTION.capabilityName()) + ); assumeFalse( "lookup join disabled for csv tests", testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP.capabilityName()) 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 7b2f85b80b3b6..f25b19c4e5d1c 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 @@ -1263,11 +1263,74 @@ public void testQueryStringFunctionsNotAllowedAfterCommands() throws Exception { ); } + public void testKqlFunctionsNotAllowedAfterCommands() throws Exception { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + // Source commands + assertEquals("1:13: [KQL] function cannot be used after SHOW", error("show info | where kql(\"8.16.0\")")); + assertEquals("1:17: [KQL] function cannot be used after ROW", error("row a= \"Anna\" | where kql(\"Anna\")")); + + // Processing commands + assertEquals( + "1:43: [KQL] function cannot be used after DISSECT", + error("from test | dissect first_name \"%{foo}\" | where kql(\"Connection\")") + ); + assertEquals("1:27: [KQL] function cannot be used after DROP", error("from test | drop emp_no | where kql(\"Anna\")")); + assertEquals( + "1:71: [KQL] function cannot be used after ENRICH", + error("from test | enrich languages on languages with lang = language_name | where kql(\"Anna\")") + ); + assertEquals("1:26: [KQL] function cannot be used after EVAL", error("from test | eval z = 2 | where kql(\"Anna\")")); + assertEquals( + "1:44: [KQL] function cannot be used after GROK", + error("from test | grok last_name \"%{WORD:foo}\" | where kql(\"Anna\")") + ); + assertEquals("1:27: [KQL] function cannot be used after KEEP", error("from test | keep emp_no | where kql(\"Anna\")")); + assertEquals("1:24: [KQL] function cannot be used after LIMIT", error("from test | limit 10 | where kql(\"Anna\")")); + assertEquals("1:35: [KQL] function cannot be used after MV_EXPAND", error("from test | mv_expand last_name | where kql(\"Anna\")")); + assertEquals( + "1:45: [KQL] function cannot be used after RENAME", + error("from test | rename last_name as full_name | where kql(\"Anna\")") + ); + assertEquals( + "1:52: [KQL] function cannot be used after STATS", + error("from test | STATS c = COUNT(emp_no) BY languages | where kql(\"Anna\")") + ); + + // Some combination of processing commands + assertEquals("1:38: [KQL] function cannot be used after LIMIT", error("from test | keep emp_no | limit 10 | where kql(\"Anna\")")); + assertEquals( + "1:46: [KQL] function cannot be used after MV_EXPAND", + error("from test | limit 10 | mv_expand last_name | where kql(\"Anna\")") + ); + assertEquals( + "1:52: [KQL] function cannot be used after KEEP", + error("from test | mv_expand last_name | keep last_name | where kql(\"Anna\")") + ); + assertEquals( + "1:77: [KQL] function cannot be used after RENAME", + error("from test | STATS c = COUNT(emp_no) BY languages | rename c as total_emps | where kql(\"Anna\")") + ); + assertEquals( + "1:54: [KQL] function cannot be used after DROP", + error("from test | rename last_name as name | drop emp_no | where kql(\"Anna\")") + ); + } + public void testQueryStringFunctionOnlyAllowedInWhere() throws Exception { assertEquals("1:9: [QSTR] function is only supported in WHERE commands", error("row a = qstr(\"Anna\")")); checkFullTextFunctionsOnlyAllowedInWhere("QSTR", "qstr(\"Anna\")", "function"); } + public void testKqlFunctionOnlyAllowedInWhere() throws Exception { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + assertEquals("1:9: [KQL] function is only supported in WHERE commands", error("row a = kql(\"Anna\")")); + checkFullTextFunctionsOnlyAllowedInWhere("KQL", "kql(\"Anna\")", "function"); + } + public void testMatchFunctionOnlyAllowedInWhere() throws Exception { checkFullTextFunctionsOnlyAllowedInWhere("MATCH", "match(first_name, \"Anna\")", "function"); } @@ -1309,10 +1372,29 @@ public void testQueryStringFunctionArgNotNullOrConstant() throws Exception { // Other value types are tested in QueryStringFunctionTests } + public void testKqlFunctionArgNotNullOrConstant() throws Exception { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + assertEquals( + "1:19: argument of [kql(first_name)] must be a constant, received [first_name]", + error("from test | where kql(first_name)") + ); + assertEquals("1:19: argument of [kql(null)] cannot be null, received [null]", error("from test | where kql(null)")); + // Other value types are tested in KqlFunctionTests + } + public void testQueryStringWithDisjunctions() { checkWithDisjunctions("QSTR", "qstr(\"first_name: Anna\")", "function"); } + public void testKqlFunctionWithDisjunctions() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + checkWithDisjunctions("KQL", "kql(\"first_name: Anna\")", "function"); + } + public void testMatchFunctionWithDisjunctions() { checkWithDisjunctions("MATCH", "match(first_name, \"Anna\")", "function"); } @@ -1368,6 +1450,13 @@ public void testQueryStringFunctionWithNonBooleanFunctions() { checkFullTextFunctionsWithNonBooleanFunctions("QSTR", "qstr(\"first_name: Anna\")", "function"); } + public void testKqlFunctionWithNonBooleanFunctions() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + checkFullTextFunctionsWithNonBooleanFunctions("KQL", "kql(\"first_name: Anna\")", "function"); + } + public void testMatchFunctionWithNonBooleanFunctions() { checkFullTextFunctionsWithNonBooleanFunctions("MATCH", "match(first_name, \"Anna\")", "function"); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java new file mode 100644 index 0000000000000..d97be6b169eef --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java @@ -0,0 +1,41 @@ +/* + * 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.fulltext; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.junit.BeforeClass; + +import java.util.List; +import java.util.function.Supplier; + +public class KqlTests extends NoneFieldFullTextFunctionTestCase { + @BeforeClass + protected static void ensureKqlFunctionEnabled() { + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + } + + public KqlTests(@Name("TestCase") Supplier testCaseSupplier) { + super(testCaseSupplier); + } + + @ParametersFactory + public static Iterable parameters() { + return generateParameters(); + } + + @Override + protected Expression build(Source source, List args) { + return new Kql(source, args.get(0)); + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java new file mode 100644 index 0000000000000..383cb8671053d --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java @@ -0,0 +1,62 @@ +/* + * 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.fulltext; + +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.hamcrest.Matcher; + +import java.util.LinkedList; +import java.util.List; +import java.util.function.Supplier; + +import static org.hamcrest.Matchers.equalTo; + +public abstract class NoneFieldFullTextFunctionTestCase extends AbstractFunctionTestCase { + + public NoneFieldFullTextFunctionTestCase(Supplier testCaseSupplier) { + this.testCase = testCaseSupplier.get(); + } + + public final void testFold() { + Expression expression = buildLiteralExpression(testCase); + if (testCase.getExpectedTypeError() != null) { + assertTypeResolutionFailure(expression); + return; + } + assertFalse("expected resolved", expression.typeResolved().unresolved()); + } + + protected static Iterable generateParameters() { + List suppliers = new LinkedList<>(); + for (DataType strType : DataType.stringTypes()) { + suppliers.add( + new TestCaseSupplier( + "<" + strType + ">", + List.of(strType), + () -> testCase(strType, randomAlphaOfLengthBetween(1, 10), equalTo(true)) + ) + ); + } + List errorsSuppliers = errorsForCasesWithoutExamples(suppliers, (v, p) -> "string"); + // Don't test null, as it is not allowed but the expected message is not a type error - so we check it separately in VerifierTests + return parameterSuppliersFromTypedData(errorsSuppliers.stream().filter(s -> s.types().contains(DataType.NULL) == false).toList()); + } + + private static TestCaseSupplier.TestCase testCase(DataType strType, String str, Matcher matcher) { + return new TestCaseSupplier.TestCase( + List.of(new TestCaseSupplier.TypedData(new BytesRef(str), strType, "query")), + "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]", + DataType.BOOLEAN, + matcher + ); + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java index b4b4ebcaacde6..f573e59ab205a 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java @@ -10,61 +10,24 @@ import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; -import org.apache.lucene.util.BytesRef; import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.tree.Source; -import org.elasticsearch.xpack.esql.core.type.DataType; -import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase; import org.elasticsearch.xpack.esql.expression.function.FunctionName; import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; -import org.hamcrest.Matcher; -import java.util.LinkedList; import java.util.List; import java.util.function.Supplier; -import static org.hamcrest.Matchers.equalTo; - @FunctionName("qstr") -public class QueryStringTests extends AbstractFunctionTestCase { +public class QueryStringTests extends NoneFieldFullTextFunctionTestCase { public QueryStringTests(@Name("TestCase") Supplier testCaseSupplier) { - this.testCase = testCaseSupplier.get(); + super(testCaseSupplier); } @ParametersFactory public static Iterable parameters() { - List suppliers = new LinkedList<>(); - for (DataType strType : DataType.stringTypes()) { - suppliers.add( - new TestCaseSupplier( - "<" + strType + ">", - List.of(strType), - () -> testCase(strType, randomAlphaOfLengthBetween(1, 10), equalTo(true)) - ) - ); - } - List errorsSuppliers = errorsForCasesWithoutExamples(suppliers, (v, p) -> "string"); - // Don't test null, as it is not allowed but the expected message is not a type error - so we check it separately in VerifierTests - return parameterSuppliersFromTypedData(errorsSuppliers.stream().filter(s -> s.types().contains(DataType.NULL) == false).toList()); - } - - public final void testFold() { - Expression expression = buildLiteralExpression(testCase); - if (testCase.getExpectedTypeError() != null) { - assertTypeResolutionFailure(expression); - return; - } - assertFalse("expected resolved", expression.typeResolved().unresolved()); - } - - private static TestCaseSupplier.TestCase testCase(DataType strType, String str, Matcher matcher) { - return new TestCaseSupplier.TestCase( - List.of(new TestCaseSupplier.TypedData(new BytesRef(str), strType, "query")), - "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]", - DataType.BOOLEAN, - matcher - ); + return generateParameters(); } @Override diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java index 269b4806680a6..4612ccb425ba2 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java @@ -26,6 +26,7 @@ import org.elasticsearch.xpack.core.enrich.EnrichPolicy; import org.elasticsearch.xpack.esql.EsqlTestUtils; import org.elasticsearch.xpack.esql.EsqlTestUtils.TestSearchStats; +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.EnrichResolution; @@ -62,6 +63,7 @@ import org.elasticsearch.xpack.esql.stats.Metrics; import org.elasticsearch.xpack.esql.stats.SearchContextStats; import org.elasticsearch.xpack.esql.stats.SearchStats; +import org.elasticsearch.xpack.kql.query.KqlQueryBuilder; import org.junit.Before; import java.io.IOException; @@ -678,7 +680,7 @@ public void testMatchFunctionMultipleWhereClauses() { * \_EsQueryExec[test], indexMode[standard], query[{"bool":{"must":[{"match":{"last_name":{"query":"Smith"}}}, * {"match":{"first_name":{"query":"John"}}}],"boost":1.0}}][_doc{f}#14], limit[1000], sort[] estimatedRowSize[324] */ - public void testMatchFunctionMultipleQstrClauses() { + public void testMatchFunctionMultipleMatchClauses() { String queryText = """ from test | where match(last_name, "Smith") and match(first_name, "John") @@ -698,6 +700,182 @@ public void testMatchFunctionMultipleQstrClauses() { assertThat(query.query().toString(), is(expected.toString())); } + /** + * Expecting + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na + * me{f}#6, long_noidx{f}#11, salary{f}#7],false] + * \_ProjectExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na + * me{f}#6, long_noidx{f}#11, salary{f}#7]] + * \_FieldExtractExec[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gen] + * \_EsQueryExec[test], indexMode[standard], query[{"kql":{"query":"last_name: Smith"}}] + */ + public void testKqlFunction() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + var plan = plannerOptimizer.plan(""" + from test + | where kql("last_name: Smith") + """, IS_SV_STATS); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(query.limit().fold(), is(1000)); + var expected = kqlQueryBuilder("last_name: Smith"); + assertThat(query.query().toString(), is(expected.toString())); + } + + /** + * Expecting + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#1419, emp_no{f}#1413, first_name{f}#1414, gender{f}#1415, job{f}#1420, job.raw{f}#1421, langua + * ges{f}#1416, last_name{f}#1417, long_noidx{f}#1422, salary{f}#1418],false] + * \_ProjectExec[[_meta_field{f}#1419, emp_no{f}#1413, first_name{f}#1414, gender{f}#1415, job{f}#1420, job.raw{f}#1421, langua + * ges{f}#1416, last_name{f}#1417, long_noidx{f}#1422, salary{f}#1418]] + * \_FieldExtractExec[_meta_field{f}#1419, emp_no{f}#1413, first_name{f}#] + * \_EsQueryExec[test], indexMode[standard], query[{"bool":{"must":[{"kql":{"query":"last_name: Smith"}} + * ,{"esql_single_value":{"field":"emp_no","next":{"range":{"emp_no":{"gt":10010,"boost":1.0}}},"source":"emp_no > 10010"}}], + * "boost":1.0}}][_doc{f}#1423], limit[1000], sort[] estimatedRowSize[324] + */ + public void testKqlFunctionConjunctionWhereOperands() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + String queryText = """ + from test + | where kql("last_name: Smith") and emp_no > 10010 + """; + var plan = plannerOptimizer.plan(queryText, IS_SV_STATS); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(query.limit().fold(), is(1000)); + + Source filterSource = new Source(2, 36, "emp_no > 10000"); + var range = wrapWithSingleQuery(queryText, QueryBuilders.rangeQuery("emp_no").gt(10010), "emp_no", filterSource); + var kqlQuery = kqlQueryBuilder("last_name: Smith"); + var expected = QueryBuilders.boolQuery().must(kqlQuery).must(range); + assertThat(query.query().toString(), is(expected.toString())); + } + + /** + * Expecting + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, double{f}#8, float{f}#9, half_ + * float{f}#10, integer{f}#12, ip{f}#13, keyword{f}#14, long{f}#15, scaled_float{f}#11, short{f}#17, text{f}#18, unsigned_long{f}#16], + * false] + * \_ProjectExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, double{f}#8, float{f}#9, half_ + * float{f}#10, integer{f}#12, ip{f}#13, keyword{f}#14, long{f}#15, scaled_float{f}#11, short{f}#17, text{f}#18, unsigned_long{f}#16] + * \_FieldExtractExec[!alias_integer, boolean{f}#4, byte{f}#5, constant_k..] + * \_EsQueryExec[test], indexMode[standard], query[{"bool":{"must":[{"kql":{"query":"last_name: Smith"}},{ + * "esql_single_value":{"field":"ip","next":{"terms":{"ip":["127.0.0.1/32"],"boost":1.0}}, + * "source":"cidr_match(ip, \"127.0.0.1/32\")@2:38"}}],"boost":1.0}}][_doc{f}#21], limit[1000], sort[] estimatedRowSize[354] + */ + public void testKqlFunctionWithFunctionsPushedToLucene() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + String queryText = """ + from test + | where kql("last_name: Smith") and cidr_match(ip, "127.0.0.1/32") + """; + var analyzer = makeAnalyzer("mapping-all-types.json"); + var plan = plannerOptimizer.plan(queryText, IS_SV_STATS, analyzer); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(query.limit().fold(), is(1000)); + + Source filterSource = new Source(2, 36, "cidr_match(ip, \"127.0.0.1/32\")"); + var terms = wrapWithSingleQuery(queryText, QueryBuilders.termsQuery("ip", "127.0.0.1/32"), "ip", filterSource); + var kqlQuery = kqlQueryBuilder("last_name: Smith"); + var expected = QueryBuilders.boolQuery().must(kqlQuery).must(terms); + assertThat(query.query().toString(), is(expected.toString())); + } + + /** + * Expecting + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#1163, emp_no{f}#1157, first_name{f}#1158, gender{f}#1159, job{f}#1164, job.raw{f}#1165, langua + * ges{f}#1160, last_name{f}#1161, long_noidx{f}#1166, salary{f}#1162],false] + * \_ProjectExec[[_meta_field{f}#1163, emp_no{f}#1157, first_name{f}#1158, gender{f}#1159, job{f}#1164, job.raw{f}#1165, langua + * ges{f}#1160, last_name{f}#1161, long_noidx{f}#1166, salary{f}#1162]] + * \_FieldExtractExec[_meta_field{f}#1163, emp_no{f}#1157, first_name{f}#] + * \_EsQueryExec[test], indexMode[standard], + * query[{"bool":{"must":[{"kql":{"query":"last_name: Smith"}}, + * {"esql_single_value":{"field":"emp_no","next":{"range":{"emp_no":{"gt":10010,"boost":1.0}}},"source":"emp_no > 10010@3:9"}}], + * "boost":1.0}}][_doc{f}#1167], limit[1000], sort[] estimatedRowSize[324] + */ + public void testKqlFunctionMultipleWhereClauses() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + String queryText = """ + from test + | where kql("last_name: Smith") + | where emp_no > 10010 + """; + var plan = plannerOptimizer.plan(queryText, IS_SV_STATS); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(query.limit().fold(), is(1000)); + + Source filterSource = new Source(3, 8, "emp_no > 10000"); + var range = wrapWithSingleQuery(queryText, QueryBuilders.rangeQuery("emp_no").gt(10010), "emp_no", filterSource); + var kqlQuery = kqlQueryBuilder("last_name: Smith"); + var expected = QueryBuilders.boolQuery().must(kqlQuery).must(range); + assertThat(query.query().toString(), is(expected.toString())); + } + + /** + * Expecting + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na + * me{f}#6, long_noidx{f}#11, salary{f}#7],false] + * \_ProjectExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na + * me{f}#6, long_noidx{f}#11, salary{f}#7]] + * \_FieldExtractExec[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gen] + * \_EsQueryExec[test], indexMode[standard], query[{"bool": {"must":[ + * {"kql":{"query":"last_name: Smith"}}, + * {"kql":{"query":"emp_no > 10010"}}],"boost":1.0}}] + */ + public void testKqlFunctionMultipleKqlClauses() { + // Skip test if the kql function is not enabled. + assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()); + + String queryText = """ + from test + | where kql("last_name: Smith") and kql("emp_no > 10010") + """; + var plan = plannerOptimizer.plan(queryText, IS_SV_STATS); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(query.limit().fold(), is(1000)); + + var kqlQueryLeft = kqlQueryBuilder("last_name: Smith"); + var kqlQueryRight = kqlQueryBuilder("emp_no > 10010"); + var expected = QueryBuilders.boolQuery().must(kqlQueryLeft).must(kqlQueryRight); + assertThat(query.query().toString(), is(expected.toString())); + } + // optimizer doesn't know yet how to break down different multi count public void testCountFieldsAndAllWithFilter() { var plan = plannerOptimizer.plan(""" @@ -1166,4 +1344,8 @@ private Stat queryStatsFor(PhysicalPlan plan) { protected List filteredWarnings() { return withDefaultLimitWarning(super.filteredWarnings()); } + + private static KqlQueryBuilder kqlQueryBuilder(String query) { + return new KqlQueryBuilder(query); + } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQueryTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQueryTests.java new file mode 100644 index 0000000000000..8dfb50f84ac1e --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQueryTests.java @@ -0,0 +1,139 @@ +/* + * 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.querydsl.query; + +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.tree.SourceTests; +import org.elasticsearch.xpack.esql.core.util.StringUtils; +import org.elasticsearch.xpack.kql.query.KqlQueryBuilder; + +import java.time.ZoneId; +import java.time.zone.ZoneRulesException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.elasticsearch.test.EqualsHashCodeTestUtils.checkEqualsAndHashCode; +import static org.hamcrest.Matchers.equalTo; + +public class KqlQueryTests extends ESTestCase { + static KqlQuery randomKqkQueryQuery() { + Map options = new HashMap<>(); + + if (randomBoolean()) { + options.put(KqlQueryBuilder.CASE_INSENSITIVE_FIELD.getPreferredName(), String.valueOf(randomBoolean())); + } + + if (randomBoolean()) { + options.put(KqlQueryBuilder.DEFAULT_FIELD_FIELD.getPreferredName(), randomIdentifier()); + } + + if (randomBoolean()) { + options.put(KqlQueryBuilder.TIME_ZONE_FIELD.getPreferredName(), randomZone().getId()); + } + + return new KqlQuery(SourceTests.randomSource(), randomAlphaOfLength(5), Collections.unmodifiableMap(options)); + } + + public void testEqualsAndHashCode() { + for (int runs = 0; runs < 100; runs++) { + checkEqualsAndHashCode(randomKqkQueryQuery(), KqlQueryTests::copy, KqlQueryTests::mutate); + } + } + + private static KqlQuery copy(KqlQuery query) { + return new KqlQuery(query.source(), query.query(), query.options()); + } + + private static KqlQuery mutate(KqlQuery query) { + List> options = Arrays.asList( + q -> new KqlQuery(SourceTests.mutate(q.source()), q.query(), q.options()), + q -> new KqlQuery(q.source(), randomValueOtherThan(q.query(), () -> randomAlphaOfLength(5)), q.options()), + q -> new KqlQuery(q.source(), q.query(), mutateOptions(q.options())) + ); + + return randomFrom(options).apply(query); + } + + private static Map mutateOptions(Map options) { + Map mutatedOptions = new HashMap<>(options); + if (options.isEmpty() == false && randomBoolean()) { + mutatedOptions = options.entrySet() + .stream() + .filter(entry -> randomBoolean()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + while (mutatedOptions.equals(options)) { + if (randomBoolean()) { + mutatedOptions = mutateOption( + mutatedOptions, + KqlQueryBuilder.CASE_INSENSITIVE_FIELD.getPreferredName(), + () -> String.valueOf(randomBoolean()) + ); + } + + if (randomBoolean()) { + mutatedOptions = mutateOption( + mutatedOptions, + KqlQueryBuilder.DEFAULT_FIELD_FIELD.getPreferredName(), + () -> randomIdentifier() + ); + } + + if (randomBoolean()) { + mutatedOptions = mutateOption( + mutatedOptions, + KqlQueryBuilder.TIME_ZONE_FIELD.getPreferredName(), + () -> randomZone().getId() + ); + } + } + + return Collections.unmodifiableMap(mutatedOptions); + } + + private static Map mutateOption(Map options, String optionName, Supplier valueSupplier) { + options = new HashMap<>(options); + options.put(optionName, randomValueOtherThan(options.get(optionName), valueSupplier)); + return options; + } + + public void testQueryBuilding() { + KqlQueryBuilder qb = getBuilder(Map.of("case_insensitive", "false")); + assertThat(qb.caseInsensitive(), equalTo(false)); + + qb = getBuilder(Map.of("case_insensitive", "false", "time_zone", "UTC", "default_field", "foo")); + assertThat(qb.caseInsensitive(), equalTo(false)); + assertThat(qb.timeZone(), equalTo(ZoneId.of("UTC"))); + assertThat(qb.defaultField(), equalTo("foo")); + + Exception e = expectThrows(IllegalArgumentException.class, () -> getBuilder(Map.of("pizza", "yummy"))); + assertThat(e.getMessage(), equalTo("illegal kql query option [pizza]")); + + e = expectThrows(ZoneRulesException.class, () -> getBuilder(Map.of("time_zone", "aoeu"))); + assertThat(e.getMessage(), equalTo("Unknown time-zone ID: aoeu")); + } + + private static KqlQueryBuilder getBuilder(Map options) { + final Source source = new Source(1, 1, StringUtils.EMPTY); + final KqlQuery kqlQuery = new KqlQuery(source, "eggplant", options); + return (KqlQueryBuilder) kqlQuery.asBuilder(); + } + + public void testToString() { + final Source source = new Source(1, 1, StringUtils.EMPTY); + final KqlQuery kqlQuery = new KqlQuery(source, "eggplant", Map.of()); + assertEquals("KqlQuery@1:2[eggplant]", kqlQuery.toString()); + } +} diff --git a/x-pack/plugin/kql/src/main/java/org/elasticsearch/xpack/kql/query/KqlQueryBuilder.java b/x-pack/plugin/kql/src/main/java/org/elasticsearch/xpack/kql/query/KqlQueryBuilder.java index 5dff9126b6be4..e2817665d8f79 100644 --- a/x-pack/plugin/kql/src/main/java/org/elasticsearch/xpack/kql/query/KqlQueryBuilder.java +++ b/x-pack/plugin/kql/src/main/java/org/elasticsearch/xpack/kql/query/KqlQueryBuilder.java @@ -17,6 +17,7 @@ import org.elasticsearch.index.query.AbstractQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryRewriteContext; +import org.elasticsearch.index.query.QueryShardException; import org.elasticsearch.index.query.SearchExecutionContext; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; @@ -26,6 +27,7 @@ import org.elasticsearch.xcontent.XContentParser; import org.elasticsearch.xpack.kql.parser.KqlParser; import org.elasticsearch.xpack.kql.parser.KqlParsingContext; +import org.elasticsearch.xpack.kql.parser.KqlParsingException; import java.io.IOException; import java.time.ZoneId; @@ -37,9 +39,9 @@ public class KqlQueryBuilder extends AbstractQueryBuilder { public static final String NAME = "kql"; public static final ParseField QUERY_FIELD = new ParseField("query"); - private static final ParseField CASE_INSENSITIVE_FIELD = new ParseField("case_insensitive"); - private static final ParseField TIME_ZONE_FIELD = new ParseField("time_zone"); - private static final ParseField DEFAULT_FIELD_FIELD = new ParseField("default_field"); + public static final ParseField CASE_INSENSITIVE_FIELD = new ParseField("case_insensitive"); + public static final ParseField TIME_ZONE_FIELD = new ParseField("time_zone"); + public static final ParseField DEFAULT_FIELD_FIELD = new ParseField("default_field"); private static final Logger log = LogManager.getLogger(KqlQueryBuilder.class); private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>(NAME, a -> { @@ -151,12 +153,16 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep @Override protected QueryBuilder doIndexMetadataRewrite(QueryRewriteContext context) throws IOException { - KqlParser parser = new KqlParser(); - QueryBuilder rewrittenQuery = parser.parseKqlQuery(query, createKqlParserContext(context)); + try { + KqlParser parser = new KqlParser(); + QueryBuilder rewrittenQuery = parser.parseKqlQuery(query, createKqlParserContext(context)); - log.trace(() -> Strings.format("KQL query %s translated to Query DSL: %s", query, Strings.toString(rewrittenQuery))); + log.trace(() -> Strings.format("KQL query %s translated to Query DSL: %s", query, Strings.toString(rewrittenQuery))); - return rewrittenQuery; + return rewrittenQuery; + } catch (KqlParsingException e) { + throw new QueryShardException(context, "Failed to parse KQL query [{}]", e, query); + } } @Override diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml index 72c7c51655378..f7dd979540afa 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml @@ -92,7 +92,7 @@ setup: - gt: {esql.functions.to_long: $functions_to_long} - match: {esql.functions.coalesce: $functions_coalesce} # Testing for the entire function set isn't feasbile, so we just check that we return the correct count as an approximation. - - length: {esql.functions: 121} # check the "sister" test below for a likely update to the same esql.functions length check + - length: {esql.functions: 122} # check the "sister" test below for a likely update to the same esql.functions length check --- "Basic ESQL usage output (telemetry) non-snapshot version": From fd6e8857bc9bf56895b3c53996a27c226a3a80fa Mon Sep 17 00:00:00 2001 From: Philippus Baalman Date: Mon, 25 Nov 2024 14:50:09 +0100 Subject: [PATCH 015/129] Mention `bbq_hnsw` for `m` and `ef_construction` options in docs (#117022) --- docs/reference/mapping/types/dense-vector.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/mapping/types/dense-vector.asciidoc b/docs/reference/mapping/types/dense-vector.asciidoc index 4c16f260c13e7..e6e11d6dd539f 100644 --- a/docs/reference/mapping/types/dense-vector.asciidoc +++ b/docs/reference/mapping/types/dense-vector.asciidoc @@ -338,12 +338,12 @@ by 32x at the cost of accuracy. See < Date: Mon, 25 Nov 2024 14:53:48 +0100 Subject: [PATCH 016/129] =?UTF-8?q?[ML]=20Unmuting=20ForecastIT=20=C2=BB?= =?UTF-8?q?=20testOverflowToDisk=20for=20Windows=20(#114807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the test on Jenkins caused flakiness in Windows. See #44609. Since we moved to Buildkite I am unmuting this test, assuming that Buildkite is not creating such deep directory structures as Jenkins (see this comment). We will mute this test again, if this assumption turns out to be wrong. Closes #44609. --- .../org/elasticsearch/xpack/ml/integration/ForecastIT.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/ForecastIT.java b/x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/ForecastIT.java index 447bca4f4e688..94fbde69e29c7 100644 --- a/x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/ForecastIT.java +++ b/x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/ForecastIT.java @@ -6,7 +6,6 @@ */ package org.elasticsearch.xpack.ml.integration; -import org.apache.lucene.util.Constants; import org.elasticsearch.ElasticsearchException; import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.action.support.master.AcknowledgedResponse; @@ -222,8 +221,6 @@ public void testMemoryStatus() { } public void testOverflowToDisk() throws Exception { - assumeFalse("https://github.com/elastic/elasticsearch/issues/44609", Constants.WINDOWS); - Detector.Builder detector = new Detector.Builder("mean", "value"); detector.setByFieldName("clientIP"); From c184b2277769854a102c02d70c7018a50056395c Mon Sep 17 00:00:00 2001 From: Martijn van Groningen Date: Mon, 25 Nov 2024 15:00:44 +0100 Subject: [PATCH 017/129] Stop using _source.mode attribute in builtin templates (#117448) Use index.source.mode index setting in builtin templates instead of the deprecated _source.mode mapping attribute. --- .../metrics-apm@mappings.yaml | 2 -- .../metrics-apm@settings.yaml | 16 +++++++++------- .../apm-data/src/main/resources/resources.yaml | 2 +- .../component-template/profiling-events.json | 8 +++++--- .../profiling-executables.json | 10 ++++++---- .../component-template/profiling-metrics.json | 8 +++++--- .../profiling-stacktraces.json | 8 +++++--- .../index-template/profiling-sq-executables.json | 10 ++++++---- .../index-template/profiling-sq-leafframes.json | 10 ++++++---- .../ProfilingIndexTemplateRegistry.java | 2 +- 10 files changed, 44 insertions(+), 32 deletions(-) diff --git a/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@mappings.yaml b/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@mappings.yaml index af28cbb7415a0..660db3a6b0e2e 100644 --- a/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@mappings.yaml +++ b/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@mappings.yaml @@ -5,8 +5,6 @@ _meta: managed: true template: mappings: - _source: - mode: synthetic properties: processor.event: type: constant_keyword diff --git a/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@settings.yaml b/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@settings.yaml index 819d5d7eafb8e..d8fc13bce79b1 100644 --- a/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@settings.yaml +++ b/x-pack/plugin/apm-data/src/main/resources/component-templates/metrics-apm@settings.yaml @@ -5,10 +5,12 @@ _meta: managed: true template: settings: - codec: best_compression - mapping: - # apm@settings sets `ignore_malformed: true`, but we need - # to disable this for metrics since they use synthetic source, - # and this combination is incompatible with the - # aggregate_metric_double field type. - ignore_malformed: false + index: + codec: best_compression + mapping: + # apm@settings sets `ignore_malformed: true`, but we need + # to disable this for metrics since they use synthetic source, + # and this combination is incompatible with the + # aggregate_metric_double field type. + ignore_malformed: false + source.mode: synthetic diff --git a/x-pack/plugin/apm-data/src/main/resources/resources.yaml b/x-pack/plugin/apm-data/src/main/resources/resources.yaml index fa209cdec3695..9484f577583eb 100644 --- a/x-pack/plugin/apm-data/src/main/resources/resources.yaml +++ b/x-pack/plugin/apm-data/src/main/resources/resources.yaml @@ -1,7 +1,7 @@ # "version" holds the version of the templates and ingest pipelines installed # by xpack-plugin apm-data. This must be increased whenever an existing template or # pipeline is changed, in order for it to be updated on Elasticsearch upgrade. -version: 11 +version: 12 component-templates: # Data lifecycle. diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-events.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-events.json index f90d2202db0d3..c7424571dd678 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-events.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-events.json @@ -15,14 +15,16 @@ "container.name", "process.thread.name" ] + }, + "mapping": { + "source": { + "mode": "synthetic" + } } }, "codec": "best_compression" }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.events.version}, diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-executables.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-executables.json index f1e5e01d50c16..ac72a03202646 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-executables.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-executables.json @@ -5,13 +5,15 @@ "auto_expand_replicas": "0-1", "refresh_interval": "10s", "hidden": true, - "lifecycle.rollover_alias": "profiling-executables" + "lifecycle.rollover_alias": "profiling-executables", + "mapping": { + "source": { + "mode": "synthetic" + } + } } }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.executables.version}, diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-metrics.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-metrics.json index 35f53a36b2d0b..bb893a07c70a1 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-metrics.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-metrics.json @@ -10,14 +10,16 @@ "@timestamp", "host.id" ] + }, + "mapping": { + "source": { + "mode": "synthetic" + } } }, "codec": "best_compression" }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.metrics.version}, diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-stacktraces.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-stacktraces.json index 6c96fb21673ae..1170e3a32d8e2 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-stacktraces.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/component-template/profiling-stacktraces.json @@ -11,13 +11,15 @@ "field": [ "Stacktrace.frame.ids" ] + }, + "mapping": { + "source": { + "mode": "synthetic" + } } } }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.stacktraces.version}, diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-executables.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-executables.json index 71c4d15989b7a..d5d24a22fc58e 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-executables.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-executables.json @@ -7,13 +7,15 @@ "index": { "auto_expand_replicas": "0-1", "refresh_interval": "10s", - "hidden": true + "hidden": true, + "mapping": { + "source": { + "mode": "synthetic" + } + } } }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.sq.executables.version}, diff --git a/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-leafframes.json b/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-leafframes.json index 20849bfe8f27d..b56b4b2874743 100644 --- a/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-leafframes.json +++ b/x-pack/plugin/core/template-resources/src/main/resources/profiling/index-template/profiling-sq-leafframes.json @@ -7,13 +7,15 @@ "index": { "auto_expand_replicas": "0-1", "refresh_interval": "10s", - "hidden": true + "hidden": true, + "mapping": { + "source": { + "mode": "synthetic" + } + } } }, "mappings": { - "_source": { - "mode": "synthetic" - }, "_meta": { "index-template-version": ${xpack.profiling.template.version}, "index-version": ${xpack.profiling.index.sq.leafframes.version}, diff --git a/x-pack/plugin/profiling/src/main/java/org/elasticsearch/xpack/profiling/persistence/ProfilingIndexTemplateRegistry.java b/x-pack/plugin/profiling/src/main/java/org/elasticsearch/xpack/profiling/persistence/ProfilingIndexTemplateRegistry.java index 7d8a474453c4c..71e8dcbff4ee6 100644 --- a/x-pack/plugin/profiling/src/main/java/org/elasticsearch/xpack/profiling/persistence/ProfilingIndexTemplateRegistry.java +++ b/x-pack/plugin/profiling/src/main/java/org/elasticsearch/xpack/profiling/persistence/ProfilingIndexTemplateRegistry.java @@ -54,7 +54,7 @@ public class ProfilingIndexTemplateRegistry extends IndexTemplateRegistry { // version 11: Added 'profiling.agent.protocol' keyword mapping to profiling-hosts // version 12: Added 'profiling.agent.env_https_proxy' keyword mapping to profiling-hosts // version 13: Added 'container.id' keyword mapping to profiling-events - public static final int INDEX_TEMPLATE_VERSION = 13; + public static final int INDEX_TEMPLATE_VERSION = 14; // history for individual indices / index templates. Only bump these for breaking changes that require to create a new index public static final int PROFILING_EVENTS_VERSION = 5; From fa9f2bff0edafae15816f9a77df979e3a38f4e9c Mon Sep 17 00:00:00 2001 From: florent-leborgne Date: Mon, 25 Nov 2024 15:13:23 +0100 Subject: [PATCH 018/129] Docs for starred esql queries in Kibana (#117468) --- docs/reference/esql/esql-kibana.asciidoc | 17 ++++++++++++++++- .../esql/esql-discover-query-history.png | Bin 290854 -> 217954 bytes .../esql/esql-discover-query-starred.png | Bin 0 -> 209828 bytes 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 docs/reference/images/esql/esql-discover-query-starred.png diff --git a/docs/reference/esql/esql-kibana.asciidoc b/docs/reference/esql/esql-kibana.asciidoc index 85969e19957af..87dd4d87fa8e3 100644 --- a/docs/reference/esql/esql-kibana.asciidoc +++ b/docs/reference/esql/esql-kibana.asciidoc @@ -106,12 +106,27 @@ detailed warning, expand the query bar, and click *warnings*. ==== Query history You can reuse your recent {esql} queries in the query bar. -In the query bar click *Show recent queries*. +In the query bar, click *Show recent queries*. You can then scroll through your recent queries: image::images/esql/esql-discover-query-history.png[align="center",size="50%"] +[discrete] +[[esql-kibana-starred-queries]] +==== Starred queries + +From the query history, you can mark some queries as favorite to find and access them faster later. + +In the query bar, click *Show recent queries*. + +From the **Recent** tab, you can star any queries you want. + +In the **Starred** tab, find all the queries you have previously starred. + +image::images/esql/esql-discover-query-starred.png[align="center",size="50%"] + + [discrete] [[esql-kibana-results-table]] === The results table diff --git a/docs/reference/images/esql/esql-discover-query-history.png b/docs/reference/images/esql/esql-discover-query-history.png index ff1d2ffa8b280eff5ba369c10e3e3db1fab4431f..eb064684af700cc791312dfe8aba2ee1122e4a53 100644 GIT binary patch literal 217954 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

-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 z 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

teHuRh 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

QIo;l*ZJvlj>rhM)zybs5z!JZr&(vP2yH!YK6J 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 literal 0 HcmV?d00001 From 9981a1d905590bb300a0f3c547d8c9cd1e251ddd Mon Sep 17 00:00:00 2001 From: Dan Rubinstein Date: Mon, 25 Nov 2024 10:27:31 -0500 Subject: [PATCH 019/129] Adding endpoint creation validation for all task types to remaining services (#115020) * Adding endpoint creation validation for all task types to remaining services * Update Cohere IT tests for rerank validation * Adding missing import * Update docs/changelog/115020.yaml * Fixing GoogleVertex tests after merge from upstream --------- Co-authored-by: Elastic Machine --- docs/changelog/115020.yaml | 5 ++ .../qa/mixed/CohereServiceMixedIT.java | 1 + .../application/CohereServiceUpgradeIT.java | 2 + .../AlibabaCloudSearchService.java | 76 ++++++------------- .../amazonbedrock/AmazonBedrockService.java | 64 ++++++---------- .../services/anthropic/AnthropicService.java | 7 ++ .../azureopenai/AzureOpenAiService.java | 62 ++++++--------- .../services/cohere/CohereService.java | 48 ++++++------ .../googlevertexai/GoogleVertexAiService.java | 57 +++++--------- .../ibmwatsonx/IbmWatsonxService.java | 46 +++++------ .../AlibabaCloudSearchServiceTests.java | 40 ++++++++++ .../AmazonBedrockServiceTests.java | 74 ++++++++++++++++++ .../azureopenai/AzureOpenAiServiceTests.java | 47 ++++++++++++ .../services/cohere/CohereServiceTests.java | 44 +++++++++++ .../GoogleVertexAiServiceTests.java | 34 +++++++++ .../GoogleVertexAiEmbeddingsModelTests.java | 33 +++++++- .../ibmwatsonx/IbmWatsonxServiceTests.java | 51 +++++++++++++ 17 files changed, 473 insertions(+), 218 deletions(-) create mode 100644 docs/changelog/115020.yaml diff --git a/docs/changelog/115020.yaml b/docs/changelog/115020.yaml new file mode 100644 index 0000000000000..2b0aefafea507 --- /dev/null +++ b/docs/changelog/115020.yaml @@ -0,0 +1,5 @@ +pr: 115020 +summary: Adding endpoint creation validation for all task types to remaining services +area: Machine Learning +type: enhancement +issues: [] diff --git a/x-pack/plugin/inference/qa/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/inference/qa/mixed/CohereServiceMixedIT.java b/x-pack/plugin/inference/qa/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/inference/qa/mixed/CohereServiceMixedIT.java index 8cb37ad645358..c16271ed44083 100644 --- a/x-pack/plugin/inference/qa/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/inference/qa/mixed/CohereServiceMixedIT.java +++ b/x-pack/plugin/inference/qa/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/inference/qa/mixed/CohereServiceMixedIT.java @@ -135,6 +135,7 @@ public void testRerank() throws IOException { final String inferenceId = "mixed-cluster-rerank"; + cohereRerankServer.enqueue(new MockResponse().setResponseCode(200).setBody(rerankResponse())); put(inferenceId, rerankConfig(getUrl(cohereRerankServer)), TaskType.RERANK); assertRerank(inferenceId); diff --git a/x-pack/plugin/inference/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/xpack/application/CohereServiceUpgradeIT.java b/x-pack/plugin/inference/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/xpack/application/CohereServiceUpgradeIT.java index 32969ffd1d112..0acbc148515bd 100644 --- a/x-pack/plugin/inference/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/xpack/application/CohereServiceUpgradeIT.java +++ b/x-pack/plugin/inference/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/xpack/application/CohereServiceUpgradeIT.java @@ -201,6 +201,7 @@ public void testRerank() throws IOException { var testTaskType = TaskType.RERANK; if (isOldCluster()) { + cohereRerankServer.enqueue(new MockResponse().setResponseCode(200).setBody(rerankResponse())); put(oldClusterId, rerankConfig(getUrl(cohereRerankServer)), testTaskType); var configs = (List>) get(testTaskType, oldClusterId).get(old_cluster_endpoint_identifier); assertThat(configs, hasSize(1)); @@ -229,6 +230,7 @@ public void testRerank() throws IOException { assertRerank(oldClusterId); // New endpoint + cohereRerankServer.enqueue(new MockResponse().setResponseCode(200).setBody(rerankResponse())); put(upgradedClusterId, rerankConfig(getUrl(cohereRerankServer)), testTaskType); configs = (List>) get(upgradedClusterId).get("endpoints"); assertThat(configs, hasSize(1)); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java index c84b4314b9d1a..6d77663f49ece 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java @@ -18,7 +18,6 @@ import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; -import org.elasticsearch.inference.InferenceService; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; import org.elasticsearch.inference.InputType; @@ -51,6 +50,7 @@ import org.elasticsearch.xpack.inference.services.alibabacloudsearch.sparse.AlibabaCloudSearchSparseModel; import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -60,7 +60,6 @@ import static org.elasticsearch.inference.TaskType.SPARSE_EMBEDDING; import static org.elasticsearch.inference.TaskType.TEXT_EMBEDDING; -import static org.elasticsearch.xpack.core.inference.action.InferenceAction.Request.DEFAULT_TIMEOUT; import static org.elasticsearch.xpack.inference.services.ServiceUtils.createInvalidModelException; import static org.elasticsearch.xpack.inference.services.ServiceUtils.parsePersistedConfigErrorMsg; import static org.elasticsearch.xpack.inference.services.ServiceUtils.removeFromMap; @@ -332,68 +331,39 @@ private EmbeddingRequestChunker.EmbeddingType getEmbeddingTypeFromTaskType(TaskT */ @Override public void checkModelConfig(Model model, ActionListener listener) { + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); + } + + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { if (model instanceof AlibabaCloudSearchEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) + var serviceSettings = embeddingsModel.getServiceSettings(); + + var updatedServiceSettings = new AlibabaCloudSearchEmbeddingsServiceSettings( + new AlibabaCloudSearchServiceSettings( + serviceSettings.getCommonSettings().modelId(), + serviceSettings.getCommonSettings().getHost(), + serviceSettings.getCommonSettings().getWorkspaceName(), + serviceSettings.getCommonSettings().getHttpSchema(), + serviceSettings.getCommonSettings().rateLimitSettings() + ), + SimilarityMeasure.DOT_PRODUCT, + embeddingSize, + serviceSettings.getMaxInputTokens() ); + + return new AlibabaCloudSearchEmbeddingsModel(embeddingsModel, updatedServiceSettings); } else { - checkAlibabaCloudSearchServiceConfig(model, this, listener); + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); } } - private AlibabaCloudSearchEmbeddingsModel updateModelWithEmbeddingDetails(AlibabaCloudSearchEmbeddingsModel model, int embeddingSize) { - AlibabaCloudSearchEmbeddingsServiceSettings serviceSettings = new AlibabaCloudSearchEmbeddingsServiceSettings( - new AlibabaCloudSearchServiceSettings( - model.getServiceSettings().getCommonSettings().modelId(), - model.getServiceSettings().getCommonSettings().getHost(), - model.getServiceSettings().getCommonSettings().getWorkspaceName(), - model.getServiceSettings().getCommonSettings().getHttpSchema(), - model.getServiceSettings().getCommonSettings().rateLimitSettings() - ), - SimilarityMeasure.DOT_PRODUCT, - embeddingSize, - model.getServiceSettings().getMaxInputTokens() - ); - - return new AlibabaCloudSearchEmbeddingsModel(model, serviceSettings); - } - @Override public TransportVersion getMinimalSupportedVersion() { return TransportVersions.ML_INFERENCE_ALIBABACLOUD_SEARCH_ADDED; } - /** - * For other models except of text embedding - * check the model's service settings and task settings - * - * @param model The new model - * @param service The inferenceService - * @param listener The listener - */ - private void checkAlibabaCloudSearchServiceConfig(Model model, InferenceService service, ActionListener listener) { - String input = ALIBABA_CLOUD_SEARCH_SERVICE_CONFIG_INPUT; - String query = model.getTaskType().equals(TaskType.RERANK) ? ALIBABA_CLOUD_SEARCH_SERVICE_CONFIG_QUERY : null; - - service.infer( - model, - query, - List.of(input), - false, - Map.of(), - InputType.INGEST, - DEFAULT_TIMEOUT, - listener.delegateFailureAndWrap((delegate, r) -> { - listener.onResponse(model); - }) - ); - } - - private static final String ALIBABA_CLOUD_SEARCH_SERVICE_CONFIG_INPUT = "input"; - private static final String ALIBABA_CLOUD_SEARCH_SERVICE_CONFIG_QUERY = "query"; - public static class Configuration { public static InferenceServiceConfiguration get() { return configuration.getOrCompute(); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java index f9822c7ab4af9..a69b9d2c70405 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java @@ -49,6 +49,7 @@ import org.elasticsearch.xpack.inference.services.amazonbedrock.embeddings.AmazonBedrockEmbeddingsModel; import org.elasticsearch.xpack.inference.services.amazonbedrock.embeddings.AmazonBedrockEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.io.IOException; import java.util.EnumSet; @@ -303,49 +304,34 @@ public Set supportedStreamingTasks() { */ @Override public void checkModelConfig(Model model, ActionListener listener) { - if (model instanceof AmazonBedrockEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) - ); - } else { - listener.onResponse(model); - } + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); } - private AmazonBedrockEmbeddingsModel updateModelWithEmbeddingDetails(AmazonBedrockEmbeddingsModel model, int embeddingSize) { - AmazonBedrockEmbeddingsServiceSettings serviceSettings = model.getServiceSettings(); - if (serviceSettings.dimensionsSetByUser() - && serviceSettings.dimensions() != null - && serviceSettings.dimensions() != embeddingSize) { - throw new ElasticsearchStatusException( - Strings.format( - "The retrieved embeddings size [%s] does not match the size specified in the settings [%s]. " - + "Please recreate the [%s] configuration with the correct dimensions", - embeddingSize, - serviceSettings.dimensions(), - model.getConfigurations().getInferenceEntityId() - ), - RestStatus.BAD_REQUEST + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { + if (model instanceof AmazonBedrockEmbeddingsModel embeddingsModel) { + var serviceSettings = embeddingsModel.getServiceSettings(); + var similarityFromModel = serviceSettings.similarity(); + var similarityToUse = similarityFromModel == null + ? getProviderDefaultSimilarityMeasure(embeddingsModel.provider()) + : similarityFromModel; + + var updatedServiceSettings = new AmazonBedrockEmbeddingsServiceSettings( + serviceSettings.region(), + serviceSettings.modelId(), + serviceSettings.provider(), + embeddingSize, + serviceSettings.dimensionsSetByUser(), + serviceSettings.maxInputTokens(), + similarityToUse, + serviceSettings.rateLimitSettings() ); - } - - var similarityFromModel = serviceSettings.similarity(); - var similarityToUse = similarityFromModel == null ? getProviderDefaultSimilarityMeasure(model.provider()) : similarityFromModel; - - AmazonBedrockEmbeddingsServiceSettings settingsToUse = new AmazonBedrockEmbeddingsServiceSettings( - serviceSettings.region(), - serviceSettings.modelId(), - serviceSettings.provider(), - embeddingSize, - serviceSettings.dimensionsSetByUser(), - serviceSettings.maxInputTokens(), - similarityToUse, - serviceSettings.rateLimitSettings() - ); - return new AmazonBedrockEmbeddingsModel(model, settingsToUse); + return new AmazonBedrockEmbeddingsModel(embeddingsModel, updatedServiceSettings); + } else { + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); + } } private static void checkProviderForTask(TaskType taskType, AmazonBedrockProvider provider) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java index 556b34b945c14..eba7353f2b12e 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java @@ -39,6 +39,7 @@ import org.elasticsearch.xpack.inference.services.anthropic.completion.AnthropicChatCompletionModel; import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -176,6 +177,12 @@ public AnthropicModel parsePersistedConfig(String inferenceEntityId, TaskType ta ); } + @Override + public void checkModelConfig(Model model, ActionListener listener) { + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); + } + @Override public InferenceServiceConfiguration getConfiguration() { return Configuration.get(); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java index 6d36e5f6c8fe7..2f3a935cdf010 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java @@ -11,7 +11,6 @@ import org.elasticsearch.TransportVersion; import org.elasticsearch.TransportVersions; import org.elasticsearch.action.ActionListener; -import org.elasticsearch.common.Strings; import org.elasticsearch.common.util.LazyInitializable; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; @@ -46,6 +45,7 @@ import org.elasticsearch.xpack.inference.services.azureopenai.embeddings.AzureOpenAiEmbeddingsModel; import org.elasticsearch.xpack.inference.services.azureopenai.embeddings.AzureOpenAiEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -294,48 +294,32 @@ protected void doChunkedInfer( */ @Override public void checkModelConfig(Model model, ActionListener listener) { - if (model instanceof AzureOpenAiEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) - ); - } else { - listener.onResponse(model); - } + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); } - private AzureOpenAiEmbeddingsModel updateModelWithEmbeddingDetails(AzureOpenAiEmbeddingsModel model, int embeddingSize) { - if (model.getServiceSettings().dimensionsSetByUser() - && model.getServiceSettings().dimensions() != null - && model.getServiceSettings().dimensions() != embeddingSize) { - throw new ElasticsearchStatusException( - Strings.format( - "The retrieved embeddings size [%s] does not match the size specified in the settings [%s]. " - + "Please recreate the [%s] configuration with the correct dimensions", - embeddingSize, - model.getServiceSettings().dimensions(), - model.getConfigurations().getInferenceEntityId() - ), - RestStatus.BAD_REQUEST + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { + if (model instanceof AzureOpenAiEmbeddingsModel embeddingsModel) { + var serviceSettings = embeddingsModel.getServiceSettings(); + var similarityFromModel = serviceSettings.similarity(); + var similarityToUse = similarityFromModel == null ? SimilarityMeasure.DOT_PRODUCT : similarityFromModel; + + var updatedServiceSettings = new AzureOpenAiEmbeddingsServiceSettings( + serviceSettings.resourceName(), + serviceSettings.deploymentId(), + serviceSettings.apiVersion(), + embeddingSize, + serviceSettings.dimensionsSetByUser(), + serviceSettings.maxInputTokens(), + similarityToUse, + serviceSettings.rateLimitSettings() ); - } - - var similarityFromModel = model.getServiceSettings().similarity(); - var similarityToUse = similarityFromModel == null ? SimilarityMeasure.DOT_PRODUCT : similarityFromModel; - - AzureOpenAiEmbeddingsServiceSettings serviceSettings = new AzureOpenAiEmbeddingsServiceSettings( - model.getServiceSettings().resourceName(), - model.getServiceSettings().deploymentId(), - model.getServiceSettings().apiVersion(), - embeddingSize, - model.getServiceSettings().dimensionsSetByUser(), - model.getServiceSettings().maxInputTokens(), - similarityToUse, - model.getServiceSettings().rateLimitSettings() - ); - return new AzureOpenAiEmbeddingsModel(model, serviceSettings); + return new AzureOpenAiEmbeddingsModel(embeddingsModel, updatedServiceSettings); + } else { + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); + } } @Override diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java index de1d055e160da..cc67470686a02 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java @@ -45,6 +45,7 @@ import org.elasticsearch.xpack.inference.services.cohere.rerank.CohereRerankModel; import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -293,36 +294,35 @@ protected void doChunkedInfer( */ @Override public void checkModelConfig(Model model, ActionListener listener) { + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); + } + + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { if (model instanceof CohereEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) + var serviceSettings = embeddingsModel.getServiceSettings(); + var similarityFromModel = serviceSettings.similarity(); + var similarityToUse = similarityFromModel == null ? defaultSimilarity() : similarityFromModel; + + var updatedServiceSettings = new CohereEmbeddingsServiceSettings( + new CohereServiceSettings( + serviceSettings.getCommonSettings().uri(), + similarityToUse, + embeddingSize, + serviceSettings.getCommonSettings().maxInputTokens(), + serviceSettings.getCommonSettings().modelId(), + serviceSettings.getCommonSettings().rateLimitSettings() + ), + serviceSettings.getEmbeddingType() ); + + return new CohereEmbeddingsModel(embeddingsModel, updatedServiceSettings); } else { - listener.onResponse(model); + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); } } - private CohereEmbeddingsModel updateModelWithEmbeddingDetails(CohereEmbeddingsModel model, int embeddingSize) { - var userDefinedSimilarity = model.getServiceSettings().similarity(); - var similarityToUse = userDefinedSimilarity == null ? defaultSimilarity() : userDefinedSimilarity; - - CohereEmbeddingsServiceSettings serviceSettings = new CohereEmbeddingsServiceSettings( - new CohereServiceSettings( - model.getServiceSettings().getCommonSettings().uri(), - similarityToUse, - embeddingSize, - model.getServiceSettings().getCommonSettings().maxInputTokens(), - model.getServiceSettings().getCommonSettings().modelId(), - model.getServiceSettings().getCommonSettings().rateLimitSettings() - ), - model.getServiceSettings().getEmbeddingType() - ); - - return new CohereEmbeddingsModel(model, serviceSettings); - } - /** * Return the default similarity measure for the embedding type. * Cohere embeddings are normalized to unit vectors therefor Dot diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java index a05b1a937d376..204593464a4ad 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java @@ -11,7 +11,6 @@ import org.elasticsearch.TransportVersion; import org.elasticsearch.TransportVersions; import org.elasticsearch.action.ActionListener; -import org.elasticsearch.common.Strings; import org.elasticsearch.common.util.LazyInitializable; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; @@ -45,6 +44,7 @@ import org.elasticsearch.xpack.inference.services.googlevertexai.embeddings.GoogleVertexAiEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.googlevertexai.rerank.GoogleVertexAiRerankModel; import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -181,15 +181,8 @@ public TransportVersion getMinimalSupportedVersion() { @Override public void checkModelConfig(Model model, ActionListener listener) { - if (model instanceof GoogleVertexAiEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) - ); - } else { - listener.onResponse(model); - } + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); } @Override @@ -240,34 +233,26 @@ protected void doChunkedInfer( } } - private GoogleVertexAiEmbeddingsModel updateModelWithEmbeddingDetails(GoogleVertexAiEmbeddingsModel model, int embeddingSize) { - if (model.getServiceSettings().dimensionsSetByUser() - && model.getServiceSettings().dimensions() != null - && model.getServiceSettings().dimensions() != embeddingSize) { - throw new ElasticsearchStatusException( - Strings.format( - "The retrieved embeddings size [%s] does not match the size specified in the settings [%s]. " - + "Please recreate the [%s] configuration with the correct dimensions", - embeddingSize, - model.getServiceSettings().dimensions(), - model.getConfigurations().getInferenceEntityId() - ), - RestStatus.BAD_REQUEST + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { + if (model instanceof GoogleVertexAiEmbeddingsModel embeddingsModel) { + var serviceSettings = embeddingsModel.getServiceSettings(); + + var updatedServiceSettings = new GoogleVertexAiEmbeddingsServiceSettings( + serviceSettings.location(), + serviceSettings.projectId(), + serviceSettings.modelId(), + serviceSettings.dimensionsSetByUser(), + serviceSettings.maxInputTokens(), + embeddingSize, + serviceSettings.similarity(), + serviceSettings.rateLimitSettings() ); - } - - GoogleVertexAiEmbeddingsServiceSettings serviceSettings = new GoogleVertexAiEmbeddingsServiceSettings( - model.getServiceSettings().location(), - model.getServiceSettings().projectId(), - model.getServiceSettings().modelId(), - model.getServiceSettings().dimensionsSetByUser(), - model.getServiceSettings().maxInputTokens(), - embeddingSize, - model.getServiceSettings().similarity(), - model.getServiceSettings().rateLimitSettings() - ); - return new GoogleVertexAiEmbeddingsModel(model, serviceSettings); + return new GoogleVertexAiEmbeddingsModel(embeddingsModel, updatedServiceSettings); + } else { + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); + } } private static GoogleVertexAiModel createModelFromPersistent( diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java index f4f4605c667c3..592900d117b39 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java @@ -44,6 +44,7 @@ import org.elasticsearch.xpack.inference.services.ServiceUtils; import org.elasticsearch.xpack.inference.services.ibmwatsonx.embeddings.IbmWatsonxEmbeddingsModel; import org.elasticsearch.xpack.inference.services.ibmwatsonx.embeddings.IbmWatsonxEmbeddingsServiceSettings; +import org.elasticsearch.xpack.inference.services.validation.ModelValidatorBuilder; import java.util.EnumSet; import java.util.HashMap; @@ -228,35 +229,34 @@ public TransportVersion getMinimalSupportedVersion() { @Override public void checkModelConfig(Model model, ActionListener listener) { + // TODO: Remove this function once all services have been updated to use the new model validators + ModelValidatorBuilder.buildModelValidator(model.getTaskType()).validate(this, model, listener); + } + + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { if (model instanceof IbmWatsonxEmbeddingsModel embeddingsModel) { - ServiceUtils.getEmbeddingSize( - model, - this, - listener.delegateFailureAndWrap((l, size) -> l.onResponse(updateModelWithEmbeddingDetails(embeddingsModel, size))) + var serviceSettings = embeddingsModel.getServiceSettings(); + var similarityFromModel = serviceSettings.similarity(); + var similarityToUse = similarityFromModel == null ? SimilarityMeasure.DOT_PRODUCT : similarityFromModel; + + var updatedServiceSettings = new IbmWatsonxEmbeddingsServiceSettings( + serviceSettings.modelId(), + serviceSettings.projectId(), + serviceSettings.url(), + serviceSettings.apiVersion(), + serviceSettings.maxInputTokens(), + embeddingSize, + similarityToUse, + serviceSettings.rateLimitSettings() ); + + return new IbmWatsonxEmbeddingsModel(embeddingsModel, updatedServiceSettings); } else { - listener.onResponse(model); + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); } } - private IbmWatsonxEmbeddingsModel updateModelWithEmbeddingDetails(IbmWatsonxEmbeddingsModel model, int embeddingSize) { - var similarityFromModel = model.getServiceSettings().similarity(); - var similarityToUse = similarityFromModel == null ? SimilarityMeasure.DOT_PRODUCT : similarityFromModel; - - IbmWatsonxEmbeddingsServiceSettings serviceSettings = new IbmWatsonxEmbeddingsServiceSettings( - model.getServiceSettings().modelId(), - model.getServiceSettings().projectId(), - model.getServiceSettings().url(), - model.getServiceSettings().apiVersion(), - model.getServiceSettings().maxInputTokens(), - embeddingSize, - similarityToUse, - model.getServiceSettings().rateLimitSettings() - ); - - return new IbmWatsonxEmbeddingsModel(model, serviceSettings); - } - @Override protected void doInfer( Model model, diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java index aac111c22558e..b6d29ccab9a49 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.inference.services.alibabacloudsearch; +import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.common.bytes.BytesArray; @@ -22,6 +23,7 @@ import org.elasticsearch.inference.InputType; import org.elasticsearch.inference.Model; import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.SimilarityMeasure; import org.elasticsearch.inference.TaskType; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.threadpool.ThreadPool; @@ -50,6 +52,7 @@ import org.elasticsearch.xpack.inference.services.alibabacloudsearch.embeddings.AlibabaCloudSearchEmbeddingsServiceSettingsTests; import org.elasticsearch.xpack.inference.services.alibabacloudsearch.embeddings.AlibabaCloudSearchEmbeddingsTaskSettingsTests; import org.elasticsearch.xpack.inference.services.alibabacloudsearch.sparse.AlibabaCloudSearchSparseModel; +import org.elasticsearch.xpack.inference.services.openai.completion.OpenAiChatCompletionModelTests; import org.hamcrest.MatcherAssert; import org.junit.After; import org.junit.Before; @@ -325,6 +328,43 @@ public void doInfer( } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new AlibabaCloudSearchService(senderFactory, createWithEmptySettings(threadPool))) { + var model = OpenAiChatCompletionModelTests.createChatCompletionModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10) + ); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_UpdatesEmbeddingSizeAndSimilarity() throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + try (var service = new AlibabaCloudSearchService(senderFactory, createWithEmptySettings(threadPool))) { + var embeddingSize = randomNonNegativeInt(); + var model = AlibabaCloudSearchEmbeddingsModelTests.createModel( + randomAlphaOfLength(10), + randomFrom(TaskType.values()), + AlibabaCloudSearchEmbeddingsServiceSettingsTests.createRandom(), + AlibabaCloudSearchEmbeddingsTaskSettingsTests.createRandom(), + null + ); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + assertEquals(SimilarityMeasure.DOT_PRODUCT, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + public void testChunkedInfer_TextEmbeddingChunkingSettingsSet() throws IOException { testChunkedInfer(TaskType.TEXT_EMBEDDING, ChunkingSettingsTests.createRandomChunkingSettings()); } diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java index e76fb10c96131..e583e50075ee7 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java @@ -50,6 +50,7 @@ import org.elasticsearch.xpack.inference.services.amazonbedrock.embeddings.AmazonBedrockEmbeddingsModel; import org.elasticsearch.xpack.inference.services.amazonbedrock.embeddings.AmazonBedrockEmbeddingsModelTests; import org.elasticsearch.xpack.inference.services.amazonbedrock.embeddings.AmazonBedrockEmbeddingsServiceSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettingsTests; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; @@ -72,6 +73,7 @@ import static org.elasticsearch.xpack.inference.results.ChatCompletionResultsTests.buildExpectationCompletion; import static org.elasticsearch.xpack.inference.results.TextEmbeddingResultsTests.buildExpectationFloat; import static org.elasticsearch.xpack.inference.services.ServiceComponentsTests.createWithEmptySettings; +import static org.elasticsearch.xpack.inference.services.amazonbedrock.AmazonBedrockProviderCapabilities.getProviderDefaultSimilarityMeasure; import static org.elasticsearch.xpack.inference.services.amazonbedrock.AmazonBedrockSecretSettingsTests.getAmazonBedrockSecretSettingsMap; import static org.elasticsearch.xpack.inference.services.amazonbedrock.completion.AmazonBedrockChatCompletionServiceSettingsTests.createChatCompletionRequestSettingsMap; import static org.elasticsearch.xpack.inference.services.amazonbedrock.completion.AmazonBedrockChatCompletionTaskSettingsTests.getChatCompletionTaskSettingsMap; @@ -1375,6 +1377,78 @@ public void testCheckModelConfig_ReturnsNewModelReference_AndDoesNotSendDimensio } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + var sender = mock(Sender.class); + var factory = mock(HttpRequestSender.Factory.class); + when(factory.createSender()).thenReturn(sender); + + var amazonBedrockFactory = new AmazonBedrockMockRequestSender.Factory( + ServiceComponentsTests.createWithSettings(threadPool, Settings.EMPTY), + mockClusterServiceEmpty() + ); + + try (var service = new AmazonBedrockService(factory, amazonBedrockFactory, createWithEmptySettings(threadPool))) { + var model = AmazonBedrockChatCompletionModelTests.createModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomFrom(AmazonBedrockProvider.values()), + randomAlphaOfLength(10), + randomAlphaOfLength(10) + ); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_NullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(null); + } + + public void testUpdateModelWithEmbeddingDetails_NonNullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(randomFrom(SimilarityMeasure.values())); + } + + private void testUpdateModelWithEmbeddingDetails_Successful(SimilarityMeasure similarityMeasure) throws IOException { + var sender = mock(Sender.class); + var factory = mock(HttpRequestSender.Factory.class); + when(factory.createSender()).thenReturn(sender); + + var amazonBedrockFactory = new AmazonBedrockMockRequestSender.Factory( + ServiceComponentsTests.createWithSettings(threadPool, Settings.EMPTY), + mockClusterServiceEmpty() + ); + + try (var service = new AmazonBedrockService(factory, amazonBedrockFactory, createWithEmptySettings(threadPool))) { + var embeddingSize = randomNonNegativeInt(); + var provider = randomFrom(AmazonBedrockProvider.values()); + var model = AmazonBedrockEmbeddingsModelTests.createModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + provider, + randomNonNegativeInt(), + randomBoolean(), + randomNonNegativeInt(), + similarityMeasure, + RateLimitSettingsTests.createRandom(), + createRandomChunkingSettings(), + randomAlphaOfLength(10), + randomAlphaOfLength(10) + ); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + SimilarityMeasure expectedSimilarityMeasure = similarityMeasure == null + ? getProviderDefaultSimilarityMeasure(provider) + : similarityMeasure; + assertEquals(expectedSimilarityMeasure, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + public void testInfer_UnauthorizedResponse() throws IOException { var sender = mock(Sender.class); var factory = mock(HttpRequestSender.Factory.class); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java index 40f8b7e0977e4..dc1970e26a3f8 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java @@ -1194,6 +1194,53 @@ public void testCheckModelConfig_ReturnsNewModelReference_AndDoesNotSendDimensio } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new AzureOpenAiService(senderFactory, createWithEmptySettings(threadPool))) { + var model = AzureOpenAiCompletionModelTests.createModelWithRandomValues(); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_NullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(null); + } + + public void testUpdateModelWithEmbeddingDetails_NonNullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(randomFrom(SimilarityMeasure.values())); + } + + private void testUpdateModelWithEmbeddingDetails_Successful(SimilarityMeasure similarityMeasure) throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new AzureOpenAiService(senderFactory, createWithEmptySettings(threadPool))) { + var embeddingSize = randomNonNegativeInt(); + var model = AzureOpenAiEmbeddingsModelTests.createModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomNonNegativeInt(), + randomBoolean(), + randomNonNegativeInt(), + similarityMeasure, + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10) + ); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + SimilarityMeasure expectedSimilarityMeasure = similarityMeasure == null ? SimilarityMeasure.DOT_PRODUCT : similarityMeasure; + assertEquals(expectedSimilarityMeasure, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + public void testInfer_UnauthorisedResponse() throws IOException, URISyntaxException { var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java index 725879e76efc1..30f3b344a268c 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java @@ -1074,6 +1074,50 @@ public void testCheckModelConfig_DoesNotUpdateSimilarity_WhenItIsSpecifiedAsCosi } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new CohereService(senderFactory, createWithEmptySettings(threadPool))) { + var model = CohereCompletionModelTests.createModel(randomAlphaOfLength(10), randomAlphaOfLength(10), randomAlphaOfLength(10)); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_NullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(null); + } + + public void testUpdateModelWithEmbeddingDetails_NonNullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(randomFrom(SimilarityMeasure.values())); + } + + private void testUpdateModelWithEmbeddingDetails_Successful(SimilarityMeasure similarityMeasure) throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new CohereService(senderFactory, createWithEmptySettings(threadPool))) { + var embeddingSize = randomNonNegativeInt(); + var model = CohereEmbeddingsModelTests.createModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + CohereEmbeddingsTaskSettings.EMPTY_SETTINGS, + randomNonNegativeInt(), + randomNonNegativeInt(), + randomAlphaOfLength(10), + randomFrom(CohereEmbeddingType.values()), + similarityMeasure + ); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + SimilarityMeasure expectedSimilarityMeasure = similarityMeasure == null ? CohereService.defaultSimilarity() : similarityMeasure; + assertEquals(expectedSimilarityMeasure, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + public void testInfer_UnauthorisedResponse() throws IOException { var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiServiceTests.java index 906a825e49561..2aeba5fcbe209 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiServiceTests.java @@ -19,6 +19,7 @@ import org.elasticsearch.inference.InputType; import org.elasticsearch.inference.Model; import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.SimilarityMeasure; import org.elasticsearch.inference.TaskType; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.http.MockWebServer; @@ -30,9 +31,11 @@ import org.elasticsearch.xpack.inference.logging.ThrottlerManager; import org.elasticsearch.xpack.inference.services.ServiceFields; import org.elasticsearch.xpack.inference.services.googlevertexai.embeddings.GoogleVertexAiEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.googlevertexai.embeddings.GoogleVertexAiEmbeddingsModelTests; import org.elasticsearch.xpack.inference.services.googlevertexai.embeddings.GoogleVertexAiEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.googlevertexai.embeddings.GoogleVertexAiEmbeddingsTaskSettings; import org.elasticsearch.xpack.inference.services.googlevertexai.rerank.GoogleVertexAiRerankModel; +import org.elasticsearch.xpack.inference.services.googlevertexai.rerank.GoogleVertexAiRerankModelTests; import org.elasticsearch.xpack.inference.services.googlevertexai.rerank.GoogleVertexAiRerankTaskSettings; import org.hamcrest.CoreMatchers; import org.hamcrest.Matchers; @@ -827,6 +830,37 @@ public void testParsePersistedConfig_CreatesAnEmbeddingsModelWhenChunkingSetting } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + try (var service = createGoogleVertexAiService()) { + var model = GoogleVertexAiRerankModelTests.createModel(randomAlphaOfLength(10), randomNonNegativeInt()); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_NullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(null); + } + + public void testUpdateModelWithEmbeddingDetails_NonNullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(randomFrom(SimilarityMeasure.values())); + } + + private void testUpdateModelWithEmbeddingDetails_Successful(SimilarityMeasure similarityMeasure) throws IOException { + try (var service = createGoogleVertexAiService()) { + var embeddingSize = randomNonNegativeInt(); + var model = GoogleVertexAiEmbeddingsModelTests.createModel(randomAlphaOfLength(10), randomBoolean(), similarityMeasure); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + SimilarityMeasure expectedSimilarityMeasure = similarityMeasure == null ? SimilarityMeasure.DOT_PRODUCT : similarityMeasure; + assertEquals(expectedSimilarityMeasure, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + // testInfer tested via end-to-end notebook tests in AppEx repo @SuppressWarnings("checkstyle:LineLength") diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/embeddings/GoogleVertexAiEmbeddingsModelTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/embeddings/GoogleVertexAiEmbeddingsModelTests.java index 7836c5c15cfb1..5b016de7493f5 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/embeddings/GoogleVertexAiEmbeddingsModelTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googlevertexai/embeddings/GoogleVertexAiEmbeddingsModelTests.java @@ -64,7 +64,7 @@ public void testOverrideWith_DoesNotOverrideAndModelRemainsEqual_WhenSettingsAre } public void testOverrideWith_SetsInputTypeToOverride_WhenFieldIsNullInModelTaskSettings_AndNullInRequestTaskSettings() { - var model = createModel("model", Boolean.FALSE, null); + var model = createModel("model", Boolean.FALSE, (InputType) null); var overriddenModel = GoogleVertexAiEmbeddingsModel.of(model, getTaskSettingsMap(null, null), InputType.SEARCH); var expectedModel = createModel("model", Boolean.FALSE, InputType.SEARCH); @@ -80,7 +80,7 @@ public void testOverrideWith_SetsInputType_FromRequest_IfValid_OverridingStoredT } public void testOverrideWith_SetsInputType_FromRequest_IfValid_OverridingRequestTaskSettings() { - var model = createModel("model", Boolean.FALSE, null); + var model = createModel("model", Boolean.FALSE, (InputType) null); var overriddenModel = GoogleVertexAiEmbeddingsModel.of(model, getTaskSettingsMap(null, InputType.CLUSTERING), InputType.SEARCH); var expectedModel = createModel("model", Boolean.FALSE, InputType.SEARCH); @@ -96,10 +96,10 @@ public void testOverrideWith_OverridesInputType_WithRequestTaskSettingsSearch_Wh } public void testOverrideWith_DoesNotSetInputType_FromRequest_IfInputTypeIsInvalid() { - var model = createModel("model", Boolean.FALSE, null); + var model = createModel("model", Boolean.FALSE, (InputType) null); var overriddenModel = GoogleVertexAiEmbeddingsModel.of(model, getTaskSettingsMap(null, null), InputType.UNSPECIFIED); - var expectedModel = createModel("model", Boolean.FALSE, null); + var expectedModel = createModel("model", Boolean.FALSE, (InputType) null); MatcherAssert.assertThat(overriddenModel, is(expectedModel)); } @@ -136,6 +136,31 @@ public static GoogleVertexAiEmbeddingsModel createModel( ); } + public static GoogleVertexAiEmbeddingsModel createModel( + String modelId, + @Nullable Boolean autoTruncate, + SimilarityMeasure similarityMeasure + ) { + return new GoogleVertexAiEmbeddingsModel( + "id", + TaskType.TEXT_EMBEDDING, + "service", + new GoogleVertexAiEmbeddingsServiceSettings( + randomAlphaOfLength(8), + randomAlphaOfLength(8), + modelId, + false, + null, + null, + similarityMeasure, + null + ), + new GoogleVertexAiEmbeddingsTaskSettings(autoTruncate, randomFrom(InputType.INGEST, InputType.SEARCH)), + null, + new GoogleVertexAiSecretSettings(new SecureString(randomAlphaOfLength(8).toCharArray())) + ); + } + public static GoogleVertexAiEmbeddingsModel createModel(String modelId, @Nullable Boolean autoTruncate, @Nullable InputType inputType) { return new GoogleVertexAiEmbeddingsModel( "id", diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java index f7f37c5bcd15f..1261e3834437b 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java @@ -51,6 +51,7 @@ import org.elasticsearch.xpack.inference.services.ServiceFields; import org.elasticsearch.xpack.inference.services.ibmwatsonx.embeddings.IbmWatsonxEmbeddingsModel; import org.elasticsearch.xpack.inference.services.ibmwatsonx.embeddings.IbmWatsonxEmbeddingsModelTests; +import org.elasticsearch.xpack.inference.services.openai.completion.OpenAiChatCompletionModelTests; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.After; @@ -930,6 +931,56 @@ public void testCheckModelConfig_DoesNotUpdateSimilarity_WhenItIsSpecifiedAsCosi } } + public void testUpdateModelWithEmbeddingDetails_InvalidModelProvided() throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new IbmWatsonxServiceWithoutAuth(senderFactory, createWithEmptySettings(threadPool))) { + var model = OpenAiChatCompletionModelTests.createChatCompletionModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10) + ); + assertThrows( + ElasticsearchStatusException.class, + () -> { service.updateModelWithEmbeddingDetails(model, randomNonNegativeInt()); } + ); + } + } + + public void testUpdateModelWithEmbeddingDetails_NullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(null); + } + + public void testUpdateModelWithEmbeddingDetails_NonNullSimilarityInOriginalModel() throws IOException { + testUpdateModelWithEmbeddingDetails_Successful(randomFrom(SimilarityMeasure.values())); + } + + private void testUpdateModelWithEmbeddingDetails_Successful(SimilarityMeasure similarityMeasure) throws IOException { + var senderFactory = HttpRequestSenderTests.createSenderFactory(threadPool, clientManager); + + try (var service = new IbmWatsonxServiceWithoutAuth(senderFactory, createWithEmptySettings(threadPool))) { + var embeddingSize = randomNonNegativeInt(); + var model = IbmWatsonxEmbeddingsModelTests.createModel( + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + URI.create(randomAlphaOfLength(10)), + randomAlphaOfLength(10), + randomAlphaOfLength(10), + randomNonNegativeInt(), + similarityMeasure + ); + + Model updatedModel = service.updateModelWithEmbeddingDetails(model, embeddingSize); + + SimilarityMeasure expectedSimilarityMeasure = similarityMeasure == null ? SimilarityMeasure.DOT_PRODUCT : similarityMeasure; + assertEquals(expectedSimilarityMeasure, updatedModel.getServiceSettings().similarity()); + assertEquals(embeddingSize, updatedModel.getServiceSettings().dimensions().intValue()); + } + } + public void testGetConfiguration() throws Exception { try (var service = createIbmWatsonxService()) { String content = XContentHelper.stripWhitespace(""" From 2f0b095b5d7ce9adcd6c37682e39c277ad5cb512 Mon Sep 17 00:00:00 2001 From: Martijn van Groningen Date: Mon, 25 Nov 2024 16:45:36 +0100 Subject: [PATCH 020/129] Stop using _source.mode attribute in traces-otel builtin template (#117487) The traces-otel@mappings component template is configured to use logsdb. No need to configure source mode separately. --- .../resources/component-templates/traces-otel@mappings.yaml | 2 -- x-pack/plugin/otel-data/src/main/resources/resources.yaml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/plugin/otel-data/src/main/resources/component-templates/traces-otel@mappings.yaml b/x-pack/plugin/otel-data/src/main/resources/component-templates/traces-otel@mappings.yaml index 2b0d1ec536fa6..3a1ba435b8f1f 100644 --- a/x-pack/plugin/otel-data/src/main/resources/component-templates/traces-otel@mappings.yaml +++ b/x-pack/plugin/otel-data/src/main/resources/component-templates/traces-otel@mappings.yaml @@ -10,8 +10,6 @@ template: sort: field: [ "resource.attributes.host.name", "@timestamp" ] mappings: - _source: - mode: synthetic properties: trace_id: type: keyword diff --git a/x-pack/plugin/otel-data/src/main/resources/resources.yaml b/x-pack/plugin/otel-data/src/main/resources/resources.yaml index b2d30c7f85cc4..9edbe5622b3f1 100644 --- a/x-pack/plugin/otel-data/src/main/resources/resources.yaml +++ b/x-pack/plugin/otel-data/src/main/resources/resources.yaml @@ -1,7 +1,7 @@ # "version" holds the version of the templates and ingest pipelines installed # by xpack-plugin otel-data. This must be increased whenever an existing template is # changed, in order for it to be updated on Elasticsearch upgrade. -version: 6 +version: 7 component-templates: - otel@mappings From b7d801809fce6669aaa530f7375bfdc6a6bcb24e Mon Sep 17 00:00:00 2001 From: padmaprasath21 <168728638+padmaprasath21@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:26:17 +0530 Subject: [PATCH 021/129] Update tsds-reindex.asciidoc (#117446) --- docs/reference/data-streams/tsds-reindex.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/data-streams/tsds-reindex.asciidoc b/docs/reference/data-streams/tsds-reindex.asciidoc index 9d6594db4e779..f4d00f33c179c 100644 --- a/docs/reference/data-streams/tsds-reindex.asciidoc +++ b/docs/reference/data-streams/tsds-reindex.asciidoc @@ -202,7 +202,7 @@ POST /_component_template/destination_template POST /_index_template/2 { "index_patterns": [ - "k8s*" + "k9s*" ], "composed_of": [ "destination_template" From 8c22fc479f7f62e1ac6ce2e6db30c1c723ab3c8f Mon Sep 17 00:00:00 2001 From: Craig Taverner Date: Mon, 25 Nov 2024 17:04:48 +0100 Subject: [PATCH 022/129] Make spatial search functions not preview (#117489) --- .../esql/functions/spatial-functions.asciidoc | 16 ++++++++-------- docs/reference/geospatial-analysis.asciidoc | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/reference/esql/functions/spatial-functions.asciidoc b/docs/reference/esql/functions/spatial-functions.asciidoc index 79acc2028d983..eee44d337b4c6 100644 --- a/docs/reference/esql/functions/spatial-functions.asciidoc +++ b/docs/reference/esql/functions/spatial-functions.asciidoc @@ -8,19 +8,19 @@ {esql} supports these spatial functions: // tag::spatial_list[] -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> +* <> +* <> +* <> +* <> +* <> +* <> +* <> // end::spatial_list[] +include::layout/st_distance.asciidoc[] include::layout/st_intersects.asciidoc[] include::layout/st_disjoint.asciidoc[] include::layout/st_contains.asciidoc[] include::layout/st_within.asciidoc[] include::layout/st_x.asciidoc[] include::layout/st_y.asciidoc[] -include::layout/st_distance.asciidoc[] diff --git a/docs/reference/geospatial-analysis.asciidoc b/docs/reference/geospatial-analysis.asciidoc index 6760040e14bc7..678e0ee17aec2 100644 --- a/docs/reference/geospatial-analysis.asciidoc +++ b/docs/reference/geospatial-analysis.asciidoc @@ -38,11 +38,11 @@ Data is often messy and incomplete. <> lets you clean, <> has support for <> functions, enabling efficient index searching for documents that intersect with, are within, are contained by, or are disjoint from a query geometry. In addition, the `ST_DISTANCE` function calculates the distance between two points. -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> -* experimental:[] <> +* <> +* <> +* <> +* <> +* <> [discrete] [[geospatial-aggregate]] From 4e3301530d16ff937fa835b42a108aee48203af5 Mon Sep 17 00:00:00 2001 From: David Kyle Date: Mon, 25 Nov 2024 16:20:18 +0000 Subject: [PATCH 023/129] [ML] Explicitly set chunking settings in preconfigured endpoints (#117327) --- .../xpack/inference/DefaultEndPointsIT.java | 20 +++++++++++++++++++ .../ElasticsearchInternalService.java | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/DefaultEndPointsIT.java b/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/DefaultEndPointsIT.java index 69767ce0b24f0..ba3e48e11928d 100644 --- a/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/DefaultEndPointsIT.java +++ b/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/DefaultEndPointsIT.java @@ -51,6 +51,14 @@ public void tearDown() throws Exception { super.tearDown(); } + public void testGet() throws IOException { + var elserModel = getModel(ElasticsearchInternalService.DEFAULT_ELSER_ID); + assertDefaultElserConfig(elserModel); + + var e5Model = getModel(ElasticsearchInternalService.DEFAULT_E5_ID); + assertDefaultE5Config(e5Model); + } + @SuppressWarnings("unchecked") public void testInferDeploysDefaultElser() throws IOException { var model = getModel(ElasticsearchInternalService.DEFAULT_ELSER_ID); @@ -79,6 +87,7 @@ private static void assertDefaultElserConfig(Map modelConfig) { adaptiveAllocations, Matchers.is(Map.of("enabled", true, "min_number_of_allocations", 0, "max_number_of_allocations", 32)) ); + assertDefaultChunkingSettings(modelConfig); } @SuppressWarnings("unchecked") @@ -113,6 +122,17 @@ private static void assertDefaultE5Config(Map modelConfig) { adaptiveAllocations, Matchers.is(Map.of("enabled", true, "min_number_of_allocations", 0, "max_number_of_allocations", 32)) ); + assertDefaultChunkingSettings(modelConfig); + } + + @SuppressWarnings("unchecked") + private static void assertDefaultChunkingSettings(Map modelConfig) { + var chunkingSettings = (Map) modelConfig.get("chunking_settings"); + assertThat( + modelConfig.toString(), + chunkingSettings, + Matchers.is(Map.of("strategy", "sentence", "max_chunk_size", 250, "sentence_overlap", 1)) + ); } public void testMultipleInferencesTriggeringDownloadAndDeploy() throws InterruptedException { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java index 718aeae979fe9..6d124906d65bd 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java @@ -862,6 +862,7 @@ public void updateModelsWithDynamicFields(List models, ActionListener> defaultsListener) { preferredModelVariantFn.accept(defaultsListener.delegateFailureAndWrap((delegate, preferredModelVariant) -> { if (PreferredModelVariant.LINUX_X86_OPTIMIZED.equals(preferredModelVariant)) { @@ -892,7 +893,7 @@ private List defaultConfigs(boolean useLinuxOptimizedModel) { new AdaptiveAllocationsSettings(Boolean.TRUE, 0, 32) ), ElserMlNodeTaskSettings.DEFAULT, - null // default chunking settings + ChunkingSettingsBuilder.DEFAULT_SETTINGS ); var defaultE5 = new MultilingualE5SmallModel( DEFAULT_E5_ID, @@ -904,7 +905,7 @@ private List defaultConfigs(boolean useLinuxOptimizedModel) { useLinuxOptimizedModel ? MULTILINGUAL_E5_SMALL_MODEL_ID_LINUX_X86 : MULTILINGUAL_E5_SMALL_MODEL_ID, new AdaptiveAllocationsSettings(Boolean.TRUE, 0, 32) ), - null // default chunking settings + ChunkingSettingsBuilder.DEFAULT_SETTINGS ); return List.of(defaultElser, defaultE5); } From 374c88a832edb53525a1c52873db185c2acdfb23 Mon Sep 17 00:00:00 2001 From: Benjamin Trent Date: Mon, 25 Nov 2024 11:38:06 -0500 Subject: [PATCH 024/129] Correct bit * byte and bit * float script comparisons (#117404) I goofed on the bit * byte and bit * float comparisons. Naturally, these should be bigendian and compare the dimensions with the binary ones appropriately. Additionally, I added a test to ensure that this is handled correctly. --- docs/changelog/117404.yaml | 5 +++ .../vectors/vector-functions.asciidoc | 4 ++ .../elasticsearch/simdvec/ESVectorUtil.java | 12 ++++-- .../simdvec/ESVectorUtilTests.java | 16 +++++++ .../141_multi_dense_vector_max_sim.yml | 6 +-- .../painless/146_dense_vector_bit_basic.yml | 42 +++++++++---------- .../action/search/SearchCapabilities.java | 4 +- .../MultiVectorScoreScriptUtilsTests.java | 2 +- .../script/VectorScoreScriptUtilsTests.java | 2 +- 9 files changed, 61 insertions(+), 32 deletions(-) create mode 100644 docs/changelog/117404.yaml diff --git a/docs/changelog/117404.yaml b/docs/changelog/117404.yaml new file mode 100644 index 0000000000000..0bab171956ca9 --- /dev/null +++ b/docs/changelog/117404.yaml @@ -0,0 +1,5 @@ +pr: 117404 +summary: Correct bit * byte and bit * float script comparisons +area: Vector Search +type: bug +issues: [] diff --git a/docs/reference/vectors/vector-functions.asciidoc b/docs/reference/vectors/vector-functions.asciidoc index 10dca8084e28a..23419e8eb12b1 100644 --- a/docs/reference/vectors/vector-functions.asciidoc +++ b/docs/reference/vectors/vector-functions.asciidoc @@ -336,6 +336,10 @@ When using `bit` vectors, not all the vector functions are available. The suppor this is the sum of the bitwise AND of the two vectors. If providing `float[]` or `byte[]`, who has `dims` number of elements, as a query vector, the `dotProduct` is the sum of the floating point values using the stored `bit` vector as a mask. +NOTE: When comparing `floats` and `bytes` with `bit` vectors, the `bit` vector is treated as a mask in big-endian order. +For example, if the `bit` vector is `10100001` (e.g. the single byte value `161`) and its compared +with array of values `[1, 2, 3, 4, 5, 6, 7, 8]` the `dotProduct` will be `1 + 3 + 8 = 16`. + Here is an example of using dot-product with bit vectors. [source,console] diff --git a/libs/simdvec/src/main/java/org/elasticsearch/simdvec/ESVectorUtil.java b/libs/simdvec/src/main/java/org/elasticsearch/simdvec/ESVectorUtil.java index de2cb9042610b..2f4743a47a14a 100644 --- a/libs/simdvec/src/main/java/org/elasticsearch/simdvec/ESVectorUtil.java +++ b/libs/simdvec/src/main/java/org/elasticsearch/simdvec/ESVectorUtil.java @@ -51,6 +51,8 @@ public static long ipByteBinByte(byte[] q, byte[] d) { /** * Compute the inner product of two vectors, where the query vector is a byte vector and the document vector is a bit vector. * This will return the sum of the query vector values using the document vector as a mask. + * When comparing the bits with the bytes, they are done in "big endian" order. For example, if the byte vector + * is [1, 2, 3, 4, 5, 6, 7, 8] and the bit vector is [0b10000000], the inner product will be 1.0. * @param q the query vector * @param d the document vector * @return the inner product of the two vectors @@ -63,9 +65,9 @@ public static int ipByteBit(byte[] q, byte[] d) { // now combine the two vectors, summing the byte dimensions where the bit in d is `1` for (int i = 0; i < d.length; i++) { byte mask = d[i]; - for (int j = 0; j < Byte.SIZE; j++) { + for (int j = Byte.SIZE - 1; j >= 0; j--) { if ((mask & (1 << j)) != 0) { - result += q[i * Byte.SIZE + j]; + result += q[i * Byte.SIZE + Byte.SIZE - 1 - j]; } } } @@ -75,6 +77,8 @@ public static int ipByteBit(byte[] q, byte[] d) { /** * Compute the inner product of two vectors, where the query vector is a float vector and the document vector is a bit vector. * This will return the sum of the query vector values using the document vector as a mask. + * When comparing the bits with the floats, they are done in "big endian" order. For example, if the float vector + * is [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] and the bit vector is [0b10000000], the inner product will be 1.0. * @param q the query vector * @param d the document vector * @return the inner product of the two vectors @@ -86,9 +90,9 @@ public static float ipFloatBit(float[] q, byte[] d) { float result = 0; for (int i = 0; i < d.length; i++) { byte mask = d[i]; - for (int j = 0; j < Byte.SIZE; j++) { + for (int j = Byte.SIZE - 1; j >= 0; j--) { if ((mask & (1 << j)) != 0) { - result += q[i * Byte.SIZE + j]; + result += q[i * Byte.SIZE + Byte.SIZE - 1 - j]; } } } diff --git a/libs/simdvec/src/test/java/org/elasticsearch/simdvec/ESVectorUtilTests.java b/libs/simdvec/src/test/java/org/elasticsearch/simdvec/ESVectorUtilTests.java index e9e0fd58f7638..368898b934c87 100644 --- a/libs/simdvec/src/test/java/org/elasticsearch/simdvec/ESVectorUtilTests.java +++ b/libs/simdvec/src/test/java/org/elasticsearch/simdvec/ESVectorUtilTests.java @@ -21,6 +21,22 @@ public class ESVectorUtilTests extends BaseVectorizationTests { static final ESVectorizationProvider defaultedProvider = BaseVectorizationTests.defaultProvider(); static final ESVectorizationProvider defOrPanamaProvider = BaseVectorizationTests.maybePanamaProvider(); + public void testIpByteBit() { + byte[] q = new byte[16]; + byte[] d = new byte[] { (byte) Integer.parseInt("01100010", 2), (byte) Integer.parseInt("10100111", 2) }; + random().nextBytes(q); + int expected = q[1] + q[2] + q[6] + q[8] + q[10] + q[13] + q[14] + q[15]; + assertEquals(expected, ESVectorUtil.ipByteBit(q, d)); + } + + public void testIpFloatBit() { + float[] q = new float[16]; + byte[] d = new byte[] { (byte) Integer.parseInt("01100010", 2), (byte) Integer.parseInt("10100111", 2) }; + random().nextFloat(); + float expected = q[1] + q[2] + q[6] + q[8] + q[10] + q[13] + q[14] + q[15]; + assertEquals(expected, ESVectorUtil.ipFloatBit(q, d), 1e-6); + } + public void testBitAndCount() { testBasicBitAndImpl(ESVectorUtil::andBitCountLong); } diff --git a/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/141_multi_dense_vector_max_sim.yml b/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/141_multi_dense_vector_max_sim.yml index caa7c59ab4c42..77d4b70cdfcae 100644 --- a/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/141_multi_dense_vector_max_sim.yml +++ b/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/141_multi_dense_vector_max_sim.yml @@ -3,7 +3,7 @@ setup: capabilities: - method: POST path: /_search - capabilities: [ multi_dense_vector_script_max_sim ] + capabilities: [ multi_dense_vector_script_max_sim_with_bugfix ] test_runner_features: capabilities reason: "Support for multi dense vector max-sim functions capability required" - skip: @@ -136,10 +136,10 @@ setup: - match: {hits.total: 2} - match: {hits.hits.0._id: "1"} - - close_to: {hits.hits.0._score: {value: 190, error: 0.01}} + - close_to: {hits.hits.0._score: {value: 220, error: 0.01}} - match: {hits.hits.1._id: "3"} - - close_to: {hits.hits.1._score: {value: 125, error: 0.01}} + - close_to: {hits.hits.1._score: {value: 147, error: 0.01}} --- "Test max-sim inv hamming scoring": - skip: diff --git a/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/146_dense_vector_bit_basic.yml b/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/146_dense_vector_bit_basic.yml index 2ee38f849e9d4..cdd65ca0eb296 100644 --- a/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/146_dense_vector_bit_basic.yml +++ b/modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/146_dense_vector_bit_basic.yml @@ -108,7 +108,7 @@ setup: capabilities: - method: POST path: /_search - capabilities: [ byte_float_bit_dot_product ] + capabilities: [ byte_float_bit_dot_product_with_bugfix ] reason: Capability required to run test - do: catch: bad_request @@ -399,7 +399,7 @@ setup: capabilities: - method: POST path: /_search - capabilities: [ byte_float_bit_dot_product ] + capabilities: [ byte_float_bit_dot_product_with_bugfix ] test_runner_features: [capabilities, close_to] reason: Capability required to run test - do: @@ -419,13 +419,13 @@ setup: - match: { hits.total: 3 } - match: {hits.hits.0._id: "2"} - - close_to: {hits.hits.0._score: {value: 35.999, error: 0.01}} + - close_to: {hits.hits.0._score: {value: 33.78, error: 0.01}} - match: {hits.hits.1._id: "3"} - - close_to: {hits.hits.1._score:{value: 27.23, error: 0.01}} + - close_to: {hits.hits.1._score:{value: 22.579, error: 0.01}} - match: {hits.hits.2._id: "1"} - - close_to: {hits.hits.2._score: {value: 16.57, error: 0.01}} + - close_to: {hits.hits.2._score: {value: 11.919, error: 0.01}} - do: headers: @@ -444,20 +444,20 @@ setup: - match: { hits.total: 3 } - match: {hits.hits.0._id: "2"} - - close_to: {hits.hits.0._score: {value: 35.999, error: 0.01}} + - close_to: {hits.hits.0._score: {value: 33.78, error: 0.01}} - match: {hits.hits.1._id: "3"} - - close_to: {hits.hits.1._score:{value: 27.23, error: 0.01}} + - close_to: {hits.hits.1._score:{value: 22.579, error: 0.01}} - match: {hits.hits.2._id: "1"} - - close_to: {hits.hits.2._score: {value: 16.57, error: 0.01}} + - close_to: {hits.hits.2._score: {value: 11.919, error: 0.01}} --- "Dot product with byte": - requires: capabilities: - method: POST path: /_search - capabilities: [ byte_float_bit_dot_product ] + capabilities: [ byte_float_bit_dot_product_with_bugfix ] test_runner_features: capabilities reason: Capability required to run test - do: @@ -476,14 +476,14 @@ setup: - match: { hits.total: 3 } - - match: {hits.hits.0._id: "1"} - - match: {hits.hits.0._score: 248} + - match: {hits.hits.0._id: "3"} + - match: {hits.hits.0._score: 415} - - match: {hits.hits.1._id: "2"} - - match: {hits.hits.1._score: 136} + - match: {hits.hits.1._id: "1"} + - match: {hits.hits.1._score: 168} - - match: {hits.hits.2._id: "3"} - - match: {hits.hits.2._score: 20} + - match: {hits.hits.2._id: "2"} + - match: {hits.hits.2._score: 126} - do: headers: @@ -501,11 +501,11 @@ setup: - match: { hits.total: 3 } - - match: {hits.hits.0._id: "1"} - - match: {hits.hits.0._score: 248} + - match: {hits.hits.0._id: "3"} + - match: {hits.hits.0._score: 415} - - match: {hits.hits.1._id: "2"} - - match: {hits.hits.1._score: 136} + - match: {hits.hits.1._id: "1"} + - match: {hits.hits.1._score: 168} - - match: {hits.hits.2._id: "3"} - - match: {hits.hits.2._score: 20} + - match: {hits.hits.2._id: "2"} + - match: {hits.hits.2._score: 126} diff --git a/server/src/main/java/org/elasticsearch/rest/action/search/SearchCapabilities.java b/server/src/main/java/org/elasticsearch/rest/action/search/SearchCapabilities.java index e5c4826bfce97..794b30aa5aab2 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/search/SearchCapabilities.java +++ b/server/src/main/java/org/elasticsearch/rest/action/search/SearchCapabilities.java @@ -27,7 +27,7 @@ private SearchCapabilities() {} /** Support synthetic source with `bit` type in `dense_vector` field when `index` is set to `false`. */ private static final String BIT_DENSE_VECTOR_SYNTHETIC_SOURCE_CAPABILITY = "bit_dense_vector_synthetic_source"; /** Support Byte and Float with Bit dot product. */ - private static final String BYTE_FLOAT_BIT_DOT_PRODUCT_CAPABILITY = "byte_float_bit_dot_product"; + private static final String BYTE_FLOAT_BIT_DOT_PRODUCT_CAPABILITY = "byte_float_bit_dot_product_with_bugfix"; /** Support docvalue_fields parameter for `dense_vector` field. */ private static final String DENSE_VECTOR_DOCVALUE_FIELDS = "dense_vector_docvalue_fields"; /** Support transforming rank rrf queries to the corresponding rrf retriever. */ @@ -41,7 +41,7 @@ private SearchCapabilities() {} /** Support multi-dense-vector script field access. */ private static final String MULTI_DENSE_VECTOR_SCRIPT_ACCESS = "multi_dense_vector_script_access"; /** Initial support for multi-dense-vector maxSim functions access. */ - private static final String MULTI_DENSE_VECTOR_SCRIPT_MAX_SIM = "multi_dense_vector_script_max_sim"; + private static final String MULTI_DENSE_VECTOR_SCRIPT_MAX_SIM = "multi_dense_vector_script_max_sim_with_bugfix"; private static final String RANDOM_SAMPLER_WITH_SCORED_SUBAGGS = "random_sampler_with_scored_subaggs"; diff --git a/server/src/test/java/org/elasticsearch/script/MultiVectorScoreScriptUtilsTests.java b/server/src/test/java/org/elasticsearch/script/MultiVectorScoreScriptUtilsTests.java index c4a1699181efc..f908f51170478 100644 --- a/server/src/test/java/org/elasticsearch/script/MultiVectorScoreScriptUtilsTests.java +++ b/server/src/test/java/org/elasticsearch/script/MultiVectorScoreScriptUtilsTests.java @@ -200,7 +200,7 @@ public void testBitMultiVectorClassBindingsDotProduct() throws IOException { function = new MaxSimDotProduct(scoreScript, floatQueryVector, fieldName); assertEquals( "maxSimDotProduct result is not equal to the expected value!", - 0.42f + 0f + 1f - 1f - 0.42f, + -1.4f + 0.42f + 0f + 1f - 1f, function.maxSimDotProduct(), 0.001 ); diff --git a/server/src/test/java/org/elasticsearch/script/VectorScoreScriptUtilsTests.java b/server/src/test/java/org/elasticsearch/script/VectorScoreScriptUtilsTests.java index 6b2178310d17c..dcaa64ede9e89 100644 --- a/server/src/test/java/org/elasticsearch/script/VectorScoreScriptUtilsTests.java +++ b/server/src/test/java/org/elasticsearch/script/VectorScoreScriptUtilsTests.java @@ -267,7 +267,7 @@ public void testBitVectorClassBindingsDotProduct() throws IOException { function = new DotProduct(scoreScript, floatQueryVector, fieldName); assertEquals( "dotProduct result is not equal to the expected value!", - 0.42f + 0f + 1f - 1f - 0.42f, + -1.4f + 0.42f + 0f + 1f - 1f, function.dotProduct(), 0.001 ); From 4e1807f0d91c17750c43b7bb43bc93198c61ba7f Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 03:50:16 +1100 Subject: [PATCH 025/129] Update docker.elastic.co/wolfi/chainguard-base:latest Docker digest to 55b297d (#116255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | docker.elastic.co/wolfi/chainguard-base | digest | `9734313` -> `55b297d` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 1pm on tuesday" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). --- .../main/java/org/elasticsearch/gradle/internal/DockerBase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java index bf901fef90450..71e968557cefe 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java @@ -22,7 +22,7 @@ public enum DockerBase { // Chainguard based wolfi image with latest jdk // This is usually updated via renovatebot // spotless:off - WOLFI("docker.elastic.co/wolfi/chainguard-base:latest@sha256:973431347ad45f40e01afbbd010bf9de929c088a63382239b90dd84f39618bc8", + WOLFI("docker.elastic.co/wolfi/chainguard-base:latest@sha256:55b297da5151d2a2997e8ab9729fe1304e4869389d7090ab7031cc29530f69f8", "-wolfi", "apk" ), From 631345f96531a66b9e4130c92df15f08c86e1a79 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Mon, 25 Nov 2024 10:20:58 -0800 Subject: [PATCH 026/129] Adjust index version for deprecating source mode (#117183) There was a bug in the version-checking logic for emitting deprecation warnings for source.mode in mappings. --- .../DataStreamTimestampFieldMapperTests.java | 3 +- .../elasticsearch/upgrades/IndexingIT.java | 9 ++++- .../index/mapper/SourceFieldMapper.java | 13 +++++-- .../mapper/DocumentParserContextTests.java | 1 + .../index/mapper/SourceFieldMapperTests.java | 28 ++++++++++++--- .../index/shard/ShardGetServiceTests.java | 2 ++ .../index/mapper/MetadataMapperTestCase.java | 9 ++--- .../test/rest/ESRestTestCase.java | 34 +++++++++++++++---- .../xpack/deprecation/DeprecationChecks.java | 3 +- .../deprecation/IndexDeprecationChecks.java | 26 ++++++++++++++ .../compute/operator/AsyncOperator.java | 1 + .../logsdb/LogsIndexModeCustomSettingsIT.java | 15 ++++++-- .../xpack/logsdb/LogsIndexModeRestTestIT.java | 6 ++++ ...heticSourceIndexSettingsProviderTests.java | 7 +++- 14 files changed, 133 insertions(+), 24 deletions(-) diff --git a/modules/data-streams/src/test/java/org/elasticsearch/datastreams/mapper/DataStreamTimestampFieldMapperTests.java b/modules/data-streams/src/test/java/org/elasticsearch/datastreams/mapper/DataStreamTimestampFieldMapperTests.java index a3995d7462b32..e009db7209eab 100644 --- a/modules/data-streams/src/test/java/org/elasticsearch/datastreams/mapper/DataStreamTimestampFieldMapperTests.java +++ b/modules/data-streams/src/test/java/org/elasticsearch/datastreams/mapper/DataStreamTimestampFieldMapperTests.java @@ -48,7 +48,8 @@ protected void registerParameters(ParameterChecker checker) throws IOException { checker.registerConflictCheck( "enabled", timestampMapping(true, b -> b.startObject("@timestamp").field("type", "date").endObject()), - timestampMapping(false, b -> b.startObject("@timestamp").field("type", "date").endObject()) + timestampMapping(false, b -> b.startObject("@timestamp").field("type", "date").endObject()), + dm -> {} ); checker.registerUpdateCheck( timestampMapping(false, b -> b.startObject("@timestamp").field("type", "date").endObject()), diff --git a/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/upgrades/IndexingIT.java b/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/upgrades/IndexingIT.java index 090f409fd46d0..86a0151e33119 100644 --- a/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/upgrades/IndexingIT.java +++ b/qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/upgrades/IndexingIT.java @@ -20,6 +20,7 @@ import org.elasticsearch.common.xcontent.support.XContentMapValues; import org.elasticsearch.core.UpdateForV9; import org.elasticsearch.index.mapper.DateFieldMapper; +import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper; import org.elasticsearch.test.ListMatcher; import org.elasticsearch.xcontent.XContentBuilder; @@ -417,9 +418,15 @@ public void testSyntheticSource() throws IOException { if (isOldCluster()) { Request createIndex = new Request("PUT", "/synthetic"); XContentBuilder indexSpec = XContentBuilder.builder(XContentType.JSON.xContent()).startObject(); + boolean useIndexSetting = SourceFieldMapper.onOrAfterDeprecateModeVersion(getOldClusterIndexVersion()); + if (useIndexSetting) { + indexSpec.startObject("settings").field("index.mapping.source.mode", "synthetic").endObject(); + } indexSpec.startObject("mappings"); { - indexSpec.startObject("_source").field("mode", "synthetic").endObject(); + if (useIndexSetting == false) { + indexSpec.startObject("_source").field("mode", "synthetic").endObject(); + } indexSpec.startObject("properties").startObject("kwd").field("type", "keyword").endObject().endObject(); } indexSpec.endObject(); 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 e5b12f748543f..9d0dc9635537b 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java @@ -26,6 +26,7 @@ import org.elasticsearch.features.NodeFeature; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.IndexSettings; +import org.elasticsearch.index.IndexVersion; import org.elasticsearch.index.IndexVersions; import org.elasticsearch.index.query.QueryShardException; import org.elasticsearch.index.query.SearchExecutionContext; @@ -297,7 +298,7 @@ private static SourceFieldMapper resolveStaticInstance(final Mode sourceMode) { if (indexMode == IndexMode.STANDARD && settingSourceMode == Mode.STORED) { return DEFAULT; } - if (c.indexVersionCreated().onOrAfter(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER)) { + if (onOrAfterDeprecateModeVersion(c.indexVersionCreated())) { return resolveStaticInstance(settingSourceMode); } else { return new SourceFieldMapper(settingSourceMode, Explicit.IMPLICIT_TRUE, Strings.EMPTY_ARRAY, Strings.EMPTY_ARRAY, true); @@ -307,14 +308,14 @@ private static SourceFieldMapper resolveStaticInstance(final Mode sourceMode) { c.getIndexSettings().getMode(), c.getSettings(), c.indexVersionCreated().onOrAfter(IndexVersions.SOURCE_MAPPER_LOSSY_PARAMS_CHECK), - c.indexVersionCreated().before(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER) + onOrAfterDeprecateModeVersion(c.indexVersionCreated()) == false ) ) { @Override public MetadataFieldMapper.Builder parse(String name, Map node, MappingParserContext parserContext) throws MapperParsingException { assert name.equals(SourceFieldMapper.NAME) : name; - if (parserContext.indexVersionCreated().after(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER) && node.containsKey("mode")) { + if (onOrAfterDeprecateModeVersion(parserContext.indexVersionCreated()) && node.containsKey("mode")) { deprecationLogger.critical(DeprecationCategory.MAPPINGS, "mapping_source_mode", SourceFieldMapper.DEPRECATION_WARNING); } return super.parse(name, node, parserContext); @@ -481,4 +482,10 @@ public boolean isDisabled() { public boolean isStored() { return mode == null || mode == Mode.STORED; } + + public static boolean onOrAfterDeprecateModeVersion(IndexVersion version) { + return version.onOrAfter(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER); + // Adjust versions after backporting. + // || version.between(IndexVersions.BACKPORT_DEPRECATE_SOURCE_MODE_MAPPER, IndexVersions.UPGRADE_TO_LUCENE_10_0_0); + } } 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 be36ab9d6eac1..a4108caaf4fc3 100644 --- a/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java +++ b/server/src/test/java/org/elasticsearch/index/mapper/DocumentParserContextTests.java @@ -133,5 +133,6 @@ 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 d7f33b9cdb3ba..fa173bc64518e 100644 --- a/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java +++ b/server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java @@ -52,7 +52,8 @@ protected void registerParameters(ParameterChecker checker) throws IOException { checker.registerConflictCheck( "enabled", topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("enabled", false).endObject()), - topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("enabled", true).endObject()) + topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("enabled", true).endObject()), + dm -> {} ); checker.registerUpdateCheck( topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("enabled", true).endObject()), @@ -62,14 +63,18 @@ protected void registerParameters(ParameterChecker checker) throws IOException { checker.registerUpdateCheck( topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject()), topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()), - dm -> assertTrue(dm.metadataMapper(SourceFieldMapper.class).isSynthetic()) + dm -> { + assertTrue(dm.metadataMapper(SourceFieldMapper.class).isSynthetic()); + assertWarnings(SourceFieldMapper.DEPRECATION_WARNING); + } ); checker.registerConflictCheck("includes", b -> b.array("includes", "foo*")); checker.registerConflictCheck("excludes", b -> b.array("excludes", "foo*")); checker.registerConflictCheck( "mode", topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()), - topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject()) + topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject()), + dm -> assertWarnings(SourceFieldMapper.DEPRECATION_WARNING) ); } @@ -206,13 +211,14 @@ 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()); @@ -220,6 +226,7 @@ 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()); @@ -230,11 +237,15 @@ public void testSyntheticUpdates() throws Exception { Exception e = expectThrows(IllegalArgumentException.class, () -> merge(mapperService, """ { "_doc" : { "_source" : { "mode" : "stored" } } } """)); + 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()); assertFalse(mapper.isSynthetic()); @@ -270,6 +281,7 @@ 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, @@ -301,6 +313,8 @@ 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( @@ -409,6 +423,7 @@ 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(); @@ -419,6 +434,7 @@ 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); } } @@ -613,6 +629,7 @@ 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() @@ -623,6 +640,7 @@ 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); } } @@ -691,6 +709,7 @@ 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() @@ -704,6 +723,7 @@ 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 a49d895f38f67..307bc26c44ba6 100644 --- a/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java +++ b/server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java @@ -21,6 +21,7 @@ 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; @@ -114,6 +115,7 @@ public void testGetFromTranslogWithSyntheticSource() throws IOException { "mode": "synthetic" """; runGetFromTranslogWithOptions(docToIndex, sourceOptions, expectedFetchedSource, "\"long\"", 7L, true); + assertWarnings(SourceFieldMapper.DEPRECATION_WARNING); } public void testGetFromTranslogWithDenseVector() throws IOException { diff --git a/test/framework/src/main/java/org/elasticsearch/index/mapper/MetadataMapperTestCase.java b/test/framework/src/main/java/org/elasticsearch/index/mapper/MetadataMapperTestCase.java index e86cb8562537f..449ecc099412f 100644 --- a/test/framework/src/main/java/org/elasticsearch/index/mapper/MetadataMapperTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/index/mapper/MetadataMapperTestCase.java @@ -38,7 +38,7 @@ protected boolean isSupportedOn(IndexVersion version) { protected abstract void registerParameters(ParameterChecker checker) throws IOException; - private record ConflictCheck(XContentBuilder init, XContentBuilder update) {} + private record ConflictCheck(XContentBuilder init, XContentBuilder update, Consumer check) {} private record UpdateCheck(XContentBuilder init, XContentBuilder update, Consumer check) {} @@ -58,7 +58,7 @@ public void registerConflictCheck(String param, CheckedConsumer {})); } /** @@ -68,8 +68,8 @@ public void registerConflictCheck(String param, CheckedConsumer check) { + conflictChecks.put(param, new ConflictCheck(init, update, check)); } public void registerUpdateCheck(XContentBuilder init, XContentBuilder update, Consumer check) { @@ -95,6 +95,7 @@ public final void testUpdates() throws IOException { e.getMessage(), anyOf(containsString("Cannot update parameter [" + param + "]"), containsString("different [" + param + "]")) ); + checker.conflictChecks.get(param).check.accept(mapperService.documentMapper()); } for (UpdateCheck updateCheck : checker.updateChecks) { MapperService mapperService = createMapperService(updateCheck.init); diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java index 8ca9c0709b359..bdef0ba631b72 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java @@ -112,7 +112,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -1835,9 +1834,10 @@ public static CreateIndexResponse createIndex(RestClient client, String name, Se if (settings != null && settings.getAsBoolean(IndexSettings.INDEX_SOFT_DELETES_SETTING.getKey(), true) == false) { expectSoftDeletesWarning(request, name); - } else if (isSyntheticSourceConfiguredInMapping(mapping)) { - request.setOptions(expectVersionSpecificWarnings(v -> v.compatible(SourceFieldMapper.DEPRECATION_WARNING))); - } + } else if (isSyntheticSourceConfiguredInMapping(mapping) + && SourceFieldMapper.onOrAfterDeprecateModeVersion(minimumIndexVersion())) { + request.setOptions(expectVersionSpecificWarnings(v -> v.current(SourceFieldMapper.DEPRECATION_WARNING))); + } final Response response = client.performRequest(request); try (var parser = responseAsParser(response)) { return TestResponseParsers.parseCreateIndexResponse(parser); @@ -1898,8 +1898,30 @@ protected static boolean isSyntheticSourceConfiguredInMapping(String mapping) { if (sourceMapper == null) { return false; } - Object mode = sourceMapper.get("mode"); - return mode != null && mode.toString().toLowerCase(Locale.ROOT).equals("synthetic"); + return sourceMapper.get("mode") != null; + } + + @SuppressWarnings("unchecked") + protected static boolean isSyntheticSourceConfiguredInTemplate(String template) { + if (template == null) { + return false; + } + var values = XContentHelper.convertToMap(JsonXContent.jsonXContent, template, false); + for (Object value : values.values()) { + Map mappings = (Map) ((Map) value).get("mappings"); + if (mappings == null) { + continue; + } + Map sourceMapper = (Map) mappings.get(SourceFieldMapper.NAME); + if (sourceMapper == null) { + continue; + } + Object mode = sourceMapper.get("mode"); + if (mode != null) { + return true; + } + } + return false; } protected static Map getIndexSettings(String index) throws IOException { diff --git a/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/DeprecationChecks.java b/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/DeprecationChecks.java index d13f3cda2a82c..f9b2cc5afe3a5 100644 --- a/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/DeprecationChecks.java +++ b/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/DeprecationChecks.java @@ -96,7 +96,8 @@ private DeprecationChecks() {} IndexDeprecationChecks::checkIndexDataPath, IndexDeprecationChecks::storeTypeSettingCheck, IndexDeprecationChecks::frozenIndexSettingCheck, - IndexDeprecationChecks::deprecatedCamelCasePattern + IndexDeprecationChecks::deprecatedCamelCasePattern, + IndexDeprecationChecks::checkSourceModeInMapping ); static List> DATA_STREAM_CHECKS = List.of( diff --git a/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/IndexDeprecationChecks.java b/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/IndexDeprecationChecks.java index 8144d960df2e8..aaf58a44a6565 100644 --- a/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/IndexDeprecationChecks.java +++ b/x-pack/plugin/deprecation/src/main/java/org/elasticsearch/xpack/deprecation/IndexDeprecationChecks.java @@ -16,6 +16,7 @@ import org.elasticsearch.index.IndexVersion; import org.elasticsearch.index.IndexVersions; import org.elasticsearch.index.engine.frozen.FrozenEngine; +import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.xpack.core.deprecation.DeprecationIssue; import java.util.ArrayList; @@ -201,6 +202,31 @@ static List findInPropertiesRecursively( return issues; } + static DeprecationIssue checkSourceModeInMapping(IndexMetadata indexMetadata, ClusterState clusterState) { + if (SourceFieldMapper.onOrAfterDeprecateModeVersion(indexMetadata.getCreationVersion())) { + boolean[] useSourceMode = { false }; + fieldLevelMappingIssue(indexMetadata, ((mappingMetadata, sourceAsMap) -> { + Object source = sourceAsMap.get("_source"); + if (source instanceof Map sourceMap) { + if (sourceMap.containsKey("mode")) { + useSourceMode[0] = true; + } + } + })); + if (useSourceMode[0]) { + return new DeprecationIssue( + DeprecationIssue.Level.CRITICAL, + SourceFieldMapper.DEPRECATION_WARNING, + "https://github.com/elastic/elasticsearch/pull/117172", + SourceFieldMapper.DEPRECATION_WARNING, + false, + null + ); + } + } + return null; + } + static DeprecationIssue deprecatedCamelCasePattern(IndexMetadata indexMetadata, ClusterState clusterState) { List fields = new ArrayList<>(); fieldLevelMappingIssue( diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/AsyncOperator.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/AsyncOperator.java index 2c36b42dee277..06b890603e489 100644 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/AsyncOperator.java +++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/AsyncOperator.java @@ -43,6 +43,7 @@ public abstract class AsyncOperator implements Operator { private final int maxOutstandingRequests; private final LongAdder totalTimeInNanos = new LongAdder(); + private boolean finished = false; private volatile boolean closed = false; diff --git a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeCustomSettingsIT.java b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeCustomSettingsIT.java index f529b9fa1db96..99acbec04551e 100644 --- a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeCustomSettingsIT.java +++ b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeCustomSettingsIT.java @@ -7,9 +7,11 @@ package org.elasticsearch.xpack.logsdb; +import org.elasticsearch.client.Request; import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.cluster.local.distribution.DistributionType; import org.junit.Before; @@ -112,8 +114,11 @@ public void testConfigureStoredSourceBeforeIndexCreation() throws IOException { }"""; assertOK(putComponentTemplate(client, "logs@custom", storedSourceMapping)); - assertOK(createDataStream(client, "logs-custom-dev")); - + Request request = new Request("PUT", "_data_stream/logs-custom-dev"); + if (SourceFieldMapper.onOrAfterDeprecateModeVersion(minimumIndexVersion())) { + request.setOptions(expectVersionSpecificWarnings(v -> v.current(SourceFieldMapper.DEPRECATION_WARNING))); + } + assertOK(client.performRequest(request)); var mapping = getMapping(client, getDataStreamBackingIndex(client, "logs-custom-dev", 0)); String sourceMode = (String) subObject("_source").apply(mapping).get("mode"); assertThat(sourceMode, equalTo("stored")); @@ -182,7 +187,11 @@ public void testConfigureStoredSourceWhenIndexIsCreated() throws IOException { }"""; assertOK(putComponentTemplate(client, "logs@custom", storedSourceMapping)); - assertOK(createDataStream(client, "logs-custom-dev")); + Request request = new Request("PUT", "_data_stream/logs-custom-dev"); + if (SourceFieldMapper.onOrAfterDeprecateModeVersion(minimumIndexVersion())) { + request.setOptions(expectVersionSpecificWarnings(v -> v.current(SourceFieldMapper.DEPRECATION_WARNING))); + } + assertOK(client.performRequest(request)); var mapping = getMapping(client, getDataStreamBackingIndex(client, "logs-custom-dev", 0)); String sourceMode = (String) subObject("_source").apply(mapping).get("mode"); diff --git a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeRestTestIT.java b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeRestTestIT.java index cc7f5bdb33871..0990592cef5e3 100644 --- a/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeRestTestIT.java +++ b/x-pack/plugin/logsdb/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsIndexModeRestTestIT.java @@ -11,6 +11,7 @@ import org.elasticsearch.client.Response; import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; +import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.test.rest.ESRestTestCase; import java.io.IOException; @@ -35,6 +36,11 @@ protected static Response putComponentTemplate(final RestClient client, final St throws IOException { final Request request = new Request("PUT", "/_component_template/" + componentTemplate); request.setJsonEntity(contends); + if (isSyntheticSourceConfiguredInTemplate(contends) && SourceFieldMapper.onOrAfterDeprecateModeVersion(minimumIndexVersion())) { + request.setOptions( + expectVersionSpecificWarnings((VersionSensitiveWarningsHandler v) -> v.current(SourceFieldMapper.DEPRECATION_WARNING)) + ); + } return client.performRequest(request); } diff --git a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java index 1f5d26eaedf34..d6cdb9f761b31 100644 --- a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java +++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java @@ -81,10 +81,12 @@ public void testNewIndexHasSyntheticSourceUsage() throws IOException { boolean result = provider.newIndexHasSyntheticSourceUsage(indexName, null, settings, List.of(new CompressedXContent(mapping))); assertTrue(result); assertThat(newMapperServiceCounter.get(), equalTo(1)); + assertWarnings(SourceFieldMapper.DEPRECATION_WARNING); } { String mapping; - if (randomBoolean()) { + boolean withSourceMode = randomBoolean(); + if (withSourceMode) { mapping = """ { "_doc": { @@ -115,6 +117,9 @@ public void testNewIndexHasSyntheticSourceUsage() throws IOException { boolean result = provider.newIndexHasSyntheticSourceUsage(indexName, null, settings, List.of(new CompressedXContent(mapping))); assertFalse(result); assertThat(newMapperServiceCounter.get(), equalTo(2)); + if (withSourceMode) { + assertWarnings(SourceFieldMapper.DEPRECATION_WARNING); + } } } From 1402e6887ca45a74d2818725e254bd3062db05f8 Mon Sep 17 00:00:00 2001 From: Ioana Tagirta Date: Mon, 25 Nov 2024 19:29:22 +0100 Subject: [PATCH 027/129] Add support for aggregations, GROK and DISSECT for semantic_text (#117337) * Add support for aggregations for semantic_text * Add capability to csv tests for grok and dissect * Sort values to avoid flaky tests --- .../xpack/esql/qa/rest/EsqlSpecTestCase.java | 3 +- .../main/resources/mapping-semantic_text.json | 4 + .../src/main/resources/semantic_text.csv | 8 +- .../src/main/resources/semantic_text.csv-spec | 98 +++++++++++++++++-- .../xpack/esql/action/EsqlCapabilities.java | 7 +- .../function/aggregate/CountDistinct.java | 3 +- .../expression/function/aggregate/Max.java | 1 + .../expression/function/aggregate/Min.java | 3 +- .../expression/function/aggregate/Values.java | 1 + .../xpack/esql/planner/AggregateMapper.java | 9 +- .../aggregate/CountDistinctTests.java | 3 +- .../function/aggregate/CountTests.java | 3 +- .../function/aggregate/MaxTests.java | 3 +- .../function/aggregate/MinTests.java | 3 +- .../function/aggregate/TopTests.java | 3 +- .../function/aggregate/ValuesTests.java | 3 +- 16 files changed, 131 insertions(+), 24 deletions(-) diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java index 265d9f7bd8cd5..2484a428c4b03 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java @@ -172,7 +172,8 @@ public final void test() throws Throwable { } protected void shouldSkipTest(String testName) throws IOException { - if (testCase.requiredCapabilities.contains("semantic_text_type")) { + if (testCase.requiredCapabilities.contains("semantic_text_type") + || testCase.requiredCapabilities.contains("semantic_text_aggregations")) { assumeTrue("Inference test service needs to be supported for semantic_text", supportsInferenceTestService()); } checkCapabilities(adminClient(), testFeatureService, testName, testCase); diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-semantic_text.json b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-semantic_text.json index c587b69828170..db15133f036bb 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-semantic_text.json +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-semantic_text.json @@ -72,6 +72,10 @@ "st_base64": { "type": "semantic_text", "inference_id": "test_sparse_inference" + }, + "st_logs": { + "type": "semantic_text", + "inference_id": "test_sparse_inference" } } } diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv index 6cae82cfefa0a..bd5fe7fad3a4e 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv @@ -1,4 +1,4 @@ -_id:keyword,semantic_text_field:semantic_text,st_bool:semantic_text,st_cartesian_point:semantic_text,st_cartesian_shape:semantic_text,st_datetime:semantic_text,st_double:semantic_text,st_geopoint:semantic_text,st_geoshape:semantic_text,st_integer:semantic_text,st_ip:semantic_text,st_long:semantic_text,st_unsigned_long:semantic_text,st_version:semantic_text,st_multi_value:semantic_text,st_unicode:semantic_text,host:keyword,description:text,value:long,st_base64:semantic_text -1,live long and prosper,false,"POINT(4297.11 -1475.53)",,1953-09-02T00:00:00.000Z,5.20128E11,"POINT(42.97109630194 14.7552534413725)","POLYGON ((30 10\, 40 40\, 20 40\, 10 20\, 30 10))",23,1.1.1.1,2147483648,2147483648,1.2.3,["Hello there!", "This is a random value", "for testing purposes"],你吃饭了吗,"host1","some description1",1001,ZWxhc3RpYw== -2,all we have to decide is what to do with the time that is given to us,true,"POINT(7580.93 2272.77)",,2023-09-24T15:57:00.000Z,4541.11,"POINT(37.97109630194 21.7552534413725)","POLYGON ((30 10\, 40 40\, 20 40\, 10 20\, 30 10))",122,1.1.2.1,123,2147483648.2,9.0.0,["nice to meet you", "bye bye!"],["谢谢", "对不起我的中文不好"],"host2","some description2",1002,aGVsbG8= -3,be excellent to each other,,,,,,,,,,,,,,,"host3","some description3",1003, +_id:keyword,semantic_text_field:semantic_text,st_bool:semantic_text,st_cartesian_point:semantic_text,st_cartesian_shape:semantic_text,st_datetime:semantic_text,st_double:semantic_text,st_geopoint:semantic_text,st_geoshape:semantic_text,st_integer:semantic_text,st_ip:semantic_text,st_long:semantic_text,st_unsigned_long:semantic_text,st_version:semantic_text,st_multi_value:semantic_text,st_unicode:semantic_text,host:keyword,description:text,value:long,st_base64:semantic_text,st_logs:semantic_text +1,live long and prosper,false,"POINT(4297.11 -1475.53)",,1953-09-02T00:00:00.000Z,5.20128E11,"POINT(42.97109630194 14.7552534413725)","POLYGON ((30 10\, 40 40\, 20 40\, 10 20\, 30 10))",23,1.1.1.1,2147483648,2147483648,1.2.3,["Hello there!", "This is a random value", "for testing purposes"],你吃饭了吗,"host1","some description1",1001,ZWxhc3RpYw==,"2024-12-23T12:15:00.000Z 1.2.3.4 example@example.com 4553" +2,all we have to decide is what to do with the time that is given to us,true,"POINT(7580.93 2272.77)",,2023-09-24T15:57:00.000Z,4541.11,"POINT(37.97109630194 21.7552534413725)","POLYGON ((30 10\, 40 40\, 20 40\, 10 20\, 30 10))",122,1.1.2.1,123,2147483648.2,9.0.0,["nice to meet you", "bye bye!"],["谢谢", "对不起我的中文不好"],"host2","some description2",1002,aGVsbG8=,"2024-01-23T12:15:00.000Z 1.2.3.4 foo@example.com 42" +3,be excellent to each other,,,,,,,,,,,,,,,"host3","some description3",1003,,"2023-01-23T12:15:00.000Z 127.0.0.1 some.email@foo.com 42" diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec index de2a79df06a50..43dc6e4d4acd2 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec @@ -88,19 +88,75 @@ _id:keyword | my_field:semantic_text 3 | be excellent to each other ; -simpleStats -required_capability: semantic_text_type +statsWithCount +required_capability: semantic_text_aggregations + +FROM semantic_text METADATA _id +| STATS result = COUNT(st_version) +; + +result:long +2 +; + +statsWithCountDistinct +required_capability: semantic_text_aggregations + +FROM semantic_text METADATA _id +| STATS result = COUNT_DISTINCT(st_version) +; + +result:long +2 +; + +statsWithValues +required_capability: semantic_text_aggregations + +FROM semantic_text METADATA _id +| STATS result = VALUES(st_version) +| EVAL result = MV_SORT(result) +; + +result:keyword +["1.2.3", "9.0.0"] +; + +statsWithMin +required_capability: semantic_text_aggregations + +FROM semantic_text METADATA _id +| STATS result = min(st_version) +; + +result:keyword +1.2.3 +; + +statsWithMax +required_capability: semantic_text_aggregations FROM semantic_text METADATA _id -| STATS COUNT(*) +| STATS result = max(st_version) ; -COUNT(*):long -3 +result:keyword +9.0.0 +; + +statsWithTop +required_capability: semantic_text_aggregations + +FROM semantic_text METADATA _id +| STATS result = top(st_version, 2, "asc") +; + +result:keyword +["1.2.3", "9.0.0"] ; statsWithGrouping -required_capability: semantic_text_type +required_capability: semantic_text_aggregations FROM semantic_text METADATA _id | STATS COUNT(*) BY st_version @@ -132,6 +188,36 @@ COUNT(*):long | my_field:semantic_text 1 | bye bye! ; +grok +required_capability: semantic_text_type + +FROM semantic_text METADATA _id +| GROK st_logs """%{TIMESTAMP_ISO8601:date} %{IP:ip} %{EMAILADDRESS:email} %{NUMBER:num}""" +| KEEP st_logs, date, ip, email, num +| SORT st_logs +; + +st_logs:semantic_text | date:keyword | ip:keyword | email:keyword | num:keyword +2023-01-23T12:15:00.000Z 127.0.0.1 some.email@foo.com 42 | 2023-01-23T12:15:00.000Z | 127.0.0.1 | some.email@foo.com | 42 +2024-01-23T12:15:00.000Z 1.2.3.4 foo@example.com 42 | 2024-01-23T12:15:00.000Z | 1.2.3.4 | foo@example.com | 42 +2024-12-23T12:15:00.000Z 1.2.3.4 example@example.com 4553 | 2024-12-23T12:15:00.000Z | 1.2.3.4 | example@example.com | 4553 +; + +dissect +required_capability: semantic_text_type + +FROM semantic_text METADATA _id +| DISSECT st_logs """%{date} %{ip} %{email} %{num}""" +| KEEP st_logs, date, ip, email, num +| SORT st_logs +; + +st_logs:semantic_text | date:keyword | ip:keyword | email:keyword | num:keyword +2023-01-23T12:15:00.000Z 127.0.0.1 some.email@foo.com 42 | 2023-01-23T12:15:00.000Z | 127.0.0.1 | some.email@foo.com | 42 +2024-01-23T12:15:00.000Z 1.2.3.4 foo@example.com 42 | 2024-01-23T12:15:00.000Z | 1.2.3.4 | foo@example.com | 42 +2024-12-23T12:15:00.000Z 1.2.3.4 example@example.com 4553 | 2024-12-23T12:15:00.000Z | 1.2.3.4 | example@example.com | 4553 +; + simpleWithLongValue required_capability: semantic_text_type 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 d9ce7fca312b3..08fa7f0a9b213 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 @@ -526,7 +526,12 @@ public enum Cap { /** * Fix for https://github.com/elastic/elasticsearch/issues/117054 */ - FIX_NESTED_FIELDS_NAME_CLASH_IN_INDEXRESOLVER; + FIX_NESTED_FIELDS_NAME_CLASH_IN_INDEXRESOLVER, + + /** + * support for aggregations on semantic_text + */ + SEMANTIC_TEXT_AGGREGATIONS(EsqlCorePlugin.SEMANTIC_TEXT_FEATURE_FLAG); private final boolean enabled; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinct.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinct.java index 5ae162f1fbb12..2e45b1c1fe082 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinct.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinct.java @@ -66,7 +66,8 @@ public class CountDistinct extends AggregateFunction implements OptionalArgument Map.entry(DataType.KEYWORD, CountDistinctBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.IP, CountDistinctBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.VERSION, CountDistinctBytesRefAggregatorFunctionSupplier::new), - Map.entry(DataType.TEXT, CountDistinctBytesRefAggregatorFunctionSupplier::new) + Map.entry(DataType.TEXT, CountDistinctBytesRefAggregatorFunctionSupplier::new), + Map.entry(DataType.SEMANTIC_TEXT, CountDistinctBytesRefAggregatorFunctionSupplier::new) ); private static final int DEFAULT_PRECISION = 3000; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java index 2165c3c7ad1a0..eb0c8abd1080b 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java @@ -51,6 +51,7 @@ public class Max extends AggregateFunction implements ToAggregator, SurrogateExp Map.entry(DataType.IP, MaxIpAggregatorFunctionSupplier::new), Map.entry(DataType.KEYWORD, MaxBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.TEXT, MaxBytesRefAggregatorFunctionSupplier::new), + Map.entry(DataType.SEMANTIC_TEXT, MaxBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.VERSION, MaxBytesRefAggregatorFunctionSupplier::new) ); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java index 7d67868dd4134..472f0b1ff5cd1 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java @@ -51,7 +51,8 @@ public class Min extends AggregateFunction implements ToAggregator, SurrogateExp Map.entry(DataType.IP, MinIpAggregatorFunctionSupplier::new), Map.entry(DataType.VERSION, MinBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.KEYWORD, MinBytesRefAggregatorFunctionSupplier::new), - Map.entry(DataType.TEXT, MinBytesRefAggregatorFunctionSupplier::new) + Map.entry(DataType.TEXT, MinBytesRefAggregatorFunctionSupplier::new), + Map.entry(DataType.SEMANTIC_TEXT, MinBytesRefAggregatorFunctionSupplier::new) ); @FunctionInfo( diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java index e7df990b20422..5260b3e8fa279 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java @@ -46,6 +46,7 @@ public class Values extends AggregateFunction implements ToAggregator { Map.entry(DataType.DOUBLE, ValuesDoubleAggregatorFunctionSupplier::new), Map.entry(DataType.KEYWORD, ValuesBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.TEXT, ValuesBytesRefAggregatorFunctionSupplier::new), + Map.entry(DataType.SEMANTIC_TEXT, ValuesBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.IP, ValuesBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.VERSION, ValuesBytesRefAggregatorFunctionSupplier::new), Map.entry(DataType.BOOLEAN, ValuesBooleanAggregatorFunctionSupplier::new) diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AggregateMapper.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AggregateMapper.java index 605e0d7c3109c..18bbfdf485a81 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AggregateMapper.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/AggregateMapper.java @@ -302,12 +302,13 @@ private static String dataTypeToString(DataType type, Class aggClass) { case DataType.INTEGER, DataType.COUNTER_INTEGER -> "Int"; case DataType.LONG, DataType.DATETIME, DataType.COUNTER_LONG, DataType.DATE_NANOS -> "Long"; case DataType.DOUBLE, DataType.COUNTER_DOUBLE -> "Double"; - case DataType.KEYWORD, DataType.IP, DataType.VERSION, DataType.TEXT -> "BytesRef"; + case DataType.KEYWORD, DataType.IP, DataType.VERSION, DataType.TEXT, DataType.SEMANTIC_TEXT -> "BytesRef"; case GEO_POINT -> "GeoPoint"; case CARTESIAN_POINT -> "CartesianPoint"; - case SEMANTIC_TEXT, UNSUPPORTED, NULL, UNSIGNED_LONG, SHORT, BYTE, FLOAT, HALF_FLOAT, SCALED_FLOAT, OBJECT, SOURCE, DATE_PERIOD, - TIME_DURATION, CARTESIAN_SHAPE, GEO_SHAPE, DOC_DATA_TYPE, TSID_DATA_TYPE, PARTIAL_AGG -> - throw new EsqlIllegalArgumentException("illegal agg type: " + type.typeName()); + case UNSUPPORTED, NULL, UNSIGNED_LONG, SHORT, BYTE, FLOAT, HALF_FLOAT, SCALED_FLOAT, OBJECT, SOURCE, DATE_PERIOD, TIME_DURATION, + CARTESIAN_SHAPE, GEO_SHAPE, DOC_DATA_TYPE, TSID_DATA_TYPE, PARTIAL_AGG -> throw new EsqlIllegalArgumentException( + "illegal agg type: " + type.typeName() + ); }; } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinctTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinctTests.java index fff2d824fc710..e0b8c1356d087 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinctTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountDistinctTests.java @@ -57,7 +57,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.ipCases(1, 1000), MultiRowTestCaseSupplier.versionCases(1, 1000), MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.SEMANTIC_TEXT) ).flatMap(List::stream).forEach(fieldCaseSupplier -> { // With precision for (var precisionCaseSupplier : precisionSuppliers) { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountTests.java index 979048534edbf..131072acff870 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/CountTests.java @@ -47,7 +47,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.geoPointCases(1, 1000, true), MultiRowTestCaseSupplier.cartesianPointCases(1, 1000, true), MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.SEMANTIC_TEXT) ).flatMap(List::stream).map(CountTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers)); // No rows diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java index 7d4b46f2a902a..ae5b3691b0a7d 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java @@ -48,7 +48,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.ipCases(1, 1000), MultiRowTestCaseSupplier.versionCases(1, 1000), MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.SEMANTIC_TEXT) ).flatMap(List::stream).map(MaxTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers)); suppliers.addAll( diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java index 58ef8d86017a8..ad2953f057635 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java @@ -48,7 +48,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.ipCases(1, 1000), MultiRowTestCaseSupplier.versionCases(1, 1000), MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.SEMANTIC_TEXT) ).flatMap(List::stream).map(MinTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers)); suppliers.addAll( diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java index f7bf338caa099..f236e4d8faf98 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java @@ -48,7 +48,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.booleanCases(1, 1000), MultiRowTestCaseSupplier.ipCases(1, 1000), MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.SEMANTIC_TEXT) ) .flatMap(List::stream) .map(fieldCaseSupplier -> TopTests.makeSupplier(fieldCaseSupplier, limitCaseSupplier, order)) diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/ValuesTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/ValuesTests.java index 29faceee7497e..5f35f8cada397 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/ValuesTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/ValuesTests.java @@ -51,7 +51,8 @@ public static Iterable parameters() { MultiRowTestCaseSupplier.versionCases(1, 1000), // Lower values for strings, as they take more space and may trigger the circuit breaker MultiRowTestCaseSupplier.stringCases(1, 20, DataType.KEYWORD), - MultiRowTestCaseSupplier.stringCases(1, 20, DataType.TEXT) + MultiRowTestCaseSupplier.stringCases(1, 20, DataType.TEXT), + MultiRowTestCaseSupplier.stringCases(1, 20, DataType.SEMANTIC_TEXT) ).flatMap(List::stream).map(ValuesTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers)); return parameterSuppliersFromTypedDataWithDefaultChecks( From 219372efaaf46a3b496df2142d3091d3434e67ec Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 25 Nov 2024 13:44:59 -0500 Subject: [PATCH 028/129] [CI] Ignore error about missing UBI artifact (#117506) --- .buildkite/scripts/dra-workflow.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.buildkite/scripts/dra-workflow.sh b/.buildkite/scripts/dra-workflow.sh index f2dc40ca1927f..bbfa81f51b286 100755 --- a/.buildkite/scripts/dra-workflow.sh +++ b/.buildkite/scripts/dra-workflow.sh @@ -75,6 +75,7 @@ find "$WORKSPACE" -type d -path "*/build/distributions" -exec chmod a+w {} \; echo --- Running release-manager +set +e # Artifacts should be generated docker run --rm \ --name release-manager \ @@ -91,4 +92,16 @@ docker run --rm \ --version "$ES_VERSION" \ --artifact-set main \ --dependency "beats:https://artifacts-${WORKFLOW}.elastic.co/beats/${BEATS_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" \ - --dependency "ml-cpp:https://artifacts-${WORKFLOW}.elastic.co/ml-cpp/${ML_CPP_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" + --dependency "ml-cpp:https://artifacts-${WORKFLOW}.elastic.co/ml-cpp/${ML_CPP_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" \ +2>&1 | tee release-manager.log +EXIT_CODE=$? +set -e + +# This failure is just generating a ton of noise right now, so let's just ignore it +# This should be removed once this issue has been fixed +if grep "elasticsearch-ubi-9.0.0-SNAPSHOT-docker-image.tar.gz" release-manager.log; then + echo "Ignoring error about missing ubi artifact" + exit 0 +fi + +exit "$EXIT_CODE" From 5a6464c552dc8d58dc40119292a99ff4d69cf385 Mon Sep 17 00:00:00 2001 From: Mikhail Berezovskiy Date: Mon, 25 Nov 2024 14:14:37 -0500 Subject: [PATCH 029/129] Add HTTP request info to leaking buffers (#116130) --- .../netty4/Netty4HttpServerTransport.java | 4 ++ .../netty4/Netty4LeakDetectionHandler.java | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4LeakDetectionHandler.java diff --git a/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4HttpServerTransport.java b/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4HttpServerTransport.java index b971a52b7afb6..36c860f1fb90b 100644 --- a/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4HttpServerTransport.java +++ b/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4HttpServerTransport.java @@ -33,6 +33,7 @@ import io.netty.handler.timeout.ReadTimeoutException; import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.util.AttributeKey; +import io.netty.util.ResourceLeakDetector; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -410,6 +411,9 @@ protected Result beginEncode(HttpResponse httpResponse, String acceptEncoding) t } }); } + if (ResourceLeakDetector.isEnabled()) { + ch.pipeline().addLast(new Netty4LeakDetectionHandler()); + } ch.pipeline() .addLast( "pipelining", diff --git a/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4LeakDetectionHandler.java b/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4LeakDetectionHandler.java new file mode 100644 index 0000000000000..8a0274872e493 --- /dev/null +++ b/modules/transport-netty4/src/main/java/org/elasticsearch/http/netty4/Netty4LeakDetectionHandler.java @@ -0,0 +1,40 @@ +/* + * 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.http.netty4; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpRequest; + +import org.elasticsearch.tasks.Task; + +/** + * Inbound channel handler that enrich leaking buffers information from HTTP request. + * It helps to detect which handler is leaking buffers. Especially integration tests that run with + * paranoid leak detector that samples all buffers for leaking. Supplying informative opaque-id in + * integ test helps to narrow down problem (for example test name). + */ +public class Netty4LeakDetectionHandler extends ChannelInboundHandlerAdapter { + + private String info; + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof HttpRequest request) { + var opaqueId = request.headers().get(Task.X_OPAQUE_ID_HTTP_HEADER); + info = "method: " + request.method() + "; uri: " + request.uri() + "; x-opaque-id: " + opaqueId; + } + if (msg instanceof HttpContent content) { + content.touch(info); + } + ctx.fireChannelRead(msg); + } +} From 930a99cc3874e2e195f4a79a4c0f5953fcf27b45 Mon Sep 17 00:00:00 2001 From: Carlos Delgado <6339205+carlosdelest@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:19:24 +0100 Subject: [PATCH 030/129] Fix and unmute synonyms tests using timeout (#117486) --- muted-tests.yml | 3 --- .../rest-api-spec/test/synonyms/10_synonyms_put.yml | 10 ++++++++-- .../test/synonyms/110_synonyms_invalid.yml | 5 ++++- .../rest-api-spec/test/synonyms/20_synonyms_get.yml | 5 ++++- .../rest-api-spec/test/synonyms/30_synonyms_delete.yml | 6 +++++- .../test/synonyms/40_synonyms_sets_get.yml | 5 ++++- .../test/synonyms/50_synonym_rule_put.yml | 6 +++++- .../test/synonyms/60_synonym_rule_get.yml | 6 ++++-- .../test/synonyms/70_synonym_rule_delete.yml | 5 ++++- .../test/synonyms/80_synonyms_from_index.yml | 5 ++++- .../test/synonyms/90_synonyms_reloading_for_synset.yml | 5 ++++- 11 files changed, 46 insertions(+), 15 deletions(-) diff --git a/muted-tests.yml b/muted-tests.yml index da8a093ebe674..f4c5a418666b9 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -185,9 +185,6 @@ tests: - class: org.elasticsearch.xpack.test.rest.XPackRestIT method: test {p0=snapshot/20_operator_privileges_disabled/Operator only settings can be set and restored by non-operator user when operator privileges is disabled} issue: https://github.com/elastic/elasticsearch/issues/116775 -- 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 - class: org.elasticsearch.xpack.searchablesnapshots.hdfs.SecureHdfsSearchableSnapshotsIT issue: https://github.com/elastic/elasticsearch/issues/116851 - class: org.elasticsearch.search.basic.SearchWithRandomIOExceptionsIT diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/10_synonyms_put.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/10_synonyms_put.yml index 675b98133ce11..93f1fafa7ab85 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/10_synonyms_put.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/10_synonyms_put.yml @@ -17,7 +17,10 @@ setup: - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 - do: synonyms.get_synonym: @@ -64,7 +67,10 @@ setup: - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 - do: synonyms.get_synonym: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/110_synonyms_invalid.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/110_synonyms_invalid.yml index 4e77e10495109..7f545b466e65f 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/110_synonyms_invalid.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/110_synonyms_invalid.yml @@ -14,7 +14,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 - do: indices.create: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/20_synonyms_get.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/20_synonyms_get.yml index 5e6d4ec2341ad..9e6af0f471e6e 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/20_synonyms_get.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/20_synonyms_get.yml @@ -17,7 +17,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 --- "Get synonyms set": diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/30_synonyms_delete.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/30_synonyms_delete.yml index 23c907f6a1137..62e8fe333ce99 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/30_synonyms_delete.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/30_synonyms_delete.yml @@ -15,7 +15,11 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 + --- "Delete synonyms set": - do: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/40_synonyms_sets_get.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/40_synonyms_sets_get.yml index 7c145dafd81cd..3815ea2c96c97 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/40_synonyms_sets_get.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/40_synonyms_sets_get.yml @@ -13,7 +13,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 - do: synonyms.put_synonym: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/50_synonym_rule_put.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/50_synonym_rule_put.yml index d8611000fe465..02757f711f690 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/50_synonym_rule_put.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/50_synonym_rule_put.yml @@ -17,7 +17,11 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 + --- "Update a synonyms rule": - do: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/60_synonym_rule_get.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/60_synonym_rule_get.yml index 0c962b51e08cb..9f1aa1d254169 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/60_synonym_rule_get.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/60_synonym_rule_get.yml @@ -17,8 +17,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true - + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 --- "Get a synonym rule": diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/70_synonym_rule_delete.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/70_synonym_rule_delete.yml index 41ab293158a35..d2c706decf4fd 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/70_synonym_rule_delete.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/70_synonym_rule_delete.yml @@ -17,7 +17,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 --- "Delete synonym rule": diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/80_synonyms_from_index.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/80_synonyms_from_index.yml index 3aba0f0b4b78b..965cae551fab2 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/80_synonyms_from_index.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/80_synonyms_from_index.yml @@ -16,7 +16,10 @@ setup: # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 # Create an index with synonym_filter that uses that synonyms set - do: diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/90_synonyms_reloading_for_synset.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/90_synonyms_reloading_for_synset.yml index ac01f2dc0178a..d6c98673253fb 100644 --- a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/90_synonyms_reloading_for_synset.yml +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/synonyms/90_synonyms_reloading_for_synset.yml @@ -28,7 +28,10 @@ # This is to ensure that all index shards (write and read) are available. In serverless this can take some time. - do: cluster.health: - wait_for_no_initializing_shards: true + index: .synonyms-2 + timeout: 2s + wait_for_status: green + ignore: 408 # Create my_index1 with synonym_filter that uses synonyms_set1 - do: From 565218e43e0874808d5929ccfecf7359d0423b37 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:38:32 +1100 Subject: [PATCH 031/129] Mute org.elasticsearch.xpack.esql.qa.single_node.FieldExtractorIT testConstantKeywordField #117524 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index f4c5a418666b9..2a800c2757f2b 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -232,6 +232,9 @@ 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 # Examples: # From 6260746cb958785c6d6c1aa023d4e00ee8b8d56f Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:39:00 +1100 Subject: [PATCH 032/129] Mute org.elasticsearch.xpack.esql.qa.multi_node.FieldExtractorIT testConstantKeywordField #117524 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 2a800c2757f2b..986bea5b248f1 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -235,6 +235,9 @@ tests: - 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 # Examples: # From e5b9b7e9babe42043184b4a2820645a46b9a000d Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:46:33 +1100 Subject: [PATCH 033/129] Mute org.elasticsearch.repositories.s3.RepositoryS3EcsClientYamlTestSuiteIT org.elasticsearch.repositories.s3.RepositoryS3EcsClientYamlTestSuiteIT #117525 --- muted-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 986bea5b248f1..37f36e9a19340 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -238,6 +238,8 @@ tests: - 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 # Examples: # From 0f5eb0c2762938eac558eb1c09e9189ecd2f5113 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Mon, 25 Nov 2024 15:15:14 -0800 Subject: [PATCH 034/129] Adjust deprecate index versions (#117523) Adjust the deprecation index check to support the backport version in 8.x. Relates #117183 --- .../src/main/java/org/elasticsearch/index/IndexVersions.java | 1 + .../org/elasticsearch/index/mapper/SourceFieldMapper.java | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/index/IndexVersions.java b/server/src/main/java/org/elasticsearch/index/IndexVersions.java index 7a5f469a57fa1..6344aa2a72ca9 100644 --- a/server/src/main/java/org/elasticsearch/index/IndexVersions.java +++ b/server/src/main/java/org/elasticsearch/index/IndexVersions.java @@ -131,6 +131,7 @@ private static Version parseUnchecked(String version) { public static final IndexVersion ADD_ROLE_MAPPING_CLEANUP_MIGRATION = def(8_518_00_0, Version.LUCENE_9_12_0); public static final IndexVersion LOGSDB_DEFAULT_IGNORE_DYNAMIC_BEYOND_LIMIT_BACKPORT = def(8_519_00_0, Version.LUCENE_9_12_0); public static final IndexVersion TIME_BASED_K_ORDERED_DOC_ID_BACKPORT = def(8_520_00_0, Version.LUCENE_9_12_0); + public static final IndexVersion V8_DEPRECATE_SOURCE_MODE_MAPPER = def(8_521_00_0, Version.LUCENE_9_12_0); public static final IndexVersion UPGRADE_TO_LUCENE_10_0_0 = def(9_000_00_0, Version.LUCENE_10_0_0); public static final IndexVersion LOGSDB_DEFAULT_IGNORE_DYNAMIC_BEYOND_LIMIT = def(9_001_00_0, Version.LUCENE_10_0_0); public static final IndexVersion TIME_BASED_K_ORDERED_DOC_ID = def(9_002_00_0, Version.LUCENE_10_0_0); 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 9d0dc9635537b..e7c7ec3535b91 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java @@ -484,8 +484,7 @@ public boolean isStored() { } public static boolean onOrAfterDeprecateModeVersion(IndexVersion version) { - return version.onOrAfter(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER); - // Adjust versions after backporting. - // || version.between(IndexVersions.BACKPORT_DEPRECATE_SOURCE_MODE_MAPPER, IndexVersions.UPGRADE_TO_LUCENE_10_0_0); + return version.onOrAfter(IndexVersions.DEPRECATE_SOURCE_MODE_MAPPER) + || version.between(IndexVersions.V8_DEPRECATE_SOURCE_MODE_MAPPER, IndexVersions.UPGRADE_TO_LUCENE_10_0_0); } } From fadc752b4a854bb655896b232fd02a2e9d822518 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Mon, 25 Nov 2024 18:52:17 -0800 Subject: [PATCH 035/129] Allow exchange source continue on failure (#117410) Currently, when an exchange request fails, we stop fetching pages and abort the ExchangeSource. However, to support partial_results, we need to continue fetching pages from other remote sinks despite failures. This change introduces a failFast flag in ExchangeSource, which enables the process to continue in case of failures. By default, this flag is set to true but switches to false when allow_partial_results is enabled. --- .../operator/exchange/ExchangeService.java | 4 +- .../exchange/ExchangeSinkHandler.java | 2 +- .../exchange/ExchangeSourceHandler.java | 94 ++++++++---- .../operator/ForkingOperatorTestCase.java | 15 +- .../exchange/ExchangeServiceTests.java | 137 +++++++++++++++--- .../xpack/esql/plugin/ComputeService.java | 79 +++++----- .../elasticsearch/xpack/esql/CsvTests.java | 11 +- 7 files changed, 253 insertions(+), 89 deletions(-) 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 e6bae7ba385e6..d633270b5c595 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 @@ -47,7 +47,7 @@ /** * {@link ExchangeService} is responsible for exchanging pages between exchange sinks and sources on the same or different nodes. * It holds a map of {@link ExchangeSinkHandler} instances for each node in the cluster to serve {@link ExchangeRequest}s - * To connect exchange sources to exchange sinks, use the {@link ExchangeSourceHandler#addRemoteSink(RemoteSink, int)} method. + * To connect exchange sources to exchange sinks, use {@link ExchangeSourceHandler#addRemoteSink(RemoteSink, boolean, int, ActionListener)}. */ public final class ExchangeService extends AbstractLifecycleComponent { // TODO: Make this a child action of the data node transport to ensure that exchanges @@ -311,7 +311,7 @@ static final class TransportRemoteSink implements RemoteSink { @Override public void fetchPageAsync(boolean allSourcesFinished, ActionListener listener) { - final long reservedBytes = estimatedPageSizeInBytes.get(); + final long reservedBytes = allSourcesFinished ? 0 : estimatedPageSizeInBytes.get(); if (reservedBytes > 0) { // This doesn't fully protect ESQL from OOM, but reduces the likelihood. blockFactory.breaker().addEstimateBytesAndMaybeBreak(reservedBytes, "fetch page"); diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSinkHandler.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSinkHandler.java index 757a3262433c8..614c3fe0ecc5c 100644 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSinkHandler.java +++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSinkHandler.java @@ -93,7 +93,7 @@ public IsBlockedResult waitForWriting() { * @param sourceFinished if true, then this handler can finish as sources have enough pages. * @param listener the listener that will be notified when pages are ready or this handler is finished * @see RemoteSink - * @see ExchangeSourceHandler#addRemoteSink(RemoteSink, int) + * @see ExchangeSourceHandler#addRemoteSink(RemoteSink, boolean, int, ActionListener) */ public void fetchPageAsync(boolean sourceFinished, ActionListener listener) { if (sourceFinished) { diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSourceHandler.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSourceHandler.java index 4baaf9ad89bd6..61b3386ce0274 100644 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSourceHandler.java +++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/operator/exchange/ExchangeSourceHandler.java @@ -24,10 +24,10 @@ /** * An {@link ExchangeSourceHandler} asynchronously fetches pages and status from multiple {@link RemoteSink}s * and feeds them to its {@link ExchangeSource}, which are created using the {@link #createExchangeSource()}) method. - * {@link RemoteSink}s are added using the {@link #addRemoteSink(RemoteSink, int)}) method. + * {@link RemoteSink}s are added using the {@link #addRemoteSink(RemoteSink, boolean, int, ActionListener)}) method. * * @see #createExchangeSource() - * @see #addRemoteSink(RemoteSink, int) + * @see #addRemoteSink(RemoteSink, boolean, int, ActionListener) */ public final class ExchangeSourceHandler { private final ExchangeBuffer buffer; @@ -35,13 +35,43 @@ public final class ExchangeSourceHandler { private final PendingInstances outstandingSinks; private final PendingInstances outstandingSources; + // Collect failures that occur while fetching pages from the remote sink with `failFast=true`. + // The exchange source will stop fetching and abort as soon as any failure is added to this failure collector. + // The final failure collected will be notified to callers via the {@code completionListener}. private final FailureCollector failure = new FailureCollector(); - public ExchangeSourceHandler(int maxBufferSize, Executor fetchExecutor) { + /** + * Creates a new ExchangeSourceHandler. + * + * @param maxBufferSize the maximum size of the exchange buffer. A larger buffer reduces ``pauses`` but uses more memory, + * which could otherwise be allocated for other purposes. + * @param fetchExecutor the executor used to fetch pages. + * @param completionListener a listener that will be notified when the exchange source handler fails or completes + */ + public ExchangeSourceHandler(int maxBufferSize, Executor fetchExecutor, ActionListener completionListener) { this.buffer = new ExchangeBuffer(maxBufferSize); this.fetchExecutor = fetchExecutor; this.outstandingSinks = new PendingInstances(() -> buffer.finish(false)); this.outstandingSources = new PendingInstances(() -> buffer.finish(true)); + buffer.addCompletionListener(ActionListener.running(() -> { + final ActionListener listener = ActionListener.assertAtLeastOnce(completionListener).delegateFailure((l, unused) -> { + final Exception e = failure.getFailure(); + if (e != null) { + l.onFailure(e); + } else { + l.onResponse(null); + } + }); + try (RefCountingListener refs = new RefCountingListener(listener)) { + for (PendingInstances pending : List.of(outstandingSinks, outstandingSources)) { + // Create an outstanding instance and then finish to complete the completionListener + // if we haven't registered any instances of exchange sinks or exchange sources before. + pending.trackNewInstance(); + pending.completion.addListener(refs.acquire()); + pending.finishInstance(); + } + } + })); } private class ExchangeSourceImpl implements ExchangeSource { @@ -89,20 +119,6 @@ public int bufferSize() { } } - public void addCompletionListener(ActionListener listener) { - buffer.addCompletionListener(ActionListener.running(() -> { - try (RefCountingListener refs = new RefCountingListener(listener)) { - for (PendingInstances pending : List.of(outstandingSinks, outstandingSources)) { - // Create an outstanding instance and then finish to complete the completionListener - // if we haven't registered any instances of exchange sinks or exchange sources before. - pending.trackNewInstance(); - pending.completion.addListener(refs.acquire()); - pending.finishInstance(); - } - } - })); - } - /** * Create a new {@link ExchangeSource} for exchanging data * @@ -159,10 +175,14 @@ void exited() { private final class RemoteSinkFetcher { private volatile boolean finished = false; private final RemoteSink remoteSink; + private final boolean failFast; + private final ActionListener completionListener; - RemoteSinkFetcher(RemoteSink remoteSink) { + RemoteSinkFetcher(RemoteSink remoteSink, boolean failFast, ActionListener completionListener) { outstandingSinks.trackNewInstance(); this.remoteSink = remoteSink; + this.failFast = failFast; + this.completionListener = completionListener; } void fetchPage() { @@ -198,15 +218,22 @@ void fetchPage() { } void onSinkFailed(Exception e) { - failure.unwrapAndCollect(e); + if (failFast) { + failure.unwrapAndCollect(e); + } buffer.waitForReading().listener().onResponse(null); // resume the Driver if it is being blocked on reading - onSinkComplete(); + if (finished == false) { + finished = true; + outstandingSinks.finishInstance(); + completionListener.onFailure(e); + } } void onSinkComplete() { if (finished == false) { finished = true; outstandingSinks.finishInstance(); + completionListener.onResponse(null); } } } @@ -215,23 +242,36 @@ void onSinkComplete() { * Add a remote sink as a new data source of this handler. The handler will start fetching data from this remote sink intermediately. * * @param remoteSink the remote sink - * @param instances the number of concurrent ``clients`` that this handler should use to fetch pages. More clients reduce latency, - * but add overhead. + * @param failFast determines how failures in this remote sink are handled: + * - If {@code false}, failures from this remote sink will not cause the exchange source to abort. + * Callers must handle these failures notified via {@code listener}. + * - If {@code true}, failures from this remote sink will cause the exchange source to abort. + * Callers can safely ignore failures notified via this listener, as they are collected and + * reported by the exchange source. + * @param instances the number of concurrent ``clients`` that this handler should use to fetch pages. + * More clients reduce latency, but add overhead. + * @param listener a listener that will be notified when the sink fails or completes * @see ExchangeSinkHandler#fetchPageAsync(boolean, ActionListener) */ - public void addRemoteSink(RemoteSink remoteSink, int instances) { + public void addRemoteSink(RemoteSink remoteSink, boolean failFast, int instances, ActionListener listener) { + final ActionListener sinkListener = ActionListener.assertAtLeastOnce(ActionListener.notifyOnce(listener)); fetchExecutor.execute(new AbstractRunnable() { @Override public void onFailure(Exception e) { - failure.unwrapAndCollect(e); + if (failFast) { + failure.unwrapAndCollect(e); + } buffer.waitForReading().listener().onResponse(null); // resume the Driver if it is being blocked on reading + sinkListener.onFailure(e); } @Override protected void doRun() { - for (int i = 0; i < instances; i++) { - var fetcher = new RemoteSinkFetcher(remoteSink); - fetcher.fetchPage(); + try (RefCountingListener refs = new RefCountingListener(sinkListener)) { + for (int i = 0; i < instances; i++) { + var fetcher = new RemoteSinkFetcher(remoteSink, failFast, refs.acquire()); + fetcher.fetchPage(); + } } } }); diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/ForkingOperatorTestCase.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/ForkingOperatorTestCase.java index c0396fdc469aa..542bf5bc384a5 100644 --- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/ForkingOperatorTestCase.java +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/operator/ForkingOperatorTestCase.java @@ -209,8 +209,19 @@ List createDriversForInput(List input, List results, boolean randomIntBetween(2, 10), threadPool.relativeTimeInMillisSupplier() ); - ExchangeSourceHandler sourceExchanger = new ExchangeSourceHandler(randomIntBetween(1, 4), threadPool.executor(ESQL_TEST_EXECUTOR)); - sourceExchanger.addRemoteSink(sinkExchanger::fetchPageAsync, 1); + ExchangeSourceHandler sourceExchanger = new ExchangeSourceHandler( + randomIntBetween(1, 4), + threadPool.executor(ESQL_TEST_EXECUTOR), + ActionListener.noop() + ); + sourceExchanger.addRemoteSink( + sinkExchanger::fetchPageAsync, + randomBoolean(), + 1, + ActionListener.noop().delegateResponse((l, e) -> { + throw new AssertionError("unexpected failure", e); + }) + ); Iterator intermediateOperatorItr; int itrSize = (splitInput.size() * 3) + 3; // 3 inter ops per initial source drivers, and 3 per final 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 0b1ecce8c375b..8949f61b7420d 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 @@ -15,6 +15,7 @@ import org.elasticsearch.cluster.ClusterModule; import org.elasticsearch.cluster.node.VersionInformation; import org.elasticsearch.common.breaker.CircuitBreaker; +import org.elasticsearch.common.breaker.CircuitBreakingException; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.ByteSizeValue; @@ -56,6 +57,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Supplier; @@ -63,6 +65,7 @@ import java.util.stream.IntStream; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasSize; public class ExchangeServiceTests extends ESTestCase { @@ -94,11 +97,10 @@ public void testBasic() throws Exception { ExchangeSinkHandler sinkExchanger = new ExchangeSinkHandler(blockFactory, 2, threadPool.relativeTimeInMillisSupplier()); ExchangeSink sink1 = sinkExchanger.createExchangeSink(); ExchangeSink sink2 = sinkExchanger.createExchangeSink(); - ExchangeSourceHandler sourceExchanger = new ExchangeSourceHandler(3, threadPool.executor(ESQL_TEST_EXECUTOR)); PlainActionFuture sourceCompletion = new PlainActionFuture<>(); - sourceExchanger.addCompletionListener(sourceCompletion); + ExchangeSourceHandler sourceExchanger = new ExchangeSourceHandler(3, threadPool.executor(ESQL_TEST_EXECUTOR), sourceCompletion); ExchangeSource source = sourceExchanger.createExchangeSource(); - sourceExchanger.addRemoteSink(sinkExchanger::fetchPageAsync, 1); + sourceExchanger.addRemoteSink(sinkExchanger::fetchPageAsync, randomBoolean(), 1, ActionListener.noop()); SubscribableListener waitForReading = source.waitForReading().listener(); assertFalse(waitForReading.isDone()); assertNull(source.pollPage()); @@ -263,7 +265,7 @@ public void close() { } } - void runConcurrentTest( + Set runConcurrentTest( int maxInputSeqNo, int maxOutputSeqNo, Supplier exchangeSource, @@ -318,16 +320,17 @@ protected void start(Driver driver, ActionListener listener) { } }.runToCompletion(drivers, future); future.actionGet(TimeValue.timeValueMinutes(1)); - var expectedSeqNos = IntStream.range(0, Math.min(maxInputSeqNo, maxOutputSeqNo)).boxed().collect(Collectors.toSet()); - assertThat(seqNoCollector.receivedSeqNos, hasSize(expectedSeqNos.size())); - assertThat(seqNoCollector.receivedSeqNos, equalTo(expectedSeqNos)); + return seqNoCollector.receivedSeqNos; } public void testConcurrentWithHandlers() { BlockFactory blockFactory = blockFactory(); PlainActionFuture sourceCompletionFuture = new PlainActionFuture<>(); - var sourceExchanger = new ExchangeSourceHandler(randomExchangeBuffer(), threadPool.executor(ESQL_TEST_EXECUTOR)); - sourceExchanger.addCompletionListener(sourceCompletionFuture); + var sourceExchanger = new ExchangeSourceHandler( + randomExchangeBuffer(), + threadPool.executor(ESQL_TEST_EXECUTOR), + sourceCompletionFuture + ); List sinkHandlers = new ArrayList<>(); Supplier exchangeSink = () -> { final ExchangeSinkHandler sinkHandler; @@ -335,17 +338,89 @@ public void testConcurrentWithHandlers() { sinkHandler = randomFrom(sinkHandlers); } else { sinkHandler = new ExchangeSinkHandler(blockFactory, randomExchangeBuffer(), threadPool.relativeTimeInMillisSupplier()); - sourceExchanger.addRemoteSink(sinkHandler::fetchPageAsync, randomIntBetween(1, 3)); + sourceExchanger.addRemoteSink(sinkHandler::fetchPageAsync, randomBoolean(), randomIntBetween(1, 3), ActionListener.noop()); sinkHandlers.add(sinkHandler); } return sinkHandler.createExchangeSink(); }; final int maxInputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); final int maxOutputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); - runConcurrentTest(maxInputSeqNo, maxOutputSeqNo, sourceExchanger::createExchangeSource, exchangeSink); + Set actualSeqNos = runConcurrentTest(maxInputSeqNo, maxOutputSeqNo, sourceExchanger::createExchangeSource, exchangeSink); + var expectedSeqNos = IntStream.range(0, Math.min(maxInputSeqNo, maxOutputSeqNo)).boxed().collect(Collectors.toSet()); + assertThat(actualSeqNos, hasSize(expectedSeqNos.size())); + assertThat(actualSeqNos, equalTo(expectedSeqNos)); sourceCompletionFuture.actionGet(10, TimeUnit.SECONDS); } + public void testExchangeSourceContinueOnFailure() { + BlockFactory blockFactory = blockFactory(); + PlainActionFuture sourceCompletionFuture = new PlainActionFuture<>(); + var exchangeSourceHandler = new ExchangeSourceHandler( + randomExchangeBuffer(), + threadPool.executor(ESQL_TEST_EXECUTOR), + sourceCompletionFuture + ); + final int maxInputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); + final int maxOutputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); + Set expectedSeqNos = ConcurrentCollections.newConcurrentSet(); + AtomicInteger failedRequests = new AtomicInteger(); + AtomicInteger totalSinks = new AtomicInteger(); + AtomicInteger failedSinks = new AtomicInteger(); + AtomicInteger completedSinks = new AtomicInteger(); + Supplier exchangeSink = () -> { + var sinkHandler = new ExchangeSinkHandler(blockFactory, randomExchangeBuffer(), threadPool.relativeTimeInMillisSupplier()); + int failAfter = randomBoolean() ? Integer.MAX_VALUE : randomIntBetween(0, 100); + AtomicInteger fetched = new AtomicInteger(); + int instance = randomIntBetween(1, 3); + totalSinks.incrementAndGet(); + AtomicBoolean sinkFailed = new AtomicBoolean(); + exchangeSourceHandler.addRemoteSink((allSourcesFinished, listener) -> { + if (fetched.incrementAndGet() > failAfter) { + sinkHandler.fetchPageAsync(true, listener.delegateFailure((l, r) -> { + failedRequests.incrementAndGet(); + sinkFailed.set(true); + listener.onFailure(new CircuitBreakingException("simulated", CircuitBreaker.Durability.PERMANENT)); + })); + } else { + sinkHandler.fetchPageAsync(allSourcesFinished, listener.delegateFailure((l, r) -> { + Page page = r.takePage(); + if (page != null) { + IntBlock block = page.getBlock(0); + for (int i = 0; i < block.getPositionCount(); i++) { + int v = block.getInt(i); + if (v < maxOutputSeqNo) { + expectedSeqNos.add(v); + } + } + } + l.onResponse(new ExchangeResponse(blockFactory, page, r.finished())); + })); + } + }, false, instance, ActionListener.wrap(r -> { + assertFalse(sinkFailed.get()); + completedSinks.incrementAndGet(); + }, e -> { + assertTrue(sinkFailed.get()); + failedSinks.incrementAndGet(); + })); + return sinkHandler.createExchangeSink(); + }; + Set actualSeqNos = runConcurrentTest( + maxInputSeqNo, + maxOutputSeqNo, + exchangeSourceHandler::createExchangeSource, + exchangeSink + ); + assertThat(actualSeqNos, equalTo(expectedSeqNos)); + assertThat(completedSinks.get() + failedSinks.get(), equalTo(totalSinks.get())); + sourceCompletionFuture.actionGet(); + if (failedRequests.get() > 0) { + assertThat(failedSinks.get(), greaterThan(0)); + } else { + assertThat(failedSinks.get(), equalTo(0)); + } + } + public void testEarlyTerminate() { BlockFactory blockFactory = blockFactory(); IntBlock block1 = blockFactory.newConstantIntBlockWith(1, 2); @@ -378,15 +453,31 @@ public void testConcurrentWithTransportActions() { try (exchange0; exchange1; node0; node1) { String exchangeId = "exchange"; Task task = new Task(1, "", "", "", null, Collections.emptyMap()); - var sourceHandler = new ExchangeSourceHandler(randomExchangeBuffer(), threadPool.executor(ESQL_TEST_EXECUTOR)); PlainActionFuture sourceCompletionFuture = new PlainActionFuture<>(); - sourceHandler.addCompletionListener(sourceCompletionFuture); + var sourceHandler = new ExchangeSourceHandler( + randomExchangeBuffer(), + threadPool.executor(ESQL_TEST_EXECUTOR), + sourceCompletionFuture + ); ExchangeSinkHandler sinkHandler = exchange1.createSinkHandler(exchangeId, randomExchangeBuffer()); Transport.Connection connection = node0.getConnection(node1.getLocalNode()); - sourceHandler.addRemoteSink(exchange0.newRemoteSink(task, exchangeId, node0, connection), randomIntBetween(1, 5)); + sourceHandler.addRemoteSink( + exchange0.newRemoteSink(task, exchangeId, node0, connection), + randomBoolean(), + randomIntBetween(1, 5), + ActionListener.noop() + ); final int maxInputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); final int maxOutputSeqNo = rarely() ? -1 : randomIntBetween(0, 50_000); - runConcurrentTest(maxInputSeqNo, maxOutputSeqNo, sourceHandler::createExchangeSource, sinkHandler::createExchangeSink); + Set actualSeqNos = runConcurrentTest( + maxInputSeqNo, + maxOutputSeqNo, + sourceHandler::createExchangeSource, + sinkHandler::createExchangeSink + ); + var expectedSeqNos = IntStream.range(0, Math.min(maxInputSeqNo, maxOutputSeqNo)).boxed().collect(Collectors.toSet()); + assertThat(actualSeqNos, hasSize(expectedSeqNos.size())); + assertThat(actualSeqNos, equalTo(expectedSeqNos)); sourceCompletionFuture.actionGet(10, TimeUnit.SECONDS); } } @@ -437,12 +528,20 @@ public void sendResponse(TransportResponse transportResponse) { try (exchange0; exchange1; node0; node1) { String exchangeId = "exchange"; Task task = new Task(1, "", "", "", null, Collections.emptyMap()); - var sourceHandler = new ExchangeSourceHandler(randomIntBetween(1, 128), threadPool.executor(ESQL_TEST_EXECUTOR)); PlainActionFuture sourceCompletionFuture = new PlainActionFuture<>(); - sourceHandler.addCompletionListener(sourceCompletionFuture); + var sourceHandler = new ExchangeSourceHandler( + randomIntBetween(1, 128), + threadPool.executor(ESQL_TEST_EXECUTOR), + sourceCompletionFuture + ); ExchangeSinkHandler sinkHandler = exchange1.createSinkHandler(exchangeId, randomIntBetween(1, 128)); Transport.Connection connection = node0.getConnection(node1.getLocalNode()); - sourceHandler.addRemoteSink(exchange0.newRemoteSink(task, exchangeId, node0, connection), randomIntBetween(1, 5)); + sourceHandler.addRemoteSink( + exchange0.newRemoteSink(task, exchangeId, node0, connection), + true, + randomIntBetween(1, 5), + ActionListener.noop() + ); Exception err = expectThrows( Exception.class, () -> runConcurrentTest(maxSeqNo, maxSeqNo, sourceHandler::createExchangeSource, sinkHandler::createExchangeSink) @@ -451,7 +550,7 @@ public void sendResponse(TransportResponse transportResponse) { assertNotNull(cause); assertThat(cause.getMessage(), equalTo("page is too large")); sinkHandler.onFailure(new RuntimeException(cause)); - sourceCompletionFuture.actionGet(10, TimeUnit.SECONDS); + expectThrows(Exception.class, () -> sourceCompletionFuture.actionGet(10, TimeUnit.SECONDS)); } } 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 eeed811674f60..e40af28fcdcbd 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 @@ -196,10 +196,6 @@ public void execute( .groupIndices(SearchRequest.DEFAULT_INDICES_OPTIONS, PlannerUtils.planOriginalIndices(physicalPlan)); var localOriginalIndices = clusterToOriginalIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); var localConcreteIndices = clusterToConcreteIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); - final var exchangeSource = new ExchangeSourceHandler( - queryPragmas.exchangeBufferSize(), - transportService.getThreadPool().executor(ThreadPool.Names.SEARCH) - ); String local = RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY; /* * Grab the output attributes here, so we can pass them to @@ -208,46 +204,58 @@ public void execute( */ List outputAttributes = physicalPlan.output(); try ( - Releasable ignored = exchangeSource.addEmptySink(); // this is the top level ComputeListener called once at the end (e.g., once all clusters have finished for a CCS) var computeListener = ComputeListener.create(local, transportService, rootTask, execInfo, listener.map(r -> { execInfo.markEndQuery(); // TODO: revisit this time recording model as part of INLINESTATS improvements return new Result(outputAttributes, collectedPages, r.getProfiles(), execInfo); })) ) { - // run compute on the coordinator - exchangeSource.addCompletionListener(computeListener.acquireAvoid()); - runCompute( - rootTask, - new ComputeContext(sessionId, RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, List.of(), configuration, exchangeSource, null), - coordinatorPlan, - computeListener.acquireCompute(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY) + var exchangeSource = new ExchangeSourceHandler( + queryPragmas.exchangeBufferSize(), + transportService.getThreadPool().executor(ThreadPool.Names.SEARCH), + computeListener.acquireAvoid() ); - // starts computes on data nodes on the main cluster - if (localConcreteIndices != null && localConcreteIndices.indices().length > 0) { - startComputeOnDataNodes( + try (Releasable ignored = exchangeSource.addEmptySink()) { + // run compute on the coordinator + runCompute( + rootTask, + new ComputeContext( + sessionId, + RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, + List.of(), + configuration, + exchangeSource, + null + ), + coordinatorPlan, + computeListener.acquireCompute(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY) + ); + // starts computes on data nodes on the main cluster + if (localConcreteIndices != null && localConcreteIndices.indices().length > 0) { + startComputeOnDataNodes( + sessionId, + RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, + rootTask, + configuration, + dataNodePlan, + Set.of(localConcreteIndices.indices()), + localOriginalIndices, + exchangeSource, + execInfo, + computeListener + ); + } + // starts computes on remote clusters + startComputeOnRemoteClusters( sessionId, - RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, rootTask, configuration, dataNodePlan, - Set.of(localConcreteIndices.indices()), - localOriginalIndices, exchangeSource, - execInfo, + getRemoteClusters(clusterToConcreteIndices, clusterToOriginalIndices), computeListener ); } - // starts computes on remote clusters - startComputeOnRemoteClusters( - sessionId, - rootTask, - configuration, - dataNodePlan, - exchangeSource, - getRemoteClusters(clusterToConcreteIndices, clusterToOriginalIndices), - computeListener - ); } } @@ -341,7 +349,7 @@ private void startComputeOnDataNodes( esqlExecutor, refs.acquire().delegateFailureAndWrap((l, unused) -> { var remoteSink = exchangeService.newRemoteSink(parentTask, childSessionId, transportService, node.connection); - exchangeSource.addRemoteSink(remoteSink, queryPragmas.concurrentExchangeClients()); + exchangeSource.addRemoteSink(remoteSink, true, queryPragmas.concurrentExchangeClients(), ActionListener.noop()); ActionListener computeResponseListener = computeListener.acquireCompute(clusterAlias); var dataNodeListener = ActionListener.runBefore(computeResponseListener, () -> l.onResponse(null)); transportService.sendChildRequest( @@ -390,7 +398,7 @@ private void startComputeOnRemoteClusters( esqlExecutor, refs.acquire().delegateFailureAndWrap((l, unused) -> { var remoteSink = exchangeService.newRemoteSink(rootTask, childSessionId, transportService, cluster.connection); - exchangeSource.addRemoteSink(remoteSink, queryPragmas.concurrentExchangeClients()); + exchangeSource.addRemoteSink(remoteSink, true, queryPragmas.concurrentExchangeClients(), ActionListener.noop()); var remotePlan = new RemoteClusterPlan(plan, cluster.concreteIndices, cluster.originalIndices); var clusterRequest = new ClusterComputeRequest(cluster.clusterAlias, childSessionId, configuration, remotePlan); var clusterListener = ActionListener.runBefore( @@ -733,9 +741,8 @@ private void runComputeOnDataNode( // run the node-level reduction var externalSink = exchangeService.getSinkHandler(externalId); task.addListener(() -> exchangeService.finishSinkHandler(externalId, new TaskCancelledException(task.getReasonCancelled()))); - var exchangeSource = new ExchangeSourceHandler(1, esqlExecutor); - exchangeSource.addCompletionListener(computeListener.acquireAvoid()); - exchangeSource.addRemoteSink(internalSink::fetchPageAsync, 1); + var exchangeSource = new ExchangeSourceHandler(1, esqlExecutor, computeListener.acquireAvoid()); + exchangeSource.addRemoteSink(internalSink::fetchPageAsync, true, 1, ActionListener.noop()); ActionListener reductionListener = computeListener.acquireCompute(); runCompute( task, @@ -872,11 +879,11 @@ void runComputeOnRemoteCluster( final String localSessionId = clusterAlias + ":" + globalSessionId; var exchangeSource = new ExchangeSourceHandler( configuration.pragmas().exchangeBufferSize(), - transportService.getThreadPool().executor(ThreadPool.Names.SEARCH) + transportService.getThreadPool().executor(ThreadPool.Names.SEARCH), + computeListener.acquireAvoid() ); try (Releasable ignored = exchangeSource.addEmptySink()) { exchangeSink.addCompletionListener(computeListener.acquireAvoid()); - exchangeSource.addCompletionListener(computeListener.acquireAvoid()); PhysicalPlan coordinatorPlan = new ExchangeSinkExec( plan.source(), plan.output(), 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 010a60ef7da15..c745801bf505f 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 @@ -539,7 +539,7 @@ void executeSubPlan( bigArrays, ByteSizeValue.ofBytes(randomLongBetween(1, BlockFactory.DEFAULT_MAX_BLOCK_PRIMITIVE_ARRAY_SIZE.getBytes() * 2)) ); - ExchangeSourceHandler exchangeSource = new ExchangeSourceHandler(between(1, 64), executor); + ExchangeSourceHandler exchangeSource = new ExchangeSourceHandler(between(1, 64), executor, ActionListener.noop()); ExchangeSinkHandler exchangeSink = new ExchangeSinkHandler(blockFactory, between(1, 64), threadPool::relativeTimeInMillis); LocalExecutionPlanner executionPlanner = new LocalExecutionPlanner( @@ -569,7 +569,14 @@ void executeSubPlan( var physicalTestOptimizer = new TestLocalPhysicalPlanOptimizer(new LocalPhysicalOptimizerContext(configuration, searchStats)); var csvDataNodePhysicalPlan = PlannerUtils.localPlan(dataNodePlan, logicalTestOptimizer, physicalTestOptimizer); - exchangeSource.addRemoteSink(exchangeSink::fetchPageAsync, randomIntBetween(1, 3)); + exchangeSource.addRemoteSink( + exchangeSink::fetchPageAsync, + Randomness.get().nextBoolean(), + randomIntBetween(1, 3), + ActionListener.noop().delegateResponse((l, e) -> { + throw new AssertionError("expected no failure", e); + }) + ); LocalExecutionPlan dataNodeExecutionPlan = executionPlanner.plan(csvDataNodePhysicalPlan); drivers.addAll(dataNodeExecutionPlan.createDrivers(getTestName())); From 5d9ad6795bd8876031cc3e270f337b23c7a147bf Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Mon, 25 Nov 2024 22:36:18 -0800 Subject: [PATCH 036/129] Move node-level reduction plan to data node (#117422) This change moves the logic for extracting the node-level plan to the data node instead of the coordinator. There are several benefits to doing this on the data node instead: 1. Minimize serialization, especially inter-cluster communications. 2. Resolve the row size estimation issue when generating this plan on data nodes. This will be addressed in a follow-up. 3. Allow each cluster to decide whether to run node-level reduction based on its own topology. --- .../org/elasticsearch/TransportVersions.java | 2 +- .../rules/physical/ProjectAwayColumns.java | 3 +- .../esql/plan/physical/FragmentExec.java | 56 +++++++++---------- .../xpack/esql/plugin/ComputeService.java | 29 +++++----- .../ExchangeSinkExecSerializationTests.java | 12 ++-- .../FragmentExecSerializationTests.java | 9 +-- .../xpack/esql/planner/FilterTests.java | 2 +- 7 files changed, 56 insertions(+), 57 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index 688d2aaf905a6..6567f48d6c232 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -206,7 +206,7 @@ static TransportVersion def(int id) { public static final TransportVersion INGEST_PIPELINE_CONFIGURATION_AS_MAP = def(8_797_00_0); 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); /* * STOP! READ THIS FIRST! No, really, * ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _ diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/ProjectAwayColumns.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/ProjectAwayColumns.java index 9f5b35e1eb9fb..d73aaee655860 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/ProjectAwayColumns.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/ProjectAwayColumns.java @@ -73,8 +73,7 @@ public PhysicalPlan apply(PhysicalPlan plan) { Source.EMPTY, new Project(logicalFragment.source(), logicalFragment, output), fragmentExec.esFilter(), - fragmentExec.estimatedRowSize(), - fragmentExec.reducer() + fragmentExec.estimatedRowSize() ); return new ExchangeExec(exec.source(), output, exec.inBetweenAggs(), newChild); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExec.java index 5b1ee14642dbe..444c111539033 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExec.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExec.java @@ -31,7 +31,6 @@ public class FragmentExec extends LeafExec implements EstimatesRowSize { private final LogicalPlan fragment; private final QueryBuilder esFilter; - private final PhysicalPlan reducer; // datanode-level physical plan node that performs an intermediate (not partial) reduce /** * Estimate of the number of bytes that'll be loaded per position before @@ -40,25 +39,28 @@ public class FragmentExec extends LeafExec implements EstimatesRowSize { private final int estimatedRowSize; public FragmentExec(LogicalPlan fragment) { - this(fragment.source(), fragment, null, 0, null); + this(fragment.source(), fragment, null, 0); } - public FragmentExec(Source source, LogicalPlan fragment, QueryBuilder esFilter, int estimatedRowSize, PhysicalPlan reducer) { + public FragmentExec(Source source, LogicalPlan fragment, QueryBuilder esFilter, int estimatedRowSize) { super(source); this.fragment = fragment; this.esFilter = esFilter; this.estimatedRowSize = estimatedRowSize; - this.reducer = reducer; } private FragmentExec(StreamInput in) throws IOException { - this( - Source.readFrom((PlanStreamInput) in), - in.readNamedWriteable(LogicalPlan.class), - in.readOptionalNamedWriteable(QueryBuilder.class), - in.readOptionalVInt(), - in.getTransportVersion().onOrAfter(TransportVersions.V_8_14_0) ? in.readOptionalNamedWriteable(PhysicalPlan.class) : null - ); + super(Source.readFrom((PlanStreamInput) in)); + this.fragment = in.readNamedWriteable(LogicalPlan.class); + this.esFilter = in.readOptionalNamedWriteable(QueryBuilder.class); + if (in.getTransportVersion().onOrAfter(TransportVersions.ESQL_REMOVE_NODE_LEVEL_PLAN)) { + this.estimatedRowSize = in.readVInt(); + } else { + this.estimatedRowSize = Objects.requireNonNull(in.readOptionalVInt()); + if (in.getTransportVersion().onOrAfter(TransportVersions.V_8_14_0)) { + in.readOptionalNamedWriteable(PhysicalPlan.class); // for old reducer + } + } } @Override @@ -66,9 +68,13 @@ public void writeTo(StreamOutput out) throws IOException { Source.EMPTY.writeTo(out); out.writeNamedWriteable(fragment()); out.writeOptionalNamedWriteable(esFilter()); - out.writeOptionalVInt(estimatedRowSize()); - if (out.getTransportVersion().onOrAfter(TransportVersions.V_8_14_0)) { - out.writeOptionalNamedWriteable(reducer); + if (out.getTransportVersion().onOrAfter(TransportVersions.ESQL_REMOVE_NODE_LEVEL_PLAN)) { + out.writeVInt(estimatedRowSize); + } else { + out.writeOptionalVInt(estimatedRowSize()); + if (out.getTransportVersion().onOrAfter(TransportVersions.V_8_14_0)) { + out.writeOptionalNamedWriteable(null);// for old reducer + } } } @@ -89,13 +95,9 @@ public Integer estimatedRowSize() { return estimatedRowSize; } - public PhysicalPlan reducer() { - return reducer; - } - @Override protected NodeInfo info() { - return NodeInfo.create(this, FragmentExec::new, fragment, esFilter, estimatedRowSize, reducer); + return NodeInfo.create(this, FragmentExec::new, fragment, esFilter, estimatedRowSize); } @Override @@ -108,24 +110,20 @@ public PhysicalPlan estimateRowSize(State state) { int estimatedRowSize = state.consumeAllFields(false); return Objects.equals(estimatedRowSize, this.estimatedRowSize) ? this - : new FragmentExec(source(), fragment, esFilter, estimatedRowSize, reducer); + : new FragmentExec(source(), fragment, esFilter, estimatedRowSize); } public FragmentExec withFragment(LogicalPlan fragment) { - return Objects.equals(fragment, this.fragment) ? this : new FragmentExec(source(), fragment, esFilter, estimatedRowSize, reducer); + return Objects.equals(fragment, this.fragment) ? this : new FragmentExec(source(), fragment, esFilter, estimatedRowSize); } public FragmentExec withFilter(QueryBuilder filter) { - return Objects.equals(filter, this.esFilter) ? this : new FragmentExec(source(), fragment, filter, estimatedRowSize, reducer); - } - - public FragmentExec withReducer(PhysicalPlan reducer) { - return Objects.equals(reducer, this.reducer) ? this : new FragmentExec(source(), fragment, esFilter, estimatedRowSize, reducer); + return Objects.equals(filter, this.esFilter) ? this : new FragmentExec(source(), fragment, filter, estimatedRowSize); } @Override public int hashCode() { - return Objects.hash(fragment, esFilter, estimatedRowSize, reducer); + return Objects.hash(fragment, esFilter, estimatedRowSize); } @Override @@ -141,8 +139,7 @@ public boolean equals(Object obj) { FragmentExec other = (FragmentExec) obj; return Objects.equals(fragment, other.fragment) && Objects.equals(esFilter, other.esFilter) - && Objects.equals(estimatedRowSize, other.estimatedRowSize) - && Objects.equals(reducer, other.reducer); + && Objects.equals(estimatedRowSize, other.estimatedRowSize); } @Override @@ -154,7 +151,6 @@ public String nodeString() { sb.append(", estimatedRowSize="); sb.append(estimatedRowSize); sb.append(", reducer=["); - sb.append(reducer == null ? "" : reducer.toString()); sb.append("], fragment=[<>\n"); sb.append(fragment.toString()); sb.append("<>]]"); 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 e40af28fcdcbd..6a0d1bf9bb035 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 @@ -60,6 +60,7 @@ import org.elasticsearch.xpack.esql.action.EsqlQueryAction; import org.elasticsearch.xpack.esql.action.EsqlSearchShardsAction; import org.elasticsearch.xpack.esql.core.expression.Attribute; +import org.elasticsearch.xpack.esql.core.util.Holder; import org.elasticsearch.xpack.esql.enrich.EnrichLookupService; import org.elasticsearch.xpack.esql.enrich.LookupFromIndexService; import org.elasticsearch.xpack.esql.plan.physical.ExchangeSinkExec; @@ -314,14 +315,7 @@ private void startComputeOnDataNodes( EsqlExecutionInfo executionInfo, ComputeListener computeListener ) { - var planWithReducer = configuration.pragmas().nodeLevelReduction() == false - ? dataNodePlan - : dataNodePlan.transformUp(FragmentExec.class, f -> { - PhysicalPlan reductionNode = PlannerUtils.dataNodeReductionPlan(f.fragment(), dataNodePlan); - return reductionNode == null ? f : f.withReducer(reductionNode); - }); - - QueryBuilder requestFilter = PlannerUtils.requestTimestampFilter(planWithReducer); + QueryBuilder requestFilter = PlannerUtils.requestTimestampFilter(dataNodePlan); var lookupListener = ActionListener.releaseAfter(computeListener.acquireAvoid(), exchangeSource.addEmptySink()); // SearchShards API can_match is done in lookupDataNodes lookupDataNodes(parentTask, clusterAlias, requestFilter, concreteIndices, originalIndices, ActionListener.wrap(dataNodeResult -> { @@ -361,7 +355,7 @@ private void startComputeOnDataNodes( clusterAlias, node.shardIds, node.aliasFilters, - planWithReducer, + dataNodePlan, originalIndices.indices(), originalIndices.indicesOptions() ), @@ -450,12 +444,12 @@ void runCompute(CancellableTask task, ComputeContext context, PhysicalPlan plan, ); LOGGER.debug("Received physical plan:\n{}", plan); + plan = PlannerUtils.localPlan(context.searchExecutionContexts(), context.configuration, plan); // the planner will also set the driver parallelism in LocalExecutionPlanner.LocalExecutionPlan (used down below) // it's doing this in the planning of EsQueryExec (the source of the data) // see also EsPhysicalOperationProviders.sourcePhysicalOperation LocalExecutionPlanner.LocalExecutionPlan localExecutionPlan = planner.plan(plan); - if (LOGGER.isDebugEnabled()) { LOGGER.debug("Local execution plan:\n{}", localExecutionPlan.describe()); } @@ -785,14 +779,23 @@ public void messageReceived(DataNodeRequest request, TransportChannel channel, T listener.onFailure(new IllegalStateException("expected a fragment plan for a remote compute; got " + request.plan())); return; } - var localExchangeSource = new ExchangeSourceExec(plan.source(), plan.output(), plan.isIntermediateAgg()); - FragmentExec fragment = (FragmentExec) fragments.get(0); + Holder reducePlanHolder = new Holder<>(); + if (request.pragmas().nodeLevelReduction()) { + PhysicalPlan dataNodePlan = request.plan(); + request.plan() + .forEachUp( + FragmentExec.class, + f -> { reducePlanHolder.set(PlannerUtils.dataNodeReductionPlan(f.fragment(), dataNodePlan)); } + ); + } reducePlan = new ExchangeSinkExec( plan.source(), plan.output(), plan.isIntermediateAgg(), - fragment.reducer() != null ? fragment.reducer().replaceChildren(List.of(localExchangeSource)) : localExchangeSource + reducePlanHolder.get() != null + ? reducePlanHolder.get().replaceChildren(List.of(localExchangeSource)) + : localExchangeSource ); } else { listener.onFailure(new IllegalStateException("expected exchange sink for a remote compute; got " + request.plan())); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/ExchangeSinkExecSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/ExchangeSinkExecSerializationTests.java index 5989c0de6b61d..f8e12cd4f5ba9 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/ExchangeSinkExecSerializationTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/ExchangeSinkExecSerializationTests.java @@ -66,12 +66,13 @@ protected boolean alwaysEmptySource() { * See {@link #testManyTypeConflicts(boolean, ByteSizeValue)} for more. */ public void testManyTypeConflicts() throws IOException { - testManyTypeConflicts(false, ByteSizeValue.ofBytes(1424048)); + testManyTypeConflicts(false, ByteSizeValue.ofBytes(1424046L)); /* * History: * 2.3mb - shorten error messages for UnsupportedAttributes #111973 * 1.8mb - cache EsFields #112008 * 1.4mb - string serialization #112929 + * 1424046b - remove node-level plan #117422 */ } @@ -80,7 +81,7 @@ public void testManyTypeConflicts() throws IOException { * See {@link #testManyTypeConflicts(boolean, ByteSizeValue)} for more. */ public void testManyTypeConflictsWithParent() throws IOException { - testManyTypeConflicts(true, ByteSizeValue.ofBytes(2774192)); + testManyTypeConflicts(true, ByteSizeValue.ofBytes(2774190)); /* * History: * 2 gb+ - start @@ -89,6 +90,7 @@ public void testManyTypeConflictsWithParent() throws IOException { * 3.1mb - cache EsFields #112008 * 2774214b - string serialization #112929 * 2774192b - remove field attribute #112881 + * 2774190b - remove node-level plan #117422 */ } @@ -103,11 +105,12 @@ private void testManyTypeConflicts(boolean withParent, ByteSizeValue expected) t * with a single root field that has many children, grandchildren etc. */ public void testDeeplyNestedFields() throws IOException { - ByteSizeValue expected = ByteSizeValue.ofBytes(47252411); + ByteSizeValue expected = ByteSizeValue.ofBytes(47252409); /* * History: * 48223371b - string serialization #112929 * 47252411b - remove field attribute #112881 + * 47252409b - remove node-level plan */ int depth = 6; @@ -123,11 +126,12 @@ public void testDeeplyNestedFields() throws IOException { * with a single root field that has many children, grandchildren etc. */ public void testDeeplyNestedFieldsKeepOnlyOne() throws IOException { - ByteSizeValue expected = ByteSizeValue.ofBytes(9425806); + ByteSizeValue expected = ByteSizeValue.ofBytes(9425804); /* * History: * 9426058b - string serialization #112929 * 9425806b - remove field attribute #112881 + * 9425804b - remove node-level plan #117422 */ int depth = 6; diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExecSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExecSerializationTests.java index 3c70290360a56..b36c42a1a06ab 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExecSerializationTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/physical/FragmentExecSerializationTests.java @@ -22,8 +22,7 @@ public static FragmentExec randomFragmentExec(int depth) { LogicalPlan fragment = AbstractLogicalPlanSerializationTests.randomChild(depth); QueryBuilder esFilter = EsqlQueryRequestTests.randomQueryBuilder(); int estimatedRowSize = between(0, Integer.MAX_VALUE); - PhysicalPlan reducer = randomChild(depth); - return new FragmentExec(source, fragment, esFilter, estimatedRowSize, reducer); + return new FragmentExec(source, fragment, esFilter, estimatedRowSize); } @Override @@ -36,15 +35,13 @@ protected FragmentExec mutateInstance(FragmentExec instance) throws IOException LogicalPlan fragment = instance.fragment(); QueryBuilder esFilter = instance.esFilter(); int estimatedRowSize = instance.estimatedRowSize(); - PhysicalPlan reducer = instance.reducer(); - switch (between(0, 3)) { + switch (between(0, 2)) { case 0 -> fragment = randomValueOtherThan(fragment, () -> AbstractLogicalPlanSerializationTests.randomChild(0)); case 1 -> esFilter = randomValueOtherThan(esFilter, EsqlQueryRequestTests::randomQueryBuilder); case 2 -> estimatedRowSize = randomValueOtherThan(estimatedRowSize, () -> between(0, Integer.MAX_VALUE)); - case 3 -> reducer = randomValueOtherThan(reducer, () -> randomChild(0)); default -> throw new UnsupportedEncodingException(); } - return new FragmentExec(instance.source(), fragment, esFilter, estimatedRowSize, reducer); + return new FragmentExec(instance.source(), fragment, esFilter, estimatedRowSize); } @Override diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/FilterTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/FilterTests.java index 8d819f9dbcd6c..55f32d07fc2cb 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/FilterTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/FilterTests.java @@ -305,7 +305,7 @@ private PhysicalPlan plan(String query, QueryBuilder restFilter) { // System.out.println("physical\n" + physical); physical = physical.transformUp( FragmentExec.class, - f -> new FragmentExec(f.source(), f.fragment(), restFilter, f.estimatedRowSize(), f.reducer()) + f -> new FragmentExec(f.source(), f.fragment(), restFilter, f.estimatedRowSize()) ); physical = physicalPlanOptimizer.optimize(physical); // System.out.println("optimized\n" + physical); From 78400b8d05c08a3e443e926648ab98102c9e32a7 Mon Sep 17 00:00:00 2001 From: Ignacio Vera Date: Tue, 26 Nov 2024 07:53:40 +0100 Subject: [PATCH 037/129] InternalMultiBucketAggregation.InternalBucket does not implement writable anymore (#117310) This allows to make some Bucket implementations leaner, in particular terms and multi-terms aggregations --- .../adjacency/InternalAdjacencyMatrix.java | 2 +- .../bucket/timeseries/InternalTimeSeries.java | 2 +- .../InternalMultiBucketAggregation.java | 6 ++- .../bucket/composite/InternalComposite.java | 4 +- .../bucket/filter/InternalFilters.java | 2 +- .../bucket/geogrid/InternalGeoGridBucket.java | 2 +- .../histogram/AbstractHistogramBucket.java | 2 +- .../bucket/prefix/InternalIpPrefix.java | 2 +- .../bucket/range/InternalBinaryRange.java | 2 +- .../bucket/range/InternalRange.java | 2 +- .../bucket/terms/AbstractInternalTerms.java | 48 ++++++++++--------- .../bucket/terms/DoubleTerms.java | 6 +-- .../GlobalOrdinalsStringTermsAggregator.java | 1 - .../bucket/terms/InternalMappedTerms.java | 10 +++- .../bucket/terms/InternalRareTerms.java | 6 ++- .../terms/InternalSignificantTerms.java | 2 +- .../bucket/terms/InternalTerms.java | 37 +++----------- .../aggregations/bucket/terms/LongTerms.java | 6 +-- .../bucket/terms/StringTerms.java | 6 +-- .../bucket/terms/UnmappedTerms.java | 5 ++ .../pipeline/BucketHelpersTests.java | 9 ---- .../multiterms/InternalMultiTerms.java | 39 +++++++-------- .../InternalCategorizationAggregation.java | 2 +- .../aggs/changepoint/ChangePointBucket.java | 2 +- 24 files changed, 94 insertions(+), 111 deletions(-) diff --git a/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/adjacency/InternalAdjacencyMatrix.java b/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/adjacency/InternalAdjacencyMatrix.java index 824f009bc7d8e..999f790ee8117 100644 --- a/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/adjacency/InternalAdjacencyMatrix.java +++ b/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/adjacency/InternalAdjacencyMatrix.java @@ -33,7 +33,7 @@ public class InternalAdjacencyMatrix extends InternalMultiBucketAggregation implements AdjacencyMatrix { - public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucket implements AdjacencyMatrix.Bucket { + public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucketWritable implements AdjacencyMatrix.Bucket { private final String key; private final long docCount; diff --git a/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java b/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java index d7590f2126325..c4669b1c25224 100644 --- a/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java +++ b/modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java @@ -34,7 +34,7 @@ public class InternalTimeSeries extends InternalMultiBucketAggregation reducePipelineBuckets(AggregationReduceContext reduceContext, Pi return reducedBuckets; } - public abstract static class InternalBucket implements Bucket, Writeable { + public abstract static class InternalBucket implements Bucket { public Object getProperty(String containingAggName, List path) { if (path.isEmpty()) { @@ -248,4 +248,8 @@ public Object getProperty(String containingAggName, List path) { return aggregation.getProperty(path.subList(1, path.size())); } } + + /** A {@link InternalBucket} that implements the {@link Writeable} interface. Most implementation might want + * to use this one except when specific logic is need to write into the stream. */ + public abstract static class InternalBucketWritable extends InternalBucket implements Writeable {} } diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java index faa953e77edd8..1492e97e6a5a5 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/composite/InternalComposite.java @@ -320,7 +320,9 @@ public int hashCode() { return Objects.hash(super.hashCode(), size, buckets, afterKey, Arrays.hashCode(reverseMuls), Arrays.hashCode(missingOrders)); } - public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucket implements CompositeAggregation.Bucket { + public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucketWritable + implements + CompositeAggregation.Bucket { private final CompositeKey key; private final long docCount; diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/filter/InternalFilters.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/filter/InternalFilters.java index c05759582346a..19cd0df9c7122 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/filter/InternalFilters.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/filter/InternalFilters.java @@ -30,7 +30,7 @@ import java.util.Objects; public class InternalFilters extends InternalMultiBucketAggregation implements Filters { - public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucket implements Filters.Bucket { + public static class InternalBucket extends InternalMultiBucketAggregation.InternalBucketWritable implements Filters.Bucket { private final String key; private long docCount; diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java index 9e3c96da2e70b..60de4c3974c92 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java @@ -19,7 +19,7 @@ import java.io.IOException; import java.util.Objects; -public abstract class InternalGeoGridBucket extends InternalMultiBucketAggregation.InternalBucket +public abstract class InternalGeoGridBucket extends InternalMultiBucketAggregation.InternalBucketWritable implements GeoGrid.Bucket, Comparable { diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/histogram/AbstractHistogramBucket.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/histogram/AbstractHistogramBucket.java index 16a83ed04e524..7806d8cd8efe2 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/histogram/AbstractHistogramBucket.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/histogram/AbstractHistogramBucket.java @@ -16,7 +16,7 @@ /** * A bucket in the histogram where documents fall in */ -public abstract class AbstractHistogramBucket extends InternalMultiBucketAggregation.InternalBucket { +public abstract class AbstractHistogramBucket extends InternalMultiBucketAggregation.InternalBucketWritable { protected final long docCount; protected final InternalAggregations aggregations; diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/prefix/InternalIpPrefix.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/prefix/InternalIpPrefix.java index 5b456b3246b64..36a8fccc77e99 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/prefix/InternalIpPrefix.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/prefix/InternalIpPrefix.java @@ -33,7 +33,7 @@ public class InternalIpPrefix extends InternalMultiBucketAggregation { - public static class Bucket extends InternalMultiBucketAggregation.InternalBucket + public static class Bucket extends InternalMultiBucketAggregation.InternalBucketWritable implements IpPrefix.Bucket, KeyComparable { diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/range/InternalBinaryRange.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/range/InternalBinaryRange.java index 9571dfebc6069..34a2ebea88440 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/range/InternalBinaryRange.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/range/InternalBinaryRange.java @@ -36,7 +36,7 @@ public final class InternalBinaryRange extends InternalMultiBucketAggregation buckets, AggregationReduceContext context) { @@ -104,7 +104,7 @@ private B reduceBucket(List buckets, AggregationReduceContext context) { for (B bucket : buckets) { docCount += bucket.getDocCount(); if (docCountError != -1) { - if (bucket.getShowDocCountError() == false || bucket.getDocCountError() == -1) { + if (getShowDocCountError() == false || bucket.getDocCountError() == -1) { docCountError = -1; } else { docCountError += bucket.getDocCountError(); @@ -257,6 +257,7 @@ public void accept(InternalAggregation aggregation) { } otherDocCount[0] += terms.getSumOfOtherDocCounts(); final long thisAggDocCountError = getDocCountError(terms); + setDocCountError(thisAggDocCountError); if (sumDocCountError != -1) { if (thisAggDocCountError == -1) { sumDocCountError = -1; @@ -264,16 +265,17 @@ public void accept(InternalAggregation aggregation) { sumDocCountError += thisAggDocCountError; } } - setDocCountError(thisAggDocCountError); - for (B bucket : terms.getBuckets()) { - // If there is already a doc count error for this bucket - // subtract this aggs doc count error from it to make the - // new value for the bucket. This then means that when the - // final error for the bucket is calculated below we account - // for the existing error calculated in a previous reduce. - // Note that if the error is unbounded (-1) this will be fixed - // later in this method. - bucket.updateDocCountError(-thisAggDocCountError); + if (getShowDocCountError()) { + for (B bucket : terms.getBuckets()) { + // If there is already a doc count error for this bucket + // subtract this aggs doc count error from it to make the + // new value for the bucket. This then means that when the + // final error for the bucket is calculated below we account + // for the existing error calculated in a previous reduce. + // Note that if the error is unbounded (-1) this will be fixed + // later in this method. + bucket.updateDocCountError(-thisAggDocCountError); + } } if (terms.getBuckets().isEmpty() == false) { bucketsList.add(terms.getBuckets()); @@ -319,17 +321,17 @@ public InternalAggregation get() { result.add(bucket.reduced(AbstractInternalTerms.this::reduceBucket, reduceContext)); }); } - for (B r : result) { - if (sumDocCountError == -1) { - r.setDocCountError(-1); - } else { - r.updateDocCountError(sumDocCountError); + if (getShowDocCountError()) { + for (B r : result) { + if (sumDocCountError == -1) { + r.setDocCountError(-1); + } else { + r.updateDocCountError(sumDocCountError); + } } } - long docCountError; - if (sumDocCountError == -1) { - docCountError = -1; - } else { + long docCountError = -1; + if (sumDocCountError != -1) { docCountError = size == 1 ? 0 : sumDocCountError; } return create(name, result, reduceContext.isFinalReduce() ? getOrder() : thisReduceOrder, docCountError, otherDocCount[0]); @@ -349,7 +351,7 @@ public InternalAggregation finalizeSampling(SamplingContext samplingContext) { b -> createBucket( samplingContext.scaleUp(b.getDocCount()), InternalAggregations.finalizeSampling(b.getAggregations(), samplingContext), - b.getShowDocCountError() ? samplingContext.scaleUp(b.getDocCountError()) : 0, + getShowDocCountError() ? samplingContext.scaleUp(b.getDocCountError()) : 0, b ) ) diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/DoubleTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/DoubleTerms.java index 9789a9edc58f7..5c28c25de6e87 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/DoubleTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/DoubleTerms.java @@ -164,8 +164,8 @@ public Bucket createBucket(InternalAggregations aggregations, Bucket prototype) prototype.term, prototype.docCount, aggregations, - prototype.showDocCountError, - prototype.docCountError, + showTermDocCountError, + prototype.getDocCountError(), prototype.format ); } @@ -216,6 +216,6 @@ public void close() { @Override protected Bucket createBucket(long docCount, InternalAggregations aggs, long docCountError, DoubleTerms.Bucket prototype) { - return new Bucket(prototype.term, docCount, aggs, prototype.showDocCountError, docCountError, format); + return new Bucket(prototype.term, docCount, aggs, showTermDocCountError, docCountError, format); } } diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java index db9da6ed67207..5a79155d1d4f5 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java @@ -880,7 +880,6 @@ StringTerms.Bucket convertTempBucketToRealBucket(OrdBucket temp, GlobalOrdLookup BytesRef term = BytesRef.deepCopyOf(lookupGlobalOrd.apply(temp.globalOrd)); StringTerms.Bucket result = new StringTerms.Bucket(term, temp.docCount, null, showTermDocCountError, 0, format); result.bucketOrd = temp.bucketOrd; - result.docCountError = 0; return result; } diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalMappedTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalMappedTerms.java index 5b9403840dfff..d7087a121b4f4 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalMappedTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalMappedTerms.java @@ -87,7 +87,10 @@ protected final void writeTermTypeInfoTo(StreamOutput out) throws IOException { writeSize(shardSize, out); out.writeBoolean(showTermDocCountError); out.writeVLong(otherDocCount); - out.writeCollection(buckets); + out.writeVInt(buckets.size()); + for (var bucket : buckets) { + bucket.writeTo(out, showTermDocCountError); + } } @Override @@ -95,6 +98,11 @@ protected void setDocCountError(long docCountError) { this.docCountError = docCountError; } + @Override + protected boolean getShowDocCountError() { + return showTermDocCountError; + } + @Override protected int getShardSize() { return shardSize; diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalRareTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalRareTerms.java index 64cebee880141..7859319f4dd0d 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalRareTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalRareTerms.java @@ -10,6 +10,7 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.util.SetBackedScalingCuckooFilter; import org.elasticsearch.search.DocValueFormat; import org.elasticsearch.search.aggregations.BucketOrder; @@ -29,10 +30,11 @@ public abstract class InternalRareTerms, B ext implements RareTerms { - public abstract static class Bucket> extends InternalMultiBucketAggregation.InternalBucket + public abstract static class Bucket> extends InternalMultiBucketAggregation.InternalBucketWritable implements RareTerms.Bucket, - KeyComparable { + KeyComparable, + Writeable { /** * Reads a bucket. Should be a constructor reference. */ diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalSignificantTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalSignificantTerms.java index 3f579947248bb..6c0eb465d1f80 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalSignificantTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalSignificantTerms.java @@ -45,7 +45,7 @@ public abstract class InternalSignificantTerms> extends InternalMultiBucketAggregation.InternalBucket + public abstract static class Bucket> extends InternalMultiBucketAggregation.InternalBucketWritable implements SignificantTerms.Bucket { /** diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalTerms.java index b94b1f5ea40b1..739f0b923eaab 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/InternalTerms.java @@ -41,9 +41,8 @@ public interface Reader> { long bucketOrd; protected long docCount; - protected long docCountError; + private long docCountError; protected InternalAggregations aggregations; - protected final boolean showDocCountError; protected final DocValueFormat format; protected Bucket( @@ -53,29 +52,23 @@ protected Bucket( long docCountError, DocValueFormat formatter ) { - this.showDocCountError = showDocCountError; this.format = formatter; this.docCount = docCount; this.aggregations = aggregations; - this.docCountError = docCountError; + this.docCountError = showDocCountError ? docCountError : -1; } /** * Read from a stream. */ protected Bucket(StreamInput in, DocValueFormat formatter, boolean showDocCountError) throws IOException { - this.showDocCountError = showDocCountError; this.format = formatter; docCount = in.readVLong(); - docCountError = -1; - if (showDocCountError) { - docCountError = in.readLong(); - } + docCountError = showDocCountError ? in.readLong() : -1; aggregations = InternalAggregations.readFrom(in); } - @Override - public final void writeTo(StreamOutput out) throws IOException { + final void writeTo(StreamOutput out, boolean showDocCountError) throws IOException { out.writeVLong(getDocCount()); if (showDocCountError) { out.writeLong(docCountError); @@ -105,9 +98,6 @@ public void setBucketOrd(long bucketOrd) { @Override public long getDocCountError() { - if (showDocCountError == false) { - throw new IllegalStateException("show_terms_doc_count_error is false"); - } return docCountError; } @@ -121,11 +111,6 @@ protected void updateDocCountError(long docCountErrorDiff) { this.docCountError += docCountErrorDiff; } - @Override - protected boolean getShowDocCountError() { - return showDocCountError; - } - @Override public InternalAggregations getAggregations() { return aggregations; @@ -155,23 +140,15 @@ public boolean equals(Object obj) { return false; } Bucket that = (Bucket) obj; - if (showDocCountError && docCountError != that.docCountError) { - /* - * docCountError doesn't matter if not showing it and - * serialization sets it to -1 no matter what it was - * before. - */ - return false; - } - return Objects.equals(docCount, that.docCount) - && Objects.equals(showDocCountError, that.showDocCountError) + return Objects.equals(docCountError, that.docCountError) + && Objects.equals(docCount, that.docCount) && Objects.equals(format, that.format) && Objects.equals(aggregations, that.aggregations); } @Override public int hashCode() { - return Objects.hash(getClass(), docCount, format, showDocCountError, showDocCountError ? docCountError : -1, aggregations); + return Objects.hash(getClass(), docCount, format, docCountError, aggregations); } } diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/LongTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/LongTerms.java index f536b7f958ca2..6c2444379c8eb 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/LongTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/LongTerms.java @@ -178,8 +178,8 @@ public Bucket createBucket(InternalAggregations aggregations, Bucket prototype) prototype.term, prototype.docCount, aggregations, - prototype.showDocCountError, - prototype.docCountError, + showTermDocCountError, + prototype.getDocCountError(), prototype.format ); } @@ -260,7 +260,7 @@ public InternalAggregation get() { @Override protected Bucket createBucket(long docCount, InternalAggregations aggs, long docCountError, LongTerms.Bucket prototype) { - return new Bucket(prototype.term, docCount, aggs, prototype.showDocCountError, docCountError, format); + return new Bucket(prototype.term, docCount, aggs, showTermDocCountError, docCountError, format); } /** diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/StringTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/StringTerms.java index 5faf6e0aaaedf..2370827230c47 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/StringTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/StringTerms.java @@ -184,15 +184,15 @@ public Bucket createBucket(InternalAggregations aggregations, Bucket prototype) prototype.termBytes, prototype.docCount, aggregations, - prototype.showDocCountError, - prototype.docCountError, + showTermDocCountError, + prototype.getDocCountError(), prototype.format ); } @Override protected Bucket createBucket(long docCount, InternalAggregations aggs, long docCountError, StringTerms.Bucket prototype) { - return new Bucket(prototype.termBytes, docCount, aggs, prototype.showDocCountError, docCountError, format); + return new Bucket(prototype.termBytes, docCount, aggs, showTermDocCountError, docCountError, format); } @Override diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/UnmappedTerms.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/UnmappedTerms.java index 8047d1f06990f..e82a2b7fe9235 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/UnmappedTerms.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/UnmappedTerms.java @@ -114,6 +114,11 @@ public final XContentBuilder doXContentBody(XContentBuilder builder, Params para return doXContentCommon(builder, params, false, 0L, 0, Collections.emptyList()); } + @Override + protected boolean getShowDocCountError() { + return false; + } + @Override protected void setDocCountError(long docCountError) {} diff --git a/server/src/test/java/org/elasticsearch/search/aggregations/pipeline/BucketHelpersTests.java b/server/src/test/java/org/elasticsearch/search/aggregations/pipeline/BucketHelpersTests.java index b2f79c02baf8d..626adc9a7c41c 100644 --- a/server/src/test/java/org/elasticsearch/search/aggregations/pipeline/BucketHelpersTests.java +++ b/server/src/test/java/org/elasticsearch/search/aggregations/pipeline/BucketHelpersTests.java @@ -9,7 +9,6 @@ package org.elasticsearch.search.aggregations.pipeline; -import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.search.aggregations.InternalAggregations; import org.elasticsearch.search.aggregations.InternalMultiBucketAggregation; import org.elasticsearch.search.aggregations.bucket.MultiBucketsAggregation; @@ -56,10 +55,6 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws }; InternalMultiBucketAggregation.InternalBucket bucket = new InternalMultiBucketAggregation.InternalBucket() { - @Override - public void writeTo(StreamOutput out) throws IOException { - - } @Override public Object getKey() { @@ -131,10 +126,6 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws }; InternalMultiBucketAggregation.InternalBucket bucket = new InternalMultiBucketAggregation.InternalBucket() { - @Override - public void writeTo(StreamOutput out) throws IOException { - - } @Override public Object getKey() { diff --git a/x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/multiterms/InternalMultiTerms.java b/x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/multiterms/InternalMultiTerms.java index c6bfb5b1b2778..0d42a2856a10e 100644 --- a/x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/multiterms/InternalMultiTerms.java +++ b/x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/multiterms/InternalMultiTerms.java @@ -42,8 +42,7 @@ public static class Bucket extends AbstractInternalTerms.AbstractTermsBucket formats; protected List terms; protected List keyConverters; @@ -60,8 +59,7 @@ public Bucket( this.terms = terms; this.docCount = docCount; this.aggregations = aggregations; - this.showDocCountError = showDocCountError; - this.docCountError = docCountError; + this.docCountError = showDocCountError ? docCountError : -1; this.formats = formats; this.keyConverters = keyConverters; } @@ -71,7 +69,6 @@ protected Bucket(StreamInput in, List formats, List formats, List { diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/changepoint/ChangePointBucket.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/changepoint/ChangePointBucket.java index 39bdb69d4da40..aed0c40043cae 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/changepoint/ChangePointBucket.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/aggs/changepoint/ChangePointBucket.java @@ -18,7 +18,7 @@ import java.io.IOException; import java.util.Objects; -public class ChangePointBucket extends InternalMultiBucketAggregation.InternalBucket implements ToXContent { +public class ChangePointBucket extends InternalMultiBucketAggregation.InternalBucketWritable implements ToXContent { private final Object key; private final long docCount; private final InternalAggregations aggregations; From ed33bea30cd898936e43e24a7927290409f30b18 Mon Sep 17 00:00:00 2001 From: Martijn van Groningen Date: Tue, 26 Nov 2024 08:02:12 +0100 Subject: [PATCH 038/129] Adjust SyntheticSourceLicenseService (#116647) Allow gold and platinum license to use synthetic source for a limited time. If the start time of a license is before the cut off date, then gold and platinum licenses will not fallback to stored source if synthetic source is used. Co-authored-by: Nikolaj Volgushev --- .../xpack/logsdb/LogsDBPlugin.java | 13 +- .../SyntheticSourceIndexSettingsProvider.java | 32 +++- .../logsdb/SyntheticSourceLicenseService.java | 83 ++++++++- .../logsdb/LegacyLicenceIntegrationTests.java | 146 +++++++++++++++ ...dexSettingsProviderLegacyLicenseTests.java | 129 +++++++++++++ ...heticSourceIndexSettingsProviderTests.java | 13 +- .../SyntheticSourceLicenseServiceTests.java | 173 ++++++++++++++++-- 7 files changed, 562 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/LegacyLicenceIntegrationTests.java create mode 100644 x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java diff --git a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBPlugin.java b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBPlugin.java index 04d12fd51bae7..904b00e6d0450 100644 --- a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBPlugin.java +++ b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBPlugin.java @@ -13,6 +13,8 @@ import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.IndexSettingProvider; +import org.elasticsearch.license.LicenseService; +import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.plugins.ActionPlugin; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.xpack.core.XPackPlugin; @@ -46,7 +48,8 @@ public LogsDBPlugin(Settings settings) { @Override public Collection createComponents(PluginServices services) { - licenseService.setLicenseState(XPackPlugin.getSharedLicenseState()); + licenseService.setLicenseService(getLicenseService()); + licenseService.setLicenseState(getLicenseState()); var clusterSettings = services.clusterService().getClusterSettings(); // The `cluster.logsdb.enabled` setting is registered by this plugin, but its value may be updated by other plugins // before this plugin registers its settings update consumer below. This means we might miss updates that occurred earlier. @@ -88,4 +91,12 @@ public List> getSettings() { actions.add(new ActionPlugin.ActionHandler<>(XPackInfoFeatureAction.LOGSDB, LogsDBInfoTransportAction.class)); return actions; } + + protected XPackLicenseState getLicenseState() { + return XPackPlugin.getSharedLicenseState(); + } + + protected LicenseService getLicenseService() { + return XPackPlugin.getSharedLicenseService(); + } } diff --git a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProvider.java b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProvider.java index 1f38ecda19515..462bad4b19551 100644 --- a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProvider.java +++ b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProvider.java @@ -81,8 +81,13 @@ public Settings getAdditionalIndexSettings( // This index name is used when validating component and index templates, we should skip this check in that case. // (See MetadataIndexTemplateService#validateIndexTemplateV2(...) method) boolean isTemplateValidation = "validate-index-name".equals(indexName); + boolean legacyLicensedUsageOfSyntheticSourceAllowed = isLegacyLicensedUsageOfSyntheticSourceAllowed( + templateIndexMode, + indexName, + dataStreamName + ); if (newIndexHasSyntheticSourceUsage(indexName, templateIndexMode, indexTemplateAndCreateRequestSettings, combinedTemplateMappings) - && syntheticSourceLicenseService.fallbackToStoredSource(isTemplateValidation)) { + && syntheticSourceLicenseService.fallbackToStoredSource(isTemplateValidation, legacyLicensedUsageOfSyntheticSourceAllowed)) { LOGGER.debug("creation of index [{}] with synthetic source without it being allowed", indexName); return Settings.builder() .put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), SourceFieldMapper.Mode.STORED.toString()) @@ -167,4 +172,29 @@ private IndexMetadata buildIndexMetadataForMapperService( tmpIndexMetadata.settings(finalResolvedSettings); return tmpIndexMetadata.build(); } + + /** + * The GA-ed use cases in which synthetic source usage is allowed with gold or platinum license. + */ + boolean isLegacyLicensedUsageOfSyntheticSourceAllowed(IndexMode templateIndexMode, String indexName, String dataStreamName) { + if (templateIndexMode == IndexMode.TIME_SERIES) { + return true; + } + + // To allow the following patterns: profiling-metrics and profiling-events + if (dataStreamName != null && dataStreamName.startsWith("profiling-")) { + return true; + } + // To allow the following patterns: .profiling-sq-executables, .profiling-sq-leafframes and .profiling-stacktraces + if (indexName.startsWith(".profiling-")) { + return true; + } + // To allow the following patterns: metrics-apm.transaction.*, metrics-apm.service_transaction.*, metrics-apm.service_summary.*, + // metrics-apm.service_destination.*, "metrics-apm.internal-* and metrics-apm.app.* + if (dataStreamName != null && dataStreamName.startsWith("metrics-apm.")) { + return true; + } + + return false; + } } 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 55d4bfe05abe3..1b3513f15a86a 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 @@ -7,18 +7,30 @@ package org.elasticsearch.xpack.logsdb; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.license.License; +import org.elasticsearch.license.LicenseService; import org.elasticsearch.license.LicensedFeature; import org.elasticsearch.license.XPackLicenseState; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + /** * Determines based on license and fallback setting whether synthetic source usages should fallback to stored source. */ final class SyntheticSourceLicenseService { - private static final String MAPPINGS_FEATURE_FAMILY = "mappings"; + static final String MAPPINGS_FEATURE_FAMILY = "mappings"; + // You can only override this property if you received explicit approval from Elastic. + private 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(); /** * A setting that determines whether source mode should always be stored source. Regardless of licence. @@ -30,31 +42,71 @@ final class SyntheticSourceLicenseService { Setting.Property.Dynamic ); - private static final LicensedFeature.Momentary SYNTHETIC_SOURCE_FEATURE = LicensedFeature.momentary( + static final LicensedFeature.Momentary SYNTHETIC_SOURCE_FEATURE = LicensedFeature.momentary( MAPPINGS_FEATURE_FAMILY, "synthetic-source", License.OperationMode.ENTERPRISE ); + static final LicensedFeature.Momentary SYNTHETIC_SOURCE_FEATURE_LEGACY = LicensedFeature.momentary( + MAPPINGS_FEATURE_FAMILY, + "synthetic-source-legacy", + License.OperationMode.GOLD + ); + + private final long cutoffDate; + private LicenseService licenseService; private XPackLicenseState licenseState; private volatile boolean syntheticSourceFallback; SyntheticSourceLicenseService(Settings settings) { - syntheticSourceFallback = FALLBACK_SETTING.get(settings); + this(settings, System.getProperty(CUTOFF_DATE_SYS_PROP_NAME)); + } + + SyntheticSourceLicenseService(Settings settings, String cutoffDate) { + this.syntheticSourceFallback = FALLBACK_SETTING.get(settings); + this.cutoffDate = getCutoffDate(cutoffDate); } /** * @return whether synthetic source mode should fallback to stored source. */ - public boolean fallbackToStoredSource(boolean isTemplateValidation) { + public boolean fallbackToStoredSource(boolean isTemplateValidation, boolean legacyLicensedUsageOfSyntheticSourceAllowed) { if (syntheticSourceFallback) { return true; } + var licenseStateSnapshot = licenseState.copyCurrentLicenseState(); + if (checkFeature(SYNTHETIC_SOURCE_FEATURE, licenseStateSnapshot, isTemplateValidation)) { + return false; + } + + var license = licenseService.getLicense(); + if (license == null) { + return true; + } + + boolean beforeCutoffDate = license.startDate() <= cutoffDate; + if (legacyLicensedUsageOfSyntheticSourceAllowed + && beforeCutoffDate + && checkFeature(SYNTHETIC_SOURCE_FEATURE_LEGACY, licenseStateSnapshot, isTemplateValidation)) { + // platinum license will allow synthetic source with gold legacy licensed feature too. + LOGGER.debug("legacy license [{}] is allowed to use synthetic source", licenseStateSnapshot.getOperationMode().description()); + return false; + } + + return true; + } + + private static boolean checkFeature( + LicensedFeature.Momentary licensedFeature, + XPackLicenseState licenseStateSnapshot, + boolean isTemplateValidation + ) { if (isTemplateValidation) { - return SYNTHETIC_SOURCE_FEATURE.checkWithoutTracking(licenseState) == false; + return licensedFeature.checkWithoutTracking(licenseStateSnapshot); } else { - return SYNTHETIC_SOURCE_FEATURE.check(licenseState) == false; + return licensedFeature.check(licenseStateSnapshot); } } @@ -62,7 +114,26 @@ void setSyntheticSourceFallback(boolean syntheticSourceFallback) { this.syntheticSourceFallback = syntheticSourceFallback; } + void setLicenseService(LicenseService licenseService) { + this.licenseService = licenseService; + } + void setLicenseState(XPackLicenseState licenseState) { this.licenseState = licenseState; } + + private static long getCutoffDate(String cutoffDateAsString) { + if (cutoffDateAsString != null) { + long cutoffDate = LocalDateTime.parse(cutoffDateAsString).toInstant(ZoneOffset.UTC).toEpochMilli(); + LOGGER.warn("Configuring [{}] is only allowed with explicit approval from Elastic.", CUTOFF_DATE_SYS_PROP_NAME); + LOGGER.info( + "Configuring [{}] to [{}]", + CUTOFF_DATE_SYS_PROP_NAME, + LocalDateTime.ofInstant(Instant.ofEpochSecond(cutoffDate), ZoneOffset.UTC) + ); + return cutoffDate; + } else { + return DEFAULT_CUTOFF_DATE; + } + } } diff --git a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/LegacyLicenceIntegrationTests.java b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/LegacyLicenceIntegrationTests.java new file mode 100644 index 0000000000000..890bc464a2579 --- /dev/null +++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/LegacyLicenceIntegrationTests.java @@ -0,0 +1,146 @@ +/* + * 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.logsdb; + +import org.elasticsearch.action.admin.indices.settings.get.GetSettingsRequest; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.index.mapper.SourceFieldMapper; +import org.elasticsearch.license.AbstractLicensesIntegrationTestCase; +import org.elasticsearch.license.GetFeatureUsageRequest; +import org.elasticsearch.license.GetFeatureUsageResponse; +import org.elasticsearch.license.License; +import org.elasticsearch.license.LicenseService; +import org.elasticsearch.license.LicensedFeature; +import org.elasticsearch.license.TransportGetFeatureUsageAction; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.test.ESIntegTestCase; +import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin; +import org.hamcrest.Matcher; +import org.junit.Before; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.List; + +import static org.elasticsearch.test.ESIntegTestCase.Scope.TEST; +import static org.elasticsearch.xpack.logsdb.SyntheticSourceLicenseServiceTests.createEnterpriseLicense; +import static org.elasticsearch.xpack.logsdb.SyntheticSourceLicenseServiceTests.createGoldOrPlatinumLicense; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +@ESIntegTestCase.ClusterScope(scope = TEST, numDataNodes = 1, numClientNodes = 0, supportsDedicatedMasters = false) +public class LegacyLicenceIntegrationTests extends AbstractLicensesIntegrationTestCase { + + @Override + protected Collection> nodePlugins() { + return List.of(P.class); + } + + @Before + public void setup() throws Exception { + wipeAllLicenses(); + ensureGreen(); + License license = createGoldOrPlatinumLicense(); + putLicense(license); + ensureGreen(); + } + + public void testSyntheticSourceUsageDisallowed() { + createIndexWithSyntheticSourceAndAssertExpectedType("test", "STORED"); + + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY, nullValue()); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE, nullValue()); + } + + public void testSyntheticSourceUsageWithLegacyLicense() { + createIndexWithSyntheticSourceAndAssertExpectedType(".profiling-stacktraces", "synthetic"); + + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY, not(nullValue())); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE, nullValue()); + } + + public void testSyntheticSourceUsageWithLegacyLicensePastCutoff() throws Exception { + long startPastCutoff = LocalDateTime.of(2025, 11, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + putLicense(createGoldOrPlatinumLicense(startPastCutoff)); + ensureGreen(); + + createIndexWithSyntheticSourceAndAssertExpectedType(".profiling-stacktraces", "STORED"); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY, nullValue()); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE, nullValue()); + } + + public void testSyntheticSourceUsageWithEnterpriseLicensePastCutoff() throws Exception { + long startPastCutoff = LocalDateTime.of(2025, 11, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + putLicense(createEnterpriseLicense(startPastCutoff)); + ensureGreen(); + + createIndexWithSyntheticSourceAndAssertExpectedType(".profiling-traces", "synthetic"); + // also supports non-exceptional indices + createIndexWithSyntheticSourceAndAssertExpectedType("test", "synthetic"); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY, nullValue()); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE, not(nullValue())); + } + + public void testSyntheticSourceUsageTracksBothLegacyAndRegularFeature() throws Exception { + createIndexWithSyntheticSourceAndAssertExpectedType(".profiling-traces", "synthetic"); + + putLicense(createEnterpriseLicense()); + ensureGreen(); + + createIndexWithSyntheticSourceAndAssertExpectedType(".profiling-traces-v2", "synthetic"); + + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY, not(nullValue())); + assertFeatureUsage(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE, not(nullValue())); + } + + private void createIndexWithSyntheticSourceAndAssertExpectedType(String indexName, String expectedType) { + var settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "synthetic").build(); + createIndex(indexName, settings); + var response = admin().indices().getSettings(new GetSettingsRequest().indices(indexName)).actionGet(); + assertThat( + response.getIndexToSettings().get(indexName).get(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey()), + equalTo(expectedType) + ); + } + + private List getFeatureUsageInfo() { + return client().execute(TransportGetFeatureUsageAction.TYPE, new GetFeatureUsageRequest()).actionGet().getFeatures(); + } + + private void assertFeatureUsage(LicensedFeature.Momentary syntheticSourceFeature, Matcher matcher) { + GetFeatureUsageResponse.FeatureUsageInfo featureUsage = getFeatureUsageInfo().stream() + .filter(f -> f.getFamily().equals(SyntheticSourceLicenseService.MAPPINGS_FEATURE_FAMILY)) + .filter(f -> f.getName().equals(syntheticSourceFeature.getName())) + .findAny() + .orElse(null); + assertThat(featureUsage, matcher); + } + + public static class P extends LocalStateCompositeXPackPlugin { + + public P(final Settings settings, final Path configPath) { + super(settings, configPath); + plugins.add(new LogsDBPlugin(settings) { + @Override + protected XPackLicenseState getLicenseState() { + return P.this.getLicenseState(); + } + + @Override + protected LicenseService getLicenseService() { + return P.this.getLicenseService(); + } + }); + } + + } +} 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 new file mode 100644 index 0000000000000..939d7d892a48d --- /dev/null +++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderLegacyLicenseTests.java @@ -0,0 +1,129 @@ +/* + * 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.logsdb; + +import org.elasticsearch.cluster.metadata.DataStream; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.index.IndexMode; +import org.elasticsearch.index.IndexVersion; +import org.elasticsearch.index.MapperTestUtils; +import org.elasticsearch.index.mapper.SourceFieldMapper; +import org.elasticsearch.license.License; +import org.elasticsearch.license.LicenseService; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.license.internal.XPackLicenseStatus; +import org.elasticsearch.test.ESTestCase; +import org.junit.Before; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.elasticsearch.xpack.logsdb.SyntheticSourceIndexSettingsProviderTests.getLogsdbIndexModeSettingsProvider; +import static org.elasticsearch.xpack.logsdb.SyntheticSourceLicenseServiceTests.createGoldOrPlatinumLicense; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SyntheticSourceIndexSettingsProviderLegacyLicenseTests extends ESTestCase { + + private SyntheticSourceIndexSettingsProvider provider; + + @Before + public void setup() throws Exception { + long time = LocalDateTime.of(2024, 11, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + License license = createGoldOrPlatinumLicense(); + var licenseState = new XPackLicenseState(() -> time, new XPackLicenseStatus(license.operationMode(), true, null)); + + var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + licenseService.setLicenseState(licenseState); + var mockLicenseService = mock(LicenseService.class); + when(mockLicenseService.getLicense()).thenReturn(license); + + SyntheticSourceLicenseService syntheticSourceLicenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + syntheticSourceLicenseService.setLicenseState(licenseState); + syntheticSourceLicenseService.setLicenseService(mockLicenseService); + + provider = new SyntheticSourceIndexSettingsProvider( + syntheticSourceLicenseService, + im -> MapperTestUtils.newMapperService(xContentRegistry(), createTempDir(), im.getSettings(), im.getIndex().getName()), + getLogsdbIndexModeSettingsProvider(false), + IndexVersion::current + ); + } + + public void testGetAdditionalIndexSettingsDefault() { + Settings settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "SYNTHETIC").build(); + String dataStreamName = "metrics-my-app"; + String indexName = DataStream.getDefaultBackingIndexName(dataStreamName, 0); + var result = provider.getAdditionalIndexSettings(indexName, dataStreamName, null, null, null, settings, List.of()); + assertThat(result.size(), equalTo(1)); + assertThat(result.get(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey()), equalTo("STORED")); + } + + public void testGetAdditionalIndexSettingsApm() throws IOException { + Settings settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "SYNTHETIC").build(); + String dataStreamName = "metrics-apm.app.test"; + String indexName = DataStream.getDefaultBackingIndexName(dataStreamName, 0); + var result = provider.getAdditionalIndexSettings(indexName, dataStreamName, null, null, null, settings, List.of()); + assertThat(result.size(), equalTo(0)); + } + + public void testGetAdditionalIndexSettingsProfiling() throws IOException { + Settings settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "SYNTHETIC").build(); + for (String dataStreamName : new String[] { "profiling-metrics", "profiling-events" }) { + String indexName = DataStream.getDefaultBackingIndexName(dataStreamName, 0); + var result = provider.getAdditionalIndexSettings(indexName, dataStreamName, null, null, null, settings, List.of()); + assertThat(result.size(), equalTo(0)); + } + + for (String indexName : new String[] { ".profiling-sq-executables", ".profiling-sq-leafframes", ".profiling-stacktraces" }) { + var result = provider.getAdditionalIndexSettings(indexName, null, null, null, null, settings, List.of()); + assertThat(result.size(), equalTo(0)); + } + } + + public void testGetAdditionalIndexSettingsTsdb() throws IOException { + Settings settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "SYNTHETIC").build(); + String dataStreamName = "metrics-my-app"; + String indexName = DataStream.getDefaultBackingIndexName(dataStreamName, 0); + var result = provider.getAdditionalIndexSettings(indexName, dataStreamName, IndexMode.TIME_SERIES, null, null, settings, List.of()); + assertThat(result.size(), equalTo(0)); + } + + public void testGetAdditionalIndexSettingsTsdbAfterCutoffDate() throws Exception { + long start = LocalDateTime.of(2024, 12, 20, 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)); + + var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + licenseService.setLicenseState(licenseState); + var mockLicenseService = mock(LicenseService.class); + when(mockLicenseService.getLicense()).thenReturn(license); + + SyntheticSourceLicenseService syntheticSourceLicenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + syntheticSourceLicenseService.setLicenseState(licenseState); + syntheticSourceLicenseService.setLicenseService(mockLicenseService); + + provider = new SyntheticSourceIndexSettingsProvider( + syntheticSourceLicenseService, + im -> MapperTestUtils.newMapperService(xContentRegistry(), createTempDir(), im.getSettings(), im.getIndex().getName()), + getLogsdbIndexModeSettingsProvider(false), + IndexVersion::current + ); + + Settings settings = Settings.builder().put(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey(), "SYNTHETIC").build(); + String dataStreamName = "metrics-my-app"; + String indexName = DataStream.getDefaultBackingIndexName(dataStreamName, 0); + var result = provider.getAdditionalIndexSettings(indexName, dataStreamName, IndexMode.TIME_SERIES, null, null, settings, List.of()); + assertThat(result.size(), equalTo(1)); + assertThat(result.get(SourceFieldMapper.INDEX_MAPPER_SOURCE_MODE_SETTING.getKey()), equalTo("STORED")); + } +} diff --git a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java index d6cdb9f761b31..df1fb8f2d958c 100644 --- a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java +++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceIndexSettingsProviderTests.java @@ -18,6 +18,8 @@ import org.elasticsearch.index.IndexVersion; import org.elasticsearch.index.MapperTestUtils; import org.elasticsearch.index.mapper.SourceFieldMapper; +import org.elasticsearch.license.License; +import org.elasticsearch.license.LicenseService; import org.elasticsearch.license.MockLicenseState; import org.elasticsearch.test.ESTestCase; import org.junit.Before; @@ -28,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.elasticsearch.common.settings.Settings.builder; +import static org.elasticsearch.xpack.logsdb.SyntheticSourceLicenseServiceTests.createEnterpriseLicense; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -39,18 +42,22 @@ public class SyntheticSourceIndexSettingsProviderTests extends ESTestCase { private SyntheticSourceIndexSettingsProvider provider; private final AtomicInteger newMapperServiceCounter = new AtomicInteger(); - private static LogsdbIndexModeSettingsProvider getLogsdbIndexModeSettingsProvider(boolean enabled) { + static LogsdbIndexModeSettingsProvider getLogsdbIndexModeSettingsProvider(boolean enabled) { return new LogsdbIndexModeSettingsProvider(Settings.builder().put("cluster.logsdb.enabled", enabled).build()); } @Before - public void setup() { - MockLicenseState licenseState = mock(MockLicenseState.class); + public void setup() throws Exception { + MockLicenseState licenseState = MockLicenseState.createMock(); when(licenseState.isAllowed(any())).thenReturn(true); var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); licenseService.setLicenseState(licenseState); + var mockLicenseService = mock(LicenseService.class); + License license = createEnterpriseLicense(); + when(mockLicenseService.getLicense()).thenReturn(license); syntheticSourceLicenseService = new SyntheticSourceLicenseService(Settings.EMPTY); syntheticSourceLicenseService.setLicenseState(licenseState); + syntheticSourceLicenseService.setLicenseService(mockLicenseService); provider = new SyntheticSourceIndexSettingsProvider(syntheticSourceLicenseService, im -> { newMapperServiceCounter.incrementAndGet(); diff --git a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseServiceTests.java b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseServiceTests.java index 430ee75eb3561..90a13b16c028e 100644 --- a/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseServiceTests.java +++ b/x-pack/plugin/logsdb/src/test/java/org/elasticsearch/xpack/logsdb/SyntheticSourceLicenseServiceTests.java @@ -8,54 +8,195 @@ package org.elasticsearch.xpack.logsdb; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.License; +import org.elasticsearch.license.LicenseService; import org.elasticsearch.license.MockLicenseState; +import org.elasticsearch.license.TestUtils; import org.elasticsearch.test.ESTestCase; +import org.junit.Before; import org.mockito.Mockito; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.elasticsearch.license.TestUtils.dateMath; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class SyntheticSourceLicenseServiceTests extends ESTestCase { + private LicenseService mockLicenseService; + private SyntheticSourceLicenseService licenseService; + + @Before + public void setup() throws Exception { + mockLicenseService = mock(LicenseService.class); + License license = createEnterpriseLicense(); + when(mockLicenseService.getLicense()).thenReturn(license); + licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + } + public void testLicenseAllowsSyntheticSource() { - MockLicenseState licenseState = mock(MockLicenseState.class); - when(licenseState.isAllowed(any())).thenReturn(true); - var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(true); licenseService.setLicenseState(licenseState); - assertFalse("synthetic source is allowed, so not fallback to stored source", licenseService.fallbackToStoredSource(false)); + licenseService.setLicenseService(mockLicenseService); + assertFalse( + "synthetic source is allowed, so not fallback to stored source", + licenseService.fallbackToStoredSource(false, randomBoolean()) + ); Mockito.verify(licenseState, Mockito.times(1)).featureUsed(any()); } public void testLicenseAllowsSyntheticSourceTemplateValidation() { - MockLicenseState licenseState = mock(MockLicenseState.class); - when(licenseState.isAllowed(any())).thenReturn(true); - var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(true); licenseService.setLicenseState(licenseState); - assertFalse("synthetic source is allowed, so not fallback to stored source", licenseService.fallbackToStoredSource(true)); + licenseService.setLicenseService(mockLicenseService); + assertFalse( + "synthetic source is allowed, so not fallback to stored source", + licenseService.fallbackToStoredSource(true, randomBoolean()) + ); Mockito.verify(licenseState, Mockito.never()).featureUsed(any()); } public void testDefaultDisallow() { - MockLicenseState licenseState = mock(MockLicenseState.class); - when(licenseState.isAllowed(any())).thenReturn(false); - var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(false); licenseService.setLicenseState(licenseState); - assertTrue("synthetic source is not allowed, so fallback to stored source", licenseService.fallbackToStoredSource(false)); + licenseService.setLicenseService(mockLicenseService); + assertTrue( + "synthetic source is not allowed, so fallback to stored source", + licenseService.fallbackToStoredSource(false, randomBoolean()) + ); Mockito.verify(licenseState, Mockito.never()).featureUsed(any()); } public void testFallback() { - MockLicenseState licenseState = mock(MockLicenseState.class); - when(licenseState.isAllowed(any())).thenReturn(true); - var licenseService = new SyntheticSourceLicenseService(Settings.EMPTY); + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(true); licenseService.setLicenseState(licenseState); + licenseService.setLicenseService(mockLicenseService); licenseService.setSyntheticSourceFallback(true); assertTrue( "synthetic source is allowed, but fallback has been enabled, so fallback to stored source", - licenseService.fallbackToStoredSource(false) + licenseService.fallbackToStoredSource(false, randomBoolean()) ); Mockito.verifyNoInteractions(licenseState); + Mockito.verifyNoInteractions(mockLicenseService); + } + + public void testGoldOrPlatinumLicense() throws Exception { + mockLicenseService = mock(LicenseService.class); + License license = createGoldOrPlatinumLicense(); + when(mockLicenseService.getLicense()).thenReturn(license); + + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.getOperationMode()).thenReturn(license.operationMode()); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY))).thenReturn(true); + licenseService.setLicenseState(licenseState); + licenseService.setLicenseService(mockLicenseService); + assertFalse( + "legacy licensed usage is allowed, so not fallback to stored source", + licenseService.fallbackToStoredSource(false, true) + ); + Mockito.verify(licenseState, Mockito.times(1)).featureUsed(any()); } + public void testGoldOrPlatinumLicenseLegacyLicenseNotAllowed() throws Exception { + mockLicenseService = mock(LicenseService.class); + License license = createGoldOrPlatinumLicense(); + when(mockLicenseService.getLicense()).thenReturn(license); + + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.getOperationMode()).thenReturn(license.operationMode()); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(false); + licenseService.setLicenseState(licenseState); + licenseService.setLicenseService(mockLicenseService); + assertTrue( + "legacy licensed usage is not allowed, so fallback to stored source", + licenseService.fallbackToStoredSource(false, false) + ); + Mockito.verify(licenseState, Mockito.never()).featureUsed(any()); + Mockito.verify(licenseState, Mockito.times(1)).isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE)); + } + + public void testGoldOrPlatinumLicenseBeyondCutoffDate() throws Exception { + long start = LocalDateTime.of(2025, 1, 1, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + License license = createGoldOrPlatinumLicense(start); + mockLicenseService = mock(LicenseService.class); + when(mockLicenseService.getLicense()).thenReturn(license); + + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.getOperationMode()).thenReturn(license.operationMode()); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE))).thenReturn(false); + licenseService.setLicenseState(licenseState); + licenseService.setLicenseService(mockLicenseService); + assertTrue("beyond cutoff date, so fallback to stored source", licenseService.fallbackToStoredSource(false, true)); + Mockito.verify(licenseState, Mockito.never()).featureUsed(any()); + Mockito.verify(licenseState, Mockito.times(1)).isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE)); + } + + public void testGoldOrPlatinumLicenseCustomCutoffDate() throws Exception { + licenseService = new SyntheticSourceLicenseService(Settings.EMPTY, "2025-01-02T00:00"); + + long start = LocalDateTime.of(2025, 1, 1, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + License license = createGoldOrPlatinumLicense(start); + mockLicenseService = mock(LicenseService.class); + when(mockLicenseService.getLicense()).thenReturn(license); + + MockLicenseState licenseState = MockLicenseState.createMock(); + when(licenseState.getOperationMode()).thenReturn(license.operationMode()); + when(licenseState.isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY))).thenReturn(true); + licenseService.setLicenseState(licenseState); + licenseService.setLicenseService(mockLicenseService); + assertFalse("custom cutoff date, so fallback to stored source", licenseService.fallbackToStoredSource(false, true)); + Mockito.verify(licenseState, Mockito.times(1)).featureUsed(any()); + Mockito.verify(licenseState, Mockito.times(1)).isAllowed(same(SyntheticSourceLicenseService.SYNTHETIC_SOURCE_FEATURE_LEGACY)); + } + + static License createEnterpriseLicense() throws Exception { + long start = LocalDateTime.of(2024, 11, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + return createEnterpriseLicense(start); + } + + static License createEnterpriseLicense(long start) throws Exception { + String uid = UUID.randomUUID().toString(); + long currentTime = System.currentTimeMillis(); + final License.Builder builder = License.builder() + .uid(uid) + .version(License.VERSION_CURRENT) + .expiryDate(dateMath("now+2d", currentTime)) + .startDate(start) + .issueDate(currentTime) + .type("enterprise") + .issuedTo("customer") + .issuer("elasticsearch") + .maxResourceUnits(10); + return TestUtils.generateSignedLicense(builder); + } + + static License createGoldOrPlatinumLicense() throws Exception { + long start = LocalDateTime.of(2024, 11, 12, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli(); + return createGoldOrPlatinumLicense(start); + } + + static License createGoldOrPlatinumLicense(long start) throws Exception { + String uid = UUID.randomUUID().toString(); + long currentTime = System.currentTimeMillis(); + final License.Builder builder = License.builder() + .uid(uid) + .version(License.VERSION_CURRENT) + .expiryDate(dateMath("now+100d", currentTime)) + .startDate(start) + .issueDate(currentTime) + .type(randomBoolean() ? "gold" : "platinum") + .issuedTo("customer") + .issuer("elasticsearch") + .maxNodes(5); + return TestUtils.generateSignedLicense(builder); + } } From b13e0d25c0ef52bf6236a981bee4823b12934a57 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 26 Nov 2024 09:06:02 +0000 Subject: [PATCH 039/129] Support dynamic credentials in `S3HttpFixture` (#117458) Rephrase the authorization check in `S3HttpFixture` in terms of a predicate provided by the caller so that there's no need for a separate subclass that handles session tokens, and so that it can support auto-generated credentials more naturally. Also adapts `Ec2ImdsHttpFixture` to dynamically generate credentials this way. Also extracts the STS fixture in `S3HttpFixtureWithSTS` into a separate service, similarly to #117324, and adapts this new fixture to dynamically generate credentials too. Relates ES-9984 --- modules/repository-s3/build.gradle | 1 + .../RepositoryS3RestReloadCredentialsIT.java | 15 +- .../s3/RepositoryS3ClientYamlTestSuiteIT.java | 25 +- .../RepositoryS3EcsClientYamlTestSuiteIT.java | 25 +- .../RepositoryS3StsClientYamlTestSuiteIT.java | 27 +- settings.gradle | 1 + test/fixtures/aws-sts-fixture/build.gradle | 19 ++ .../fixture/aws/sts/AwsStsHttpFixture.java | 64 +++++ .../fixture/aws/sts/AwsStsHttpHandler.java} | 77 +++-- .../aws/sts/AwsStsHttpHandlerTests.java | 268 ++++++++++++++++++ .../fixture/aws/imds/Ec2ImdsHttpFixture.java | 13 +- .../fixture/aws/imds/Ec2ImdsHttpHandler.java | 12 +- .../aws/imds/Ec2ImdsHttpHandlerTests.java | 15 +- .../java/fixture/s3/DynamicS3Credentials.java | 39 +++ .../main/java/fixture/s3/S3HttpFixture.java | 40 ++- .../s3/S3HttpFixtureWithSessionToken.java | 42 --- ...earchableSnapshotsCredentialsReloadIT.java | 23 +- 17 files changed, 552 insertions(+), 154 deletions(-) create mode 100644 test/fixtures/aws-sts-fixture/build.gradle create mode 100644 test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpFixture.java rename test/fixtures/{s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSTS.java => aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java} (66%) create mode 100644 test/fixtures/aws-sts-fixture/src/test/java/fixture/aws/sts/AwsStsHttpHandlerTests.java create mode 100644 test/fixtures/s3-fixture/src/main/java/fixture/s3/DynamicS3Credentials.java delete mode 100644 test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java diff --git a/modules/repository-s3/build.gradle b/modules/repository-s3/build.gradle index 9a7f0a5994d73..ed1777891f40d 100644 --- a/modules/repository-s3/build.gradle +++ b/modules/repository-s3/build.gradle @@ -46,6 +46,7 @@ dependencies { yamlRestTestImplementation project(":test:framework") yamlRestTestImplementation project(':test:fixtures:s3-fixture') yamlRestTestImplementation project(':test:fixtures:ec2-imds-fixture') + yamlRestTestImplementation project(':test:fixtures:aws-sts-fixture') yamlRestTestImplementation project(':test:fixtures:minio-fixture') internalClusterTestImplementation project(':test:fixtures:minio-fixture') diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java index 2f3e995b52468..430c0a1994967 100644 --- a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java @@ -35,7 +35,14 @@ public class RepositoryS3RestReloadCredentialsIT extends ESRestTestCase { private static final String BUCKET = "RepositoryS3RestReloadCredentialsIT-bucket-" + HASHED_SEED; private static final String BASE_PATH = "RepositoryS3RestReloadCredentialsIT-base-path-" + HASHED_SEED; - public static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, "ignored"); + private static volatile String repositoryAccessKey; + + public static final S3HttpFixture s3Fixture = new S3HttpFixture( + true, + BUCKET, + BASE_PATH, + S3HttpFixture.mutableAccessKey(() -> repositoryAccessKey) + ); private static final MutableSettingsProvider keystoreSettings = new MutableSettingsProvider(); @@ -68,7 +75,7 @@ public void testReloadCredentialsFromKeystore() throws IOException { // Set up initial credentials final var accessKey1 = randomIdentifier(); - s3Fixture.setAccessKey(accessKey1); + repositoryAccessKey = accessKey1; keystoreSettings.put("s3.client.default.access_key", accessKey1); keystoreSettings.put("s3.client.default.secret_key", randomIdentifier()); cluster.updateStoredSecureSettings(); @@ -79,14 +86,14 @@ public void testReloadCredentialsFromKeystore() throws IOException { // Rotate credentials in blob store final var accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier); - s3Fixture.setAccessKey(accessKey2); + repositoryAccessKey = accessKey2; // Ensure that initial credentials now invalid final var accessDeniedException2 = expectThrows(ResponseException.class, () -> client().performRequest(verifyRequest)); assertThat(accessDeniedException2.getResponse().getStatusLine().getStatusCode(), equalTo(500)); assertThat( accessDeniedException2.getMessage(), - allOf(containsString("Bad access key"), containsString("Status Code: 403"), containsString("Error Code: AccessDenied")) + allOf(containsString("Access denied"), containsString("Status Code: 403"), containsString("Error Code: AccessDenied")) ); // Set up refreshed credentials diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java index 64cb3c3fd3a69..a3b154b4bdfed 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java @@ -10,8 +10,8 @@ package org.elasticsearch.repositories.s3; import fixture.aws.imds.Ec2ImdsHttpFixture; +import fixture.s3.DynamicS3Credentials; import fixture.s3.S3HttpFixture; -import fixture.s3.S3HttpFixtureWithSessionToken; import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; @@ -34,27 +34,30 @@ public class RepositoryS3ClientYamlTestSuiteIT extends AbstractRepositoryS3Clien private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); private static final String TEMPORARY_SESSION_TOKEN = "session_token-" + HASHED_SEED; - private static final String IMDS_ACCESS_KEY = "imds-access-key-" + HASHED_SEED; - private static final String IMDS_SESSION_TOKEN = "imds-session-token-" + HASHED_SEED; private static final S3HttpFixture s3Fixture = new S3HttpFixture(); - private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithSessionToken = new S3HttpFixtureWithSessionToken( + private static final S3HttpFixture s3HttpFixtureWithSessionToken = new S3HttpFixture( + true, "session_token_bucket", "session_token_base_path_integration_tests", - System.getProperty("s3TemporaryAccessKey"), - TEMPORARY_SESSION_TOKEN + S3HttpFixture.fixedAccessKeyAndToken(System.getProperty("s3TemporaryAccessKey"), TEMPORARY_SESSION_TOKEN) ); - private static final S3HttpFixtureWithSessionToken s3HttpFixtureWithImdsSessionToken = new S3HttpFixtureWithSessionToken( + private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); + + private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture( + dynamicS3Credentials::addValidCredentials, + Set.of() + ); + + private static final S3HttpFixture s3HttpFixtureWithImdsSessionToken = new S3HttpFixture( + true, "ec2_bucket", "ec2_base_path", - IMDS_ACCESS_KEY, - IMDS_SESSION_TOKEN + dynamicS3Credentials::isAuthorized ); - private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(IMDS_ACCESS_KEY, IMDS_SESSION_TOKEN, Set.of()); - public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") .keystore("s3.client.integration_test_permanent.access_key", System.getProperty("s3PermanentAccessKey")) diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java index a522c9b17145b..bbd003f506ead 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java @@ -10,12 +10,12 @@ package org.elasticsearch.repositories.s3; import fixture.aws.imds.Ec2ImdsHttpFixture; -import fixture.s3.S3HttpFixtureWithSessionToken; +import fixture.s3.DynamicS3Credentials; +import fixture.s3.S3HttpFixture; import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; -import org.elasticsearch.cluster.routing.Murmur3HashFunction; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; import org.junit.ClassRule; @@ -26,23 +26,20 @@ public class RepositoryS3EcsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { - private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); - private static final String ECS_ACCESS_KEY = "ecs-access-key-" + HASHED_SEED; - private static final String ECS_SESSION_TOKEN = "ecs-session-token-" + HASHED_SEED; - - private static final S3HttpFixtureWithSessionToken s3Fixture = new S3HttpFixtureWithSessionToken( - "ecs_bucket", - "ecs_base_path", - ECS_ACCESS_KEY, - ECS_SESSION_TOKEN - ); + private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture( - ECS_ACCESS_KEY, - ECS_SESSION_TOKEN, + dynamicS3Credentials::addValidCredentials, Set.of("/ecs_credentials_endpoint") ); + private static final S3HttpFixture s3Fixture = new S3HttpFixture( + true, + "ecs_bucket", + "ecs_base_path", + dynamicS3Credentials::isAuthorized + ); + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") .setting("s3.client.integration_test_ecs.endpoint", s3Fixture::getAddress) diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java index 24f03a6ae7624..7c4d719485113 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java @@ -9,8 +9,9 @@ package org.elasticsearch.repositories.s3; +import fixture.aws.sts.AwsStsHttpFixture; +import fixture.s3.DynamicS3Credentials; import fixture.s3.S3HttpFixture; -import fixture.s3.S3HttpFixtureWithSTS; import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; @@ -24,13 +25,27 @@ public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { - public static final S3HttpFixture s3Fixture = new S3HttpFixture(); - private static final S3HttpFixtureWithSTS s3Sts = new S3HttpFixtureWithSTS(); + private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); + + private static final S3HttpFixture s3HttpFixture = new S3HttpFixture( + true, + "sts_bucket", + "sts_base_path", + dynamicS3Credentials::isAuthorized + ); + + private static final AwsStsHttpFixture stsHttpFixture = new AwsStsHttpFixture(dynamicS3Credentials::addValidCredentials, """ + Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDans\ + FBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFO\ + zTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ"""); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .setting("s3.client.integration_test_sts.endpoint", s3Sts::getAddress) - .systemProperty("com.amazonaws.sdk.stsMetadataServiceEndpointOverride", () -> s3Sts.getAddress() + "/assume-role-with-web-identity") + .setting("s3.client.integration_test_sts.endpoint", s3HttpFixture::getAddress) + .systemProperty( + "com.amazonaws.sdk.stsMetadataServiceEndpointOverride", + () -> stsHttpFixture.getAddress() + "/assume-role-with-web-identity" + ) .configFile("repository-s3/aws-web-identity-token-file", Resource.fromClasspath("aws-web-identity-token-file")) .environment("AWS_WEB_IDENTITY_TOKEN_FILE", System.getProperty("awsWebIdentityTokenExternalLocation")) // // The AWS STS SDK requires the role and session names to be set. We can verify that they are sent to S3S in the @@ -40,7 +55,7 @@ public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3Cl .build(); @ClassRule - public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(s3Sts).around(cluster); + public static TestRule ruleChain = RuleChain.outerRule(s3HttpFixture).around(stsHttpFixture).around(cluster); @ParametersFactory public static Iterable parameters() throws Exception { diff --git a/settings.gradle b/settings.gradle index 7bf03263031f1..4722fc311480a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -86,6 +86,7 @@ List projects = [ 'distribution:tools:ansi-console', 'server', 'test:framework', + 'test:fixtures:aws-sts-fixture', 'test:fixtures:azure-fixture', 'test:fixtures:ec2-imds-fixture', 'test:fixtures:gcs-fixture', diff --git a/test/fixtures/aws-sts-fixture/build.gradle b/test/fixtures/aws-sts-fixture/build.gradle new file mode 100644 index 0000000000000..57f0f8fe25493 --- /dev/null +++ b/test/fixtures/aws-sts-fixture/build.gradle @@ -0,0 +1,19 @@ +/* + * 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". + */ +apply plugin: 'elasticsearch.java' + +description = 'Fixture for emulating the Security Token Service (STS) running in AWS' + +dependencies { + api project(':server') + api("junit:junit:${versions.junit}") { + transitive = false + } + api project(':test:framework') +} diff --git a/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpFixture.java b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpFixture.java new file mode 100644 index 0000000000000..13ba7eaf8ba67 --- /dev/null +++ b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpFixture.java @@ -0,0 +1,64 @@ +/* + * 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 fixture.aws.sts; + +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.rules.ExternalResource; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Objects; +import java.util.function.BiConsumer; + +public class AwsStsHttpFixture extends ExternalResource { + + private HttpServer server; + + private final BiConsumer newCredentialsConsumer; + private final String webIdentityToken; + + public AwsStsHttpFixture(BiConsumer newCredentialsConsumer, String webIdentityToken) { + this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer); + this.webIdentityToken = Objects.requireNonNull(webIdentityToken); + } + + protected HttpHandler createHandler() { + return new AwsStsHttpHandler(newCredentialsConsumer, webIdentityToken); + } + + public String getAddress() { + return "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + } + + public void stop(int delay) { + server.stop(delay); + } + + protected void before() throws Throwable { + server = HttpServer.create(resolveAddress(), 0); + server.createContext("/", Objects.requireNonNull(createHandler())); + server.start(); + } + + @Override + protected void after() { + stop(0); + } + + private static InetSocketAddress resolveAddress() { + try { + return new InetSocketAddress(InetAddress.getByName("localhost"), 0); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } +} diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSTS.java b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java similarity index 66% rename from test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSTS.java rename to test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java index 54e0be1e321a2..84541f5e15211 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSTS.java +++ b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java @@ -6,12 +6,16 @@ * 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 fixture.s3; +package fixture.aws.sts; +import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.rest.RestStatus; +import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; @@ -19,53 +23,39 @@ import java.util.Arrays; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; import java.util.stream.Collectors; -public class S3HttpFixtureWithSTS extends S3HttpFixture { +import static org.elasticsearch.test.ESTestCase.randomIdentifier; - private static final String ROLE_ARN = "arn:aws:iam::123456789012:role/FederatedWebIdentityRole"; - private static final String ROLE_NAME = "sts-fixture-test"; - private final String sessionToken; - private final String webIdentityToken; +/** + * Minimal HTTP handler that emulates the AWS STS server + */ +@SuppressForbidden(reason = "this test uses a HttpServer to emulate the AWS STS endpoint") +public class AwsStsHttpHandler implements HttpHandler { - public S3HttpFixtureWithSTS() { - this(true); - } + static final String ROLE_ARN = "arn:aws:iam::123456789012:role/FederatedWebIdentityRole"; + static final String ROLE_NAME = "sts-fixture-test"; - public S3HttpFixtureWithSTS(boolean enabled) { - this( - enabled, - "sts_bucket", - "sts_base_path", - "sts_access_key", - "sts_session_token", - "Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDansFBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFOzTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ" - ); - } + private final BiConsumer newCredentialsConsumer; + private final String webIdentityToken; - public S3HttpFixtureWithSTS( - boolean enabled, - String bucket, - String basePath, - String accessKey, - String sessionToken, - String webIdentityToken - ) { - super(enabled, bucket, basePath, accessKey); - this.sessionToken = sessionToken; - this.webIdentityToken = webIdentityToken; + public AwsStsHttpHandler(BiConsumer newCredentialsConsumer, String webIdentityToken) { + this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer); + this.webIdentityToken = Objects.requireNonNull(webIdentityToken); } @Override - protected HttpHandler createHandler() { - final HttpHandler delegate = super.createHandler(); + public void handle(final HttpExchange exchange) throws IOException { + // https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html + + try (exchange) { + final var requestMethod = exchange.getRequestMethod(); + final var path = exchange.getRequestURI().getPath(); + + if ("POST".equals(requestMethod) && "/assume-role-with-web-identity/".equals(path)) { - return exchange -> { - // https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html - // It's run as a separate service, but we emulate it under the `assume-role-with-web-identity` endpoint - // of the S3 serve for the simplicity sake - if ("POST".equals(exchange.getRequestMethod()) - && exchange.getRequestURI().getPath().startsWith("/assume-role-with-web-identity")) { String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); Map params = Arrays.stream(body.split("&")) .map(e -> e.split("=")) @@ -82,6 +72,9 @@ protected HttpHandler createHandler() { exchange.close(); return; } + final var accessKey = randomIdentifier(); + final var sessionToken = randomIdentifier(); + newCredentialsConsumer.accept(accessKey, sessionToken); final byte[] response = String.format( Locale.ROOT, """ @@ -95,7 +88,7 @@ protected HttpHandler createHandler() { %s - secret_access_key + %s %s %s @@ -109,6 +102,7 @@ protected HttpHandler createHandler() { ROLE_ARN, ROLE_NAME, sessionToken, + randomIdentifier(), ZonedDateTime.now().plusDays(1L).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")), accessKey ).getBytes(StandardCharsets.UTF_8); @@ -118,7 +112,8 @@ protected HttpHandler createHandler() { exchange.close(); return; } - delegate.handle(exchange); - }; + + ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError("not supported: " + requestMethod + " " + path)); + } } } diff --git a/test/fixtures/aws-sts-fixture/src/test/java/fixture/aws/sts/AwsStsHttpHandlerTests.java b/test/fixtures/aws-sts-fixture/src/test/java/fixture/aws/sts/AwsStsHttpHandlerTests.java new file mode 100644 index 0000000000000..4094ce18e7aef --- /dev/null +++ b/test/fixtures/aws-sts-fixture/src/test/java/fixture/aws/sts/AwsStsHttpHandlerTests.java @@ -0,0 +1,268 @@ +/* + * 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 fixture.aws.sts; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.containsString; + +public class AwsStsHttpHandlerTests extends ESTestCase { + + public void testGenerateCredentials() { + final Map generatedCredentials = new HashMap<>(); + + final var webIdentityToken = randomUnicodeOfLength(10); + final var handler = new AwsStsHttpHandler(generatedCredentials::put, webIdentityToken); + + final var response = handleRequest( + handler, + Map.of( + "Action", + "AssumeRoleWithWebIdentity", + "RoleSessionName", + AwsStsHttpHandler.ROLE_NAME, + "RoleArn", + AwsStsHttpHandler.ROLE_ARN, + "WebIdentityToken", + webIdentityToken + ) + ); + assertEquals(RestStatus.OK, response.status()); + + assertThat(generatedCredentials, aMapWithSize(1)); + final var accessKey = generatedCredentials.keySet().iterator().next(); + final var sessionToken = generatedCredentials.values().iterator().next(); + + final var responseBody = response.body().utf8ToString(); + assertThat(responseBody, containsString("" + accessKey + "")); + assertThat(responseBody, containsString("" + sessionToken + "")); + } + + public void testInvalidAction() { + final var handler = new AwsStsHttpHandler((key, token) -> fail(), randomUnicodeOfLength(10)); + final var response = handleRequest(handler, Map.of("Action", "Unsupported")); + assertEquals(RestStatus.BAD_REQUEST, response.status()); + } + + public void testInvalidRole() { + final var webIdentityToken = randomUnicodeOfLength(10); + final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken); + final var response = handleRequest( + handler, + Map.of( + "Action", + "AssumeRoleWithWebIdentity", + "RoleSessionName", + randomValueOtherThan(AwsStsHttpHandler.ROLE_NAME, ESTestCase::randomIdentifier), + "RoleArn", + AwsStsHttpHandler.ROLE_ARN, + "WebIdentityToken", + webIdentityToken + ) + ); + assertEquals(RestStatus.UNAUTHORIZED, response.status()); + } + + public void testInvalidToken() { + final var webIdentityToken = randomUnicodeOfLength(10); + final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken); + final var response = handleRequest( + handler, + Map.of( + "Action", + "AssumeRoleWithWebIdentity", + "RoleSessionName", + AwsStsHttpHandler.ROLE_NAME, + "RoleArn", + AwsStsHttpHandler.ROLE_ARN, + "WebIdentityToken", + randomValueOtherThan(webIdentityToken, () -> randomUnicodeOfLength(10)) + ) + ); + assertEquals(RestStatus.UNAUTHORIZED, response.status()); + } + + public void testInvalidARN() { + final var webIdentityToken = randomUnicodeOfLength(10); + final var handler = new AwsStsHttpHandler((key, token) -> fail(), webIdentityToken); + final var response = handleRequest( + handler, + Map.of( + "Action", + "AssumeRoleWithWebIdentity", + "RoleSessionName", + AwsStsHttpHandler.ROLE_NAME, + "RoleArn", + randomValueOtherThan(AwsStsHttpHandler.ROLE_ARN, ESTestCase::randomIdentifier), + "WebIdentityToken", + webIdentityToken + ) + ); + assertEquals(RestStatus.UNAUTHORIZED, response.status()); + } + + private record TestHttpResponse(RestStatus status, BytesReference body) {} + + private static TestHttpResponse handleRequest(AwsStsHttpHandler handler, Map body) { + final var httpExchange = new TestHttpExchange( + "POST", + "/assume-role-with-web-identity/", + new BytesArray( + body.entrySet() + .stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")) + ), + TestHttpExchange.EMPTY_HEADERS + ); + try { + handler.handle(httpExchange); + } catch (IOException e) { + fail(e); + } + assertNotEquals(0, httpExchange.getResponseCode()); + return new TestHttpResponse(RestStatus.fromCode(httpExchange.getResponseCode()), httpExchange.getResponseBodyContents()); + } + + private static class TestHttpExchange extends HttpExchange { + + private static final Headers EMPTY_HEADERS = new Headers(); + + private final String method; + private final URI uri; + private final BytesReference requestBody; + private final Headers requestHeaders; + + private final Headers responseHeaders = new Headers(); + private final BytesStreamOutput responseBody = new BytesStreamOutput(); + private int responseCode; + + TestHttpExchange(String method, String uri, BytesReference requestBody, Headers requestHeaders) { + this.method = method; + this.uri = URI.create(uri); + this.requestBody = requestBody; + this.requestHeaders = requestHeaders; + } + + @Override + public Headers getRequestHeaders() { + return requestHeaders; + } + + @Override + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public URI getRequestURI() { + return uri; + } + + @Override + public String getRequestMethod() { + return method; + } + + @Override + public HttpContext getHttpContext() { + return null; + } + + @Override + public void close() {} + + @Override + public InputStream getRequestBody() { + try { + return requestBody.streamInput(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public OutputStream getResponseBody() { + return responseBody; + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) { + this.responseCode = rCode; + } + + @Override + public InetSocketAddress getRemoteAddress() { + return null; + } + + @Override + public int getResponseCode() { + return responseCode; + } + + public BytesReference getResponseBodyContents() { + return responseBody.bytes(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return null; + } + + @Override + public String getProtocol() { + return "HTTP/1.1"; + } + + @Override + public Object getAttribute(String name) { + return null; + } + + @Override + public void setAttribute(String name, Object value) { + fail("setAttribute not implemented"); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + fail("setStreams not implemented"); + } + + @Override + public HttpPrincipal getPrincipal() { + fail("getPrincipal not implemented"); + throw new UnsupportedOperationException("getPrincipal not implemented"); + } + } + +} diff --git a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java index 68f46d778018c..13d36c6fc4812 100644 --- a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java +++ b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java @@ -18,23 +18,22 @@ import java.net.UnknownHostException; import java.util.Objects; import java.util.Set; +import java.util.function.BiConsumer; public class Ec2ImdsHttpFixture extends ExternalResource { private HttpServer server; - private final String accessKey; - private final String sessionToken; + private final BiConsumer newCredentialsConsumer; private final Set alternativeCredentialsEndpoints; - public Ec2ImdsHttpFixture(String accessKey, String sessionToken, Set alternativeCredentialsEndpoints) { - this.accessKey = accessKey; - this.sessionToken = sessionToken; - this.alternativeCredentialsEndpoints = alternativeCredentialsEndpoints; + public Ec2ImdsHttpFixture(BiConsumer newCredentialsConsumer, Set alternativeCredentialsEndpoints) { + this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer); + this.alternativeCredentialsEndpoints = Objects.requireNonNull(alternativeCredentialsEndpoints); } protected HttpHandler createHandler() { - return new Ec2ImdsHttpHandler(accessKey, sessionToken, alternativeCredentialsEndpoints); + return new Ec2ImdsHttpHandler(newCredentialsConsumer, alternativeCredentialsEndpoints); } public String getAddress() { diff --git a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java index 04e5e83bddfa9..a92f1bdc5f9ae 100644 --- a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java +++ b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.Objects; import java.util.Set; +import java.util.function.BiConsumer; import static org.elasticsearch.test.ESTestCase.randomIdentifier; @@ -36,13 +37,11 @@ public class Ec2ImdsHttpHandler implements HttpHandler { private static final String IMDS_SECURITY_CREDENTIALS_PATH = "/latest/meta-data/iam/security-credentials/"; - private final String accessKey; - private final String sessionToken; + private final BiConsumer newCredentialsConsumer; private final Set validCredentialsEndpoints = ConcurrentCollections.newConcurrentSet(); - public Ec2ImdsHttpHandler(String accessKey, String sessionToken, Collection alternativeCredentialsEndpoints) { - this.accessKey = Objects.requireNonNull(accessKey); - this.sessionToken = Objects.requireNonNull(sessionToken); + public Ec2ImdsHttpHandler(BiConsumer newCredentialsConsumer, Collection alternativeCredentialsEndpoints) { + this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer); this.validCredentialsEndpoints.addAll(alternativeCredentialsEndpoints); } @@ -70,6 +69,9 @@ public void handle(final HttpExchange exchange) throws IOException { exchange.getResponseBody().write(response); return; } else if (validCredentialsEndpoints.contains(path)) { + final String accessKey = randomIdentifier(); + final String sessionToken = randomIdentifier(); + newCredentialsConsumer.accept(accessKey, sessionToken); final byte[] response = Strings.format( """ { diff --git a/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java b/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java index 5d5cbfae3fa60..369b0ef449b2f 100644 --- a/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java +++ b/test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java @@ -28,15 +28,18 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.URI; +import java.util.HashMap; +import java.util.Map; import java.util.Set; +import static org.hamcrest.Matchers.aMapWithSize; + public class Ec2ImdsHttpHandlerTests extends ESTestCase { public void testImdsV1() throws IOException { - final var accessKey = randomIdentifier(); - final var sessionToken = randomIdentifier(); + final Map generatedCredentials = new HashMap<>(); - final var handler = new Ec2ImdsHttpHandler(accessKey, sessionToken, Set.of()); + final var handler = new Ec2ImdsHttpHandler(generatedCredentials::put, Set.of()); final var roleResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/"); assertEquals(RestStatus.OK, roleResponse.status()); @@ -46,6 +49,10 @@ public void testImdsV1() throws IOException { final var credentialsResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/" + profileName); assertEquals(RestStatus.OK, credentialsResponse.status()); + assertThat(generatedCredentials, aMapWithSize(1)); + final var accessKey = generatedCredentials.keySet().iterator().next(); + final var sessionToken = generatedCredentials.values().iterator().next(); + final var responseMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), credentialsResponse.body().streamInput(), false); assertEquals(Set.of("AccessKeyId", "Expiration", "RoleArn", "SecretAccessKey", "Token"), responseMap.keySet()); assertEquals(accessKey, responseMap.get("AccessKeyId")); @@ -55,7 +62,7 @@ public void testImdsV1() throws IOException { public void testImdsV2Disabled() { assertEquals( RestStatus.METHOD_NOT_ALLOWED, - handleRequest(new Ec2ImdsHttpHandler(randomIdentifier(), randomIdentifier(), Set.of()), "PUT", "/latest/api/token").status() + handleRequest(new Ec2ImdsHttpHandler((accessKey, sessionToken) -> fail(), Set.of()), "PUT", "/latest/api/token").status() ); } diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/DynamicS3Credentials.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/DynamicS3Credentials.java new file mode 100644 index 0000000000000..4e8f267ad3543 --- /dev/null +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/DynamicS3Credentials.java @@ -0,0 +1,39 @@ +/* + * 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 fixture.s3; + +import org.elasticsearch.common.util.concurrent.ConcurrentCollections; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Allows dynamic creation of access-key/session-token credentials for accessing AWS services such as S3. Typically there's one service + * (e.g. IMDS or STS) which creates credentials dynamically and registers them here using {@link #addValidCredentials}, and then the + * {@link S3HttpFixture} uses {@link #isAuthorized} to validate the credentials it receives corresponds with some previously-generated + * credentials. + */ +public class DynamicS3Credentials { + private final Map> validCredentialsMap = ConcurrentCollections.newConcurrentMap(); + + public boolean isAuthorized(String authorizationHeader, String sessionTokenHeader) { + return authorizationHeader != null + && sessionTokenHeader != null + && validCredentialsMap.getOrDefault(sessionTokenHeader, Set.of()).stream().anyMatch(authorizationHeader::contains); + } + + public void addValidCredentials(String accessKey, String sessionToken) { + validCredentialsMap.computeIfAbsent( + Objects.requireNonNull(sessionToken, "sessionToken"), + t -> ConcurrentCollections.newConcurrentSet() + ).add(Objects.requireNonNull(accessKey, "accessKey")); + } +} diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java index 421478a53e6bc..36f8fedcb3335 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java @@ -21,6 +21,8 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; import java.util.Objects; +import java.util.function.BiPredicate; +import java.util.function.Supplier; public class S3HttpFixture extends ExternalResource { @@ -29,21 +31,21 @@ public class S3HttpFixture extends ExternalResource { private final boolean enabled; private final String bucket; private final String basePath; - protected volatile String accessKey; + private final BiPredicate authorizationPredicate; public S3HttpFixture() { this(true); } public S3HttpFixture(boolean enabled) { - this(enabled, "bucket", "base_path_integration_tests", "s3_test_access_key"); + this(enabled, "bucket", "base_path_integration_tests", fixedAccessKey("s3_test_access_key")); } - public S3HttpFixture(boolean enabled, String bucket, String basePath, String accessKey) { + public S3HttpFixture(boolean enabled, String bucket, String basePath, BiPredicate authorizationPredicate) { this.enabled = enabled; this.bucket = bucket; this.basePath = basePath; - this.accessKey = accessKey; + this.authorizationPredicate = authorizationPredicate; } protected HttpHandler createHandler() { @@ -51,9 +53,11 @@ protected HttpHandler createHandler() { @Override public void handle(final HttpExchange exchange) throws IOException { try { - final String authorization = exchange.getRequestHeaders().getFirst("Authorization"); - if (authorization == null || authorization.contains(accessKey) == false) { - sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Bad access key"); + if (authorizationPredicate.test( + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("x-amz-security-token") + ) == false) { + sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Access denied by " + authorizationPredicate); return; } super.handle(exchange); @@ -76,7 +80,7 @@ public void stop(int delay) { protected void before() throws Throwable { if (enabled) { - InetSocketAddress inetSocketAddress = resolveAddress("localhost", 0); + InetSocketAddress inetSocketAddress = resolveAddress(); this.server = HttpServer.create(inetSocketAddress, 0); HttpHandler handler = createHandler(); this.server.createContext("/", Objects.requireNonNull(handler)); @@ -91,15 +95,27 @@ protected void after() { } } - private static InetSocketAddress resolveAddress(String address, int port) { + private static InetSocketAddress resolveAddress() { try { - return new InetSocketAddress(InetAddress.getByName(address), port); + return new InetSocketAddress(InetAddress.getByName("localhost"), 0); } catch (UnknownHostException e) { throw new RuntimeException(e); } } - public void setAccessKey(String accessKey) { - this.accessKey = accessKey; + public static BiPredicate fixedAccessKey(String accessKey) { + return mutableAccessKey(() -> accessKey); + } + + public static BiPredicate mutableAccessKey(Supplier accessKeySupplier) { + return (authorizationHeader, sessionTokenHeader) -> authorizationHeader != null + && authorizationHeader.contains(accessKeySupplier.get()); + } + + public static BiPredicate fixedAccessKeyAndToken(String accessKey, String sessionToken) { + Objects.requireNonNull(sessionToken); + final var accessKeyPredicate = fixedAccessKey(accessKey); + return (authorizationHeader, sessionTokenHeader) -> accessKeyPredicate.test(authorizationHeader, sessionTokenHeader) + && sessionToken.equals(sessionTokenHeader); } } diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java deleted file mode 100644 index 001cc34d9b20d..0000000000000 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixtureWithSessionToken.java +++ /dev/null @@ -1,42 +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", 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 fixture.s3; - -import com.sun.net.httpserver.HttpHandler; - -import org.elasticsearch.rest.RestStatus; - -import static fixture.s3.S3HttpHandler.sendError; - -public class S3HttpFixtureWithSessionToken extends S3HttpFixture { - - protected final String sessionToken; - - public S3HttpFixtureWithSessionToken(String bucket, String basePath, String accessKey, String sessionToken) { - super(true, bucket, basePath, accessKey); - this.sessionToken = sessionToken; - } - - @Override - protected HttpHandler createHandler() { - final HttpHandler delegate = super.createHandler(); - return exchange -> { - final String securityToken = exchange.getRequestHeaders().getFirst("x-amz-security-token"); - if (securityToken == null) { - sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "No session token"); - return; - } - if (securityToken.equals(sessionToken) == false) { - sendError(exchange, RestStatus.FORBIDDEN, "AccessDenied", "Bad session token"); - return; - } - delegate.handle(exchange); - }; - } -} diff --git a/x-pack/plugin/searchable-snapshots/qa/s3/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/s3/S3SearchableSnapshotsCredentialsReloadIT.java b/x-pack/plugin/searchable-snapshots/qa/s3/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/s3/S3SearchableSnapshotsCredentialsReloadIT.java index 3049fe830e728..989e5468c4fb3 100644 --- a/x-pack/plugin/searchable-snapshots/qa/s3/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/s3/S3SearchableSnapshotsCredentialsReloadIT.java +++ b/x-pack/plugin/searchable-snapshots/qa/s3/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/s3/S3SearchableSnapshotsCredentialsReloadIT.java @@ -44,7 +44,14 @@ public class S3SearchableSnapshotsCredentialsReloadIT extends ESRestTestCase { private static final String BUCKET = "S3SearchableSnapshotsCredentialsReloadIT-bucket"; private static final String BASE_PATH = "S3SearchableSnapshotsCredentialsReloadIT-base-path"; - public static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, "ignored"); + private static volatile String repositoryAccessKey; + + public static final S3HttpFixture s3Fixture = new S3HttpFixture( + true, + BUCKET, + BASE_PATH, + S3HttpFixture.mutableAccessKey(() -> repositoryAccessKey) + ); private static final MutableSettingsProvider keystoreSettings = new MutableSettingsProvider(); @@ -78,7 +85,7 @@ public void testReloadCredentialsFromKeystore() throws IOException { // Set up initial credentials final String accessKey1 = randomIdentifier(); - s3Fixture.setAccessKey(accessKey1); + repositoryAccessKey = accessKey1; keystoreSettings.put("s3.client.default.access_key", accessKey1); keystoreSettings.put("s3.client.default.secret_key", randomIdentifier()); cluster.updateStoredSecureSettings(); @@ -92,7 +99,7 @@ public void testReloadCredentialsFromKeystore() throws IOException { // Rotate credentials in blob store logger.info("--> rotate credentials"); final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier); - s3Fixture.setAccessKey(accessKey2); + repositoryAccessKey = accessKey2; // Ensure searchable snapshot now does not work due to invalid credentials logger.info("--> expect failure"); @@ -118,7 +125,7 @@ public void testReloadCredentialsFromAlternativeClient() throws IOException { final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier); final String alternativeClient = randomValueOtherThan("default", ESTestCase::randomIdentifier); - s3Fixture.setAccessKey(accessKey1); + repositoryAccessKey = accessKey1; keystoreSettings.put("s3.client.default.access_key", accessKey1); keystoreSettings.put("s3.client.default.secret_key", randomIdentifier()); keystoreSettings.put("s3.client." + alternativeClient + ".access_key", accessKey2); @@ -133,7 +140,7 @@ public void testReloadCredentialsFromAlternativeClient() throws IOException { // Rotate credentials in blob store logger.info("--> rotate credentials"); - s3Fixture.setAccessKey(accessKey2); + repositoryAccessKey = accessKey2; // Ensure searchable snapshot now does not work due to invalid credentials logger.info("--> expect failure"); @@ -157,7 +164,7 @@ public void testReloadCredentialsFromMetadata() throws IOException { final String accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier); testHarness.putRepository(b -> b.put("access_key", accessKey1).put("secret_key", randomIdentifier())); - s3Fixture.setAccessKey(accessKey1); + repositoryAccessKey = accessKey1; testHarness.createFrozenSearchableSnapshotIndex(); @@ -166,7 +173,7 @@ public void testReloadCredentialsFromMetadata() throws IOException { // Rotate credentials in blob store logger.info("--> rotate credentials"); - s3Fixture.setAccessKey(accessKey2); + repositoryAccessKey = accessKey2; // Ensure searchable snapshot now does not work due to invalid credentials logger.info("--> expect failure"); @@ -269,7 +276,7 @@ void ensureSearchFailure() throws IOException { assertThat( expectThrows(ResponseException.class, () -> client().performRequest(searchRequest)).getMessage(), allOf( - containsString("Bad access key"), + containsString("Access denied"), containsString("Status Code: 403"), containsString("Error Code: AccessDenied"), containsString("failed to read data from cache") From a860d3ab33cf12bba782924c3fd87c586fe887ad Mon Sep 17 00:00:00 2001 From: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:48:35 +0100 Subject: [PATCH 040/129] [DOCS] Trivial: remove tech preview badge (#117461) --- docs/reference/intro.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/intro.asciidoc b/docs/reference/intro.asciidoc index 2908c55789bab..e0100b1c5640b 100644 --- a/docs/reference/intro.asciidoc +++ b/docs/reference/intro.asciidoc @@ -85,7 +85,7 @@ You can deploy {es} in various ways. **Hosted options** * {cloud}/ec-getting-started-trial.html[*Elastic Cloud Hosted*]: {es} is available as part of the hosted Elastic Stack offering, deployed in the cloud with your provider of choice. Sign up for a https://cloud.elastic.co/registration[14-day free trial]. -* {serverless-docs}/general/sign-up-trial[*Elastic Cloud Serverless* (technical preview)]: Create serverless projects for autoscaled and fully managed {es} deployments. Sign up for a https://cloud.elastic.co/serverless-registration[14-day free trial]. +* {serverless-docs}/general/sign-up-trial[*Elastic Cloud Serverless*]: Create serverless projects for autoscaled and fully managed {es} deployments. Sign up for a https://cloud.elastic.co/serverless-registration[14-day free trial]. **Advanced options** From 5b929d7f415094e1e58609e86ff977b46d71c016 Mon Sep 17 00:00:00 2001 From: Tim Grein Date: Tue, 26 Nov 2024 12:01:10 +0100 Subject: [PATCH 041/129] Small wording fix in ESIntegTestCase (#117341) --- .../src/main/java/org/elasticsearch/test/ESIntegTestCase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/framework/src/main/java/org/elasticsearch/test/ESIntegTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/ESIntegTestCase.java index d7c5c598ce978..af92eae8c8a19 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/ESIntegTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/ESIntegTestCase.java @@ -281,7 +281,7 @@ public abstract class ESIntegTestCase extends ESTestCase { /** * Annotation for third-party integration tests. *

- * These are tests the require a third-party service in order to run. They + * These are tests, which require a third-party service in order to run. They * may require the user to manually configure an external process (such as rabbitmq), * or may additionally require some external configuration (e.g. AWS credentials) * via the {@code tests.config} system property. From 5e028220c91af4a37d6a0abcc9d5b9359ba0eaf3 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Tue, 26 Nov 2024 12:06:52 +0100 Subject: [PATCH 042/129] [Docs] Update incremental sync note (#117545) --- docs/reference/connector/docs/connectors-content-syncs.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/connector/docs/connectors-content-syncs.asciidoc b/docs/reference/connector/docs/connectors-content-syncs.asciidoc index f1745382677a2..0a2eb54047170 100644 --- a/docs/reference/connector/docs/connectors-content-syncs.asciidoc +++ b/docs/reference/connector/docs/connectors-content-syncs.asciidoc @@ -52,7 +52,7 @@ However, a fast, accessible third-party data source that stores huge amounts of [NOTE] ==== -Incremental syncs for the SharePoint Online connector use specific logic. +Incremental syncs for <> and <> connectors use specific logic. All other connectors use the same shared connector framework logic for incremental syncs. ==== From 5a749a30d6bed5aaff8f057e6c14f53a75713acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20J=C3=B3zala?= <377355+jozala@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:42:41 +0100 Subject: [PATCH 043/129] Changelog for default container image change to UBI (#117482) The image has been changed in #116739 --- docs/changelog/116739.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/116739.yaml diff --git a/docs/changelog/116739.yaml b/docs/changelog/116739.yaml new file mode 100644 index 0000000000000..ea3b1253a9008 --- /dev/null +++ b/docs/changelog/116739.yaml @@ -0,0 +1,5 @@ +pr: 116739 +summary: Change default Docker image to be based on UBI minimal instead of Ubuntu +area: Infra/Core +type: enhancement +issues: [] From d7797eed31237104a369b54b16d3dcf56fe56fbc Mon Sep 17 00:00:00 2001 From: Ievgen Degtiarenko Date: Tue, 26 Nov 2024 12:50:47 +0100 Subject: [PATCH 044/129] Add a way to log hot threads in plain text (#111053) This adds a way to log current threads in plain text to logs. This way we do not need to decode them and can search by stack trace in logs (for example to know if the issue is recurring). Please note, this produces a multi-line log entry. --- .../action/admin/HotThreadsIT.java | 23 +++++++ .../elasticsearch/monitor/jvm/HotThreads.java | 60 ++++++++++++++----- .../monitor/jvm/HotThreadsTests.java | 2 +- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/server/src/internalClusterTest/java/org/elasticsearch/action/admin/HotThreadsIT.java b/server/src/internalClusterTest/java/org/elasticsearch/action/admin/HotThreadsIT.java index 8c80cee58f46c..76a6717ab1d09 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/action/admin/HotThreadsIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/action/admin/HotThreadsIT.java @@ -22,6 +22,7 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.monitor.jvm.HotThreads; import org.elasticsearch.test.ESIntegTestCase; +import org.elasticsearch.test.MockLog; import org.elasticsearch.test.junit.annotations.TestLogging; import org.hamcrest.Matcher; @@ -31,6 +32,7 @@ import static org.elasticsearch.index.query.QueryBuilders.boolQuery; import static org.elasticsearch.index.query.QueryBuilders.matchAllQuery; import static org.elasticsearch.index.query.QueryBuilders.termQuery; +import static org.elasticsearch.test.MockLog.assertThatLogger; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; import static org.hamcrest.CoreMatchers.equalTo; @@ -211,4 +213,25 @@ public void testLogLocalHotThreads() { ) ); } + + @TestLogging(reason = "testing logging at various levels", value = "org.elasticsearch.action.admin.HotThreadsIT:TRACE") + public void testLogLocalCurrentThreadsInPlainText() { + final var level = randomFrom(Level.TRACE, Level.DEBUG, Level.INFO, Level.WARN, Level.ERROR); + assertThatLogger( + () -> HotThreads.logLocalCurrentThreads(logger, level, getTestName()), + HotThreadsIT.class, + new MockLog.SeenEventExpectation( + "Should log hot threads header in plain text", + HotThreadsIT.class.getCanonicalName(), + level, + "testLogLocalCurrentThreadsInPlainText: Hot threads at" + ), + new MockLog.SeenEventExpectation( + "Should log hot threads cpu usage in plain text", + HotThreadsIT.class.getCanonicalName(), + level, + "cpu usage by thread" + ) + ); + } } diff --git a/server/src/main/java/org/elasticsearch/monitor/jvm/HotThreads.java b/server/src/main/java/org/elasticsearch/monitor/jvm/HotThreads.java index b14ef171ccd1d..8c903fdc634d3 100644 --- a/server/src/main/java/org/elasticsearch/monitor/jvm/HotThreads.java +++ b/server/src/main/java/org/elasticsearch/monitor/jvm/HotThreads.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.io.OutputStreamWriter; +import java.io.StringWriter; import java.io.Writer; import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; @@ -105,6 +106,33 @@ public static void logLocalHotThreads(Logger logger, Level level, String prefix, } } + /** + * Capture and log the current threads on the local node. Unlike hot threads this does not sample and captures current state only. + * Useful for capturing stack traces for unexpectedly-slow operations in production. The resulting message might be large, so it is + * split per thread and logged as multiple entries. + * + * @param logger The logger to use for the logging + * @param level The log level to use for the logging. + * @param prefix The prefix to emit on each chunk of the logging. + */ + public static void logLocalCurrentThreads(Logger logger, Level level, String prefix) { + if (logger.isEnabled(level) == false) { + return; + } + + try (var writer = new StringWriter()) { + new HotThreads().busiestThreads(500).threadElementsSnapshotCount(1).detect(writer, () -> { + logger.log(level, "{}: {}", prefix, writer.toString()); + writer.getBuffer().setLength(0); + }); + } catch (Exception e) { + logger.error( + () -> org.elasticsearch.common.Strings.format("failed to write local current threads with prefix [%s]", prefix), + e + ); + } + } + public enum ReportType { CPU("cpu"), @@ -192,11 +220,12 @@ public HotThreads sortOrder(SortOrder order) { } public void detect(Writer writer) throws Exception { + detect(writer, () -> {}); + } + + public void detect(Writer writer, Runnable onNextThread) throws Exception { synchronized (mutex) { - innerDetect(ManagementFactory.getThreadMXBean(), SunThreadInfo.INSTANCE, Thread.currentThread().getId(), (interval) -> { - Thread.sleep(interval); - return null; - }, writer); + innerDetect(ManagementFactory.getThreadMXBean(), SunThreadInfo.INSTANCE, Thread.currentThread().getId(), writer, onNextThread); } } @@ -245,13 +274,15 @@ Map getAllValidThreadInfos(ThreadMXBean threadBean, ThreadInfo[][] captureThreadStacks(ThreadMXBean threadBean, long[] threadIds) throws InterruptedException { ThreadInfo[][] result = new ThreadInfo[threadElementsSnapshotCount][]; - for (int j = 0; j < threadElementsSnapshotCount; j++) { - // NOTE, javadoc of getThreadInfo says: If a thread of the given ID is not alive or does not exist, - // null will be set in the corresponding element in the returned array. A thread is alive if it has - // been started and has not yet died. + + // NOTE, javadoc of getThreadInfo says: If a thread of the given ID is not alive or does not exist, + // null will be set in the corresponding element in the returned array. A thread is alive if it has + // been started and has not yet died. + for (int j = 0; j < threadElementsSnapshotCount - 1; j++) { result[j] = threadBean.getThreadInfo(threadIds, Integer.MAX_VALUE); Thread.sleep(threadElementsSnapshotDelay.millis()); } + result[threadElementsSnapshotCount - 1] = threadBean.getThreadInfo(threadIds, Integer.MAX_VALUE); return result; } @@ -267,13 +298,8 @@ private double getTimeSharePercentage(long time) { return (((double) time) / interval.nanos()) * 100; } - void innerDetect( - ThreadMXBean threadBean, - SunThreadInfo sunThreadInfo, - long currentThreadId, - SleepFunction threadSleep, - Writer writer - ) throws Exception { + void innerDetect(ThreadMXBean threadBean, SunThreadInfo sunThreadInfo, long currentThreadId, Writer writer, Runnable onNextThread) + throws Exception { if (threadBean.isThreadCpuTimeSupported() == false) { throw new ElasticsearchException("thread CPU time is not supported on this JDK"); } @@ -297,10 +323,11 @@ void innerDetect( .append(", ignoreIdleThreads=") .append(Boolean.toString(ignoreIdleThreads)) .append(":\n"); + onNextThread.run(); // Capture before and after thread state with timings Map previousThreadInfos = getAllValidThreadInfos(threadBean, sunThreadInfo, currentThreadId); - threadSleep.apply(interval.millis()); + Thread.sleep(interval.millis()); Map latestThreadInfos = getAllValidThreadInfos(threadBean, sunThreadInfo, currentThreadId); latestThreadInfos.forEach((threadId, accumulator) -> accumulator.subtractPrevious(previousThreadInfos.get(threadId))); @@ -430,6 +457,7 @@ void innerDetect( } } } + onNextThread.run(); } } diff --git a/server/src/test/java/org/elasticsearch/monitor/jvm/HotThreadsTests.java b/server/src/test/java/org/elasticsearch/monitor/jvm/HotThreadsTests.java index 93c40185f62ac..37eb69c0ca409 100644 --- a/server/src/test/java/org/elasticsearch/monitor/jvm/HotThreadsTests.java +++ b/server/src/test/java/org/elasticsearch/monitor/jvm/HotThreadsTests.java @@ -947,7 +947,7 @@ private static String innerDetect( long currentThreadId ) throws Exception { try (var writer = new StringWriter()) { - hotThreads.innerDetect(mockedMthreadMXBeanBean, sunThreadInfo, currentThreadId, (interval) -> null, writer); + hotThreads.innerDetect(mockedMthreadMXBeanBean, sunThreadInfo, currentThreadId, writer, () -> {}); return writer.toString(); } } From a245e709ba5a94ad7a476a84d43f0b04bd361fc4 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Tue, 26 Nov 2024 23:02:11 +1100 Subject: [PATCH 045/129] Mute org.elasticsearch.xpack.esql.qa.mixed.FieldExtractorIT testConstantKeywordField #117531 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 37f36e9a19340..b3c34505e6561 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -240,6 +240,9 @@ tests: 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 # Examples: # From 5e16bc3fa615d76a5f188e0b722691da2981e633 Mon Sep 17 00:00:00 2001 From: Alexey Ivanov Date: Tue, 26 Nov 2024 12:49:33 +0000 Subject: [PATCH 046/129] [CI] FileSettingsServiceIT testErrorCanRecoverOnRestart failing (#116895) (#117511) Fixes flaky test FileSettingsServiceIT.testErrorCanRecoverOnRestart Fixes #116895 --- .../reservedstate/service/FileSettingsServiceIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/internalClusterTest/java/org/elasticsearch/reservedstate/service/FileSettingsServiceIT.java b/server/src/internalClusterTest/java/org/elasticsearch/reservedstate/service/FileSettingsServiceIT.java index 90326abb381d0..85f0e2cf7e3ff 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/reservedstate/service/FileSettingsServiceIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/reservedstate/service/FileSettingsServiceIT.java @@ -398,7 +398,7 @@ public void testErrorCanRecoverOnRestart() throws Exception { FileSettingsService masterFileSettingsService = internalCluster().getInstance(FileSettingsService.class, masterNode); - assertTrue(masterFileSettingsService.watching()); + assertBusy(() -> assertTrue(masterFileSettingsService.watching())); assertFalse(dataFileSettingsService.watching()); writeJSONFile(masterNode, testErrorJSON, logger, versionCounter.incrementAndGet()); @@ -434,7 +434,7 @@ public void testNewErrorOnRestartReprocessing() throws Exception { FileSettingsService masterFileSettingsService = internalCluster().getInstance(FileSettingsService.class, masterNode); - assertTrue(masterFileSettingsService.watching()); + assertBusy(() -> assertTrue(masterFileSettingsService.watching())); assertFalse(dataFileSettingsService.watching()); writeJSONFile(masterNode, testErrorJSON, logger, versionCounter.incrementAndGet()); From 1495c550ad05af55acec47ca1445b5faeb86d4e8 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Wed, 27 Nov 2024 00:54:46 +1100 Subject: [PATCH 047/129] Mute org.elasticsearch.backwards.MixedClusterClientYamlTestSuiteIT test {p0=synonyms/90_synonyms_reloading_for_synset/Reload analyzers for specific synonym set} #116777 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index b3c34505e6561..49898308e411b 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -243,6 +243,9 @@ tests: - 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 # Examples: # From 2bc1b4f6062c33a259b4aa0df9a7118bbfc4dc2e Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 26 Nov 2024 13:58:54 +0000 Subject: [PATCH 048/129] Make `PutStoredScriptRequest` immutable (#117556) No need for this request to be mutable, we always know all the values at creation time. Also adjusts the `toString()` impl to use the `source` field, since this is the only spot that we use the `content` so with this change we can follow up with a 9.x-only change to remove it. --- .../script/mustache/SearchTemplateIT.java | 11 +-- .../elasticsearch/script/StoredScriptsIT.java | 26 ++----- .../storedscripts/PutStoredScriptRequest.java | 78 ++++++------------- .../PutStoredScriptRequestTests.java | 12 ++- .../StoredScriptIntegTestUtils.java | 22 ++++-- .../integration/DlsFlsRequestCacheTests.java | 17 +--- 6 files changed, 60 insertions(+), 106 deletions(-) diff --git a/modules/lang-mustache/src/internalClusterTest/java/org/elasticsearch/script/mustache/SearchTemplateIT.java b/modules/lang-mustache/src/internalClusterTest/java/org/elasticsearch/script/mustache/SearchTemplateIT.java index defd20b64762b..cc0b0122e9cce 100644 --- a/modules/lang-mustache/src/internalClusterTest/java/org/elasticsearch/script/mustache/SearchTemplateIT.java +++ b/modules/lang-mustache/src/internalClusterTest/java/org/elasticsearch/script/mustache/SearchTemplateIT.java @@ -13,12 +13,10 @@ import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptAction; import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptResponse; -import org.elasticsearch.action.admin.cluster.storedscripts.PutStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.TransportDeleteStoredScriptAction; import org.elasticsearch.action.admin.cluster.storedscripts.TransportPutStoredScriptAction; import org.elasticsearch.action.bulk.BulkRequestBuilder; import org.elasticsearch.action.search.SearchRequest; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.script.ScriptType; @@ -39,6 +37,7 @@ import java.util.Map; import java.util.concurrent.ExecutionException; +import static org.elasticsearch.action.admin.cluster.storedscripts.StoredScriptIntegTestUtils.newPutStoredScriptTestRequest; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; @@ -467,12 +466,6 @@ public static void assertHitCount(SearchTemplateRequestBuilder requestBuilder, l } private void putJsonStoredScript(String id, String jsonContent) { - assertAcked( - safeExecute( - TransportPutStoredScriptAction.TYPE, - new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT).id(id) - .content(new BytesArray(jsonContent), XContentType.JSON) - ) - ); + assertAcked(safeExecute(TransportPutStoredScriptAction.TYPE, newPutStoredScriptTestRequest(id, jsonContent))); } } diff --git a/server/src/internalClusterTest/java/org/elasticsearch/script/StoredScriptsIT.java b/server/src/internalClusterTest/java/org/elasticsearch/script/StoredScriptsIT.java index e9efab5934e52..76ea5b99a2a6b 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/script/StoredScriptsIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/script/StoredScriptsIT.java @@ -11,16 +11,13 @@ import org.elasticsearch.action.admin.cluster.storedscripts.DeleteStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptAction; import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptRequest; -import org.elasticsearch.action.admin.cluster.storedscripts.PutStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.TransportDeleteStoredScriptAction; import org.elasticsearch.action.admin.cluster.storedscripts.TransportPutStoredScriptAction; import org.elasticsearch.action.support.master.AcknowledgedResponse; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Strings; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.test.ESIntegTestCase; -import org.elasticsearch.xcontent.XContentType; import java.util.Arrays; import java.util.Collection; @@ -28,6 +25,7 @@ import java.util.Map; import java.util.function.Function; +import static org.elasticsearch.action.admin.cluster.storedscripts.StoredScriptIntegTestUtils.newPutStoredScriptTestRequest; import static org.elasticsearch.action.admin.cluster.storedscripts.StoredScriptIntegTestUtils.putJsonStoredScript; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; @@ -73,14 +71,9 @@ public void testBasics() { safeAwaitAndUnwrapFailure( IllegalArgumentException.class, AcknowledgedResponse.class, - l -> client().execute( - TransportPutStoredScriptAction.TYPE, - new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT).id("id#") - .content(new BytesArray(Strings.format(""" - {"script": {"lang": "%s", "source": "1"} } - """, LANG)), XContentType.JSON), - l - ) + l -> client().execute(TransportPutStoredScriptAction.TYPE, newPutStoredScriptTestRequest("id#", Strings.format(""" + {"script": {"lang": "%s", "source": "1"} } + """, LANG)), l) ).getMessage() ); } @@ -91,14 +84,9 @@ public void testMaxScriptSize() { safeAwaitAndUnwrapFailure( IllegalArgumentException.class, AcknowledgedResponse.class, - l -> client().execute( - TransportPutStoredScriptAction.TYPE, - new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT).id("foobar") - .content(new BytesArray(Strings.format(""" - {"script": { "lang": "%s", "source":"0123456789abcdef"} }\ - """, LANG)), XContentType.JSON), - l - ) + l -> client().execute(TransportPutStoredScriptAction.TYPE, newPutStoredScriptTestRequest("foobar", Strings.format(""" + {"script": { "lang": "%s", "source":"0123456789abcdef"} }\ + """, LANG)), l) ).getMessage() ); } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java index 35e46d3f2a4da..8e453cd5bac3a 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java @@ -11,10 +11,12 @@ import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.support.master.AcknowledgedRequest; +import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.script.StoredScriptSource; import org.elasticsearch.xcontent.ToXContentFragment; @@ -28,11 +30,15 @@ public class PutStoredScriptRequest extends AcknowledgedRequest implements ToXContentFragment { - private String id; - private String context; - private BytesReference content; - private XContentType xContentType; - private StoredScriptSource source; + @Nullable + private final String id; + + @Nullable + private final String context; + + private final BytesReference content; + private final XContentType xContentType; + private final StoredScriptSource source; public PutStoredScriptRequest(StreamInput in) throws IOException { super(in); @@ -43,15 +49,11 @@ public PutStoredScriptRequest(StreamInput in) throws IOException { source = new StoredScriptSource(in); } - public PutStoredScriptRequest(TimeValue masterNodeTimeout, TimeValue ackTimeout) { - super(masterNodeTimeout, ackTimeout); - } - public PutStoredScriptRequest( TimeValue masterNodeTimeout, TimeValue ackTimeout, - String id, - String context, + @Nullable String id, + @Nullable String context, BytesReference content, XContentType xContentType, StoredScriptSource source @@ -59,9 +61,9 @@ public PutStoredScriptRequest( super(masterNodeTimeout, ackTimeout); this.id = id; this.context = context; - this.content = content; + this.content = Objects.requireNonNull(content); this.xContentType = Objects.requireNonNull(xContentType); - this.source = source; + this.source = Objects.requireNonNull(source); } @Override @@ -74,10 +76,6 @@ public ActionRequestValidationException validate() { validationException = addValidationError("id cannot contain '#' for stored script", validationException); } - if (content == null) { - validationException = addValidationError("must specify code for stored script", validationException); - } - return validationException; } @@ -85,20 +83,10 @@ public String id() { return id; } - public PutStoredScriptRequest id(String id) { - this.id = id; - return this; - } - public String context() { return context; } - public PutStoredScriptRequest context(String context) { - this.context = context; - return this; - } - public BytesReference content() { return content; } @@ -111,16 +99,6 @@ public StoredScriptSource source() { return source; } - /** - * Set the script source and the content type of the bytes. - */ - public PutStoredScriptRequest content(BytesReference content, XContentType xContentType) { - this.content = content; - this.xContentType = Objects.requireNonNull(xContentType); - this.source = StoredScriptSource.parse(content, xContentType); - return this; - } - @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); @@ -133,28 +111,16 @@ public void writeTo(StreamOutput out) throws IOException { @Override public String toString() { - String source = "_na_"; - - try { - source = XContentHelper.convertToJson(content, false, xContentType); - } catch (Exception e) { - // ignore - } - - return "put stored script {id [" - + id - + "]" - + (context != null ? ", context [" + context + "]" : "") - + ", content [" - + source - + "]}"; + return Strings.format( + "put stored script {id [%s]%s, content [%s]}", + id, + context != null ? ", context [" + context + "]" : "", + source + ); } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - builder.field("script"); - source.toXContent(builder, params); - - return builder; + return builder.field("script", source, params); } } diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequestTests.java index ffdd588764699..023e7693f8a47 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequestTests.java @@ -57,9 +57,15 @@ public void testToXContent() throws IOException { BytesReference expectedRequestBody = BytesReference.bytes(builder); - PutStoredScriptRequest request = new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT); - request.id("test1"); - request.content(expectedRequestBody, xContentType); + PutStoredScriptRequest request = new PutStoredScriptRequest( + TEST_REQUEST_TIMEOUT, + TEST_REQUEST_TIMEOUT, + "test1", + null, + expectedRequestBody, + xContentType, + StoredScriptSource.parse(expectedRequestBody, xContentType) + ); XContentBuilder requestBuilder = XContentBuilder.builder(xContentType.xContent()); requestBuilder.startObject(); diff --git a/test/framework/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/StoredScriptIntegTestUtils.java b/test/framework/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/StoredScriptIntegTestUtils.java index 5f979d75ec382..0a090af431dae 100644 --- a/test/framework/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/StoredScriptIntegTestUtils.java +++ b/test/framework/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/StoredScriptIntegTestUtils.java @@ -11,6 +11,7 @@ import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.script.StoredScriptSource; import org.elasticsearch.test.ESIntegTestCase; import org.elasticsearch.xcontent.XContentType; @@ -25,11 +26,22 @@ public static void putJsonStoredScript(String id, String jsonContent) { } public static void putJsonStoredScript(String id, BytesReference jsonContent) { - assertAcked( - ESIntegTestCase.safeExecute( - TransportPutStoredScriptAction.TYPE, - new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT).id(id).content(jsonContent, XContentType.JSON) - ) + assertAcked(ESIntegTestCase.safeExecute(TransportPutStoredScriptAction.TYPE, newPutStoredScriptTestRequest(id, jsonContent))); + } + + public static PutStoredScriptRequest newPutStoredScriptTestRequest(String id, String jsonContent) { + return newPutStoredScriptTestRequest(id, new BytesArray(jsonContent)); + } + + public static PutStoredScriptRequest newPutStoredScriptTestRequest(String id, BytesReference jsonContent) { + return new PutStoredScriptRequest( + TEST_REQUEST_TIMEOUT, + TEST_REQUEST_TIMEOUT, + id, + null, + jsonContent, + XContentType.JSON, + StoredScriptSource.parse(jsonContent, XContentType.JSON) ); } } diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DlsFlsRequestCacheTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DlsFlsRequestCacheTests.java index a5f827c2a4b53..82a10f21debfb 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DlsFlsRequestCacheTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DlsFlsRequestCacheTests.java @@ -8,13 +8,11 @@ package org.elasticsearch.integration; import org.elasticsearch.ElasticsearchSecurityException; -import org.elasticsearch.action.admin.cluster.storedscripts.PutStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.TransportPutStoredScriptAction; import org.elasticsearch.action.admin.indices.alias.Alias; import org.elasticsearch.action.search.SearchRequestBuilder; import org.elasticsearch.action.support.broadcast.BroadcastResponse; import org.elasticsearch.client.internal.Client; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Strings; @@ -24,7 +22,6 @@ import org.elasticsearch.search.SearchHit; import org.elasticsearch.test.SecuritySingleNodeTestCase; import org.elasticsearch.test.hamcrest.ElasticsearchAssertions; -import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest; @@ -43,6 +40,7 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import static org.elasticsearch.action.admin.cluster.storedscripts.StoredScriptIntegTestUtils.newPutStoredScriptTestRequest; import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.IMMEDIATE; import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.NONE; import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.WAIT_UNTIL; @@ -350,17 +348,8 @@ public void testRequestCacheWithTemplateRoleQuery() { private void prepareIndices() { final Client client = client(); - assertAcked( - safeExecute( - TransportPutStoredScriptAction.TYPE, - new PutStoredScriptRequest(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT).id("my-script") - .content( - new BytesArray(""" - {"script":{"source":"{\\"match\\":{\\"username\\":\\"{{_user.username}}\\"}}","lang":"mustache"}}"""), - XContentType.JSON - ) - ) - ); + assertAcked(safeExecute(TransportPutStoredScriptAction.TYPE, newPutStoredScriptTestRequest("my-script", """ + {"script":{"source":"{\\"match\\":{\\"username\\":\\"{{_user.username}}\\"}}","lang":"mustache"}}"""))); assertAcked(indicesAdmin().prepareCreate(DLS_INDEX).addAlias(new Alias("dls-alias")).get()); client.prepareIndex(DLS_INDEX).setId("101").setSource("number", 101, "letter", "A").get(); From e9f899ee6913fe00dc8ef7a4254c76e8dca31b47 Mon Sep 17 00:00:00 2001 From: Pooya Salehi Date: Tue, 26 Nov 2024 16:44:15 +0100 Subject: [PATCH 049/129] Add current node weight as an APM metric (#117557) As discussed previously, the current node weight (calculated the same way that we calculate for the desired balance computations) might also be useful to have as a metric. The difference is that the current node weight is calculated based on the current cluster state rather than the internal state of the BalancedShardsAllocator (i.e. Balancer and ModelNode). To share all the weight calculation logic I had to move out the weight function and a few related utilities. NodeAllocationStatsProvider is still shared by both the AllocationStatsService and the desired balance metric collection. Relates ES-10080 --- .../DesiredBalanceReconcilerMetricsIT.java | 10 ++ .../elasticsearch/cluster/ClusterModule.java | 2 +- .../allocation/AllocationStatsService.java | 23 ++- .../NodeAllocationStatsProvider.java | 61 ++++++- .../allocator/BalancedShardsAllocator.java | 136 ++------------- .../allocation/allocator/DesiredBalance.java | 2 +- .../allocator/DesiredBalanceMetrics.java | 26 ++- .../allocator/DesiredBalanceReconciler.java | 11 +- .../allocation/allocator/WeightFunction.java | 157 ++++++++++++++++++ .../AllocationStatsServiceTests.java | 6 +- .../BalancedShardsAllocatorTests.java | 2 +- .../cluster/ESAllocationTestCase.java | 10 +- 12 files changed, 297 insertions(+), 149 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/WeightFunction.java diff --git a/server/src/internalClusterTest/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconcilerMetricsIT.java b/server/src/internalClusterTest/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconcilerMetricsIT.java index b3ec4a5331180..355427c4e059b 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconcilerMetricsIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconcilerMetricsIT.java @@ -117,6 +117,15 @@ public void testDesiredBalanceMetrics() { assertThat((String) nodeStat.attributes().get("node_id"), is(in(nodeIds))); assertThat((String) nodeStat.attributes().get("node_name"), is(in(nodeNames))); } + final var currentNodeWeightsMetrics = telemetryPlugin.getDoubleGaugeMeasurement( + DesiredBalanceMetrics.CURRENT_NODE_WEIGHT_METRIC_NAME + ); + assertThat(currentNodeWeightsMetrics.size(), equalTo(2)); + for (var nodeStat : currentNodeWeightsMetrics) { + assertTrue(nodeStat.isDouble()); + assertThat((String) nodeStat.attributes().get("node_id"), is(in(nodeIds))); + assertThat((String) nodeStat.attributes().get("node_name"), is(in(nodeNames))); + } final var currentNodeShardCountMetrics = telemetryPlugin.getLongGaugeMeasurement( DesiredBalanceMetrics.CURRENT_NODE_SHARD_COUNT_METRIC_NAME ); @@ -196,6 +205,7 @@ private static void assertMetricsAreBeingPublished(String nodeName, boolean shou testTelemetryPlugin.getLongGaugeMeasurement(DesiredBalanceMetrics.DESIRED_BALANCE_NODE_SHARD_COUNT_METRIC_NAME), matcher ); + assertThat(testTelemetryPlugin.getDoubleGaugeMeasurement(DesiredBalanceMetrics.CURRENT_NODE_WEIGHT_METRIC_NAME), matcher); assertThat(testTelemetryPlugin.getDoubleGaugeMeasurement(DesiredBalanceMetrics.CURRENT_NODE_WRITE_LOAD_METRIC_NAME), matcher); assertThat(testTelemetryPlugin.getLongGaugeMeasurement(DesiredBalanceMetrics.CURRENT_NODE_DISK_USAGE_METRIC_NAME), matcher); assertThat(testTelemetryPlugin.getLongGaugeMeasurement(DesiredBalanceMetrics.CURRENT_NODE_SHARD_COUNT_METRIC_NAME), matcher); diff --git a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java index 046f4b6b0b251..c2da33f8f4135 100644 --- a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java +++ b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java @@ -139,7 +139,7 @@ public ClusterModule( this.clusterPlugins = clusterPlugins; this.deciderList = createAllocationDeciders(settings, clusterService.getClusterSettings(), clusterPlugins); this.allocationDeciders = new AllocationDeciders(deciderList); - var nodeAllocationStatsProvider = new NodeAllocationStatsProvider(writeLoadForecaster); + var nodeAllocationStatsProvider = new NodeAllocationStatsProvider(writeLoadForecaster, clusterService.getClusterSettings()); this.shardsAllocator = createShardsAllocator( settings, clusterService.getClusterSettings(), diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsService.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsService.java index 0c82faaaeaa45..b98e9050d2b4a 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsService.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsService.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.function.Supplier; +import java.util.stream.Collectors; public class AllocationStatsService { private final ClusterService clusterService; @@ -39,6 +40,26 @@ public AllocationStatsService( } public Map stats() { - return nodeAllocationStatsProvider.stats(clusterService.state(), clusterInfoService.getClusterInfo(), desiredBalanceSupplier.get()); + var state = clusterService.state(); + var stats = nodeAllocationStatsProvider.stats( + state.metadata(), + state.getRoutingNodes(), + clusterInfoService.getClusterInfo(), + desiredBalanceSupplier.get() + ); + return stats.entrySet() + .stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> new NodeAllocationStats( + e.getValue().shards(), + e.getValue().undesiredShards(), + e.getValue().forecastedIngestLoad(), + e.getValue().forecastedDiskUsage(), + e.getValue().currentDiskUsage() + ) + ) + ); } } diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsProvider.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsProvider.java index 157b409be14d3..8368f5916ef91 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsProvider.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsProvider.java @@ -10,11 +10,15 @@ package org.elasticsearch.cluster.routing.allocation; import org.elasticsearch.cluster.ClusterInfo; -import org.elasticsearch.cluster.ClusterState; import org.elasticsearch.cluster.metadata.IndexMetadata; +import org.elasticsearch.cluster.metadata.Metadata; import org.elasticsearch.cluster.routing.RoutingNode; +import org.elasticsearch.cluster.routing.RoutingNodes; import org.elasticsearch.cluster.routing.ShardRouting; +import org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator; import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalance; +import org.elasticsearch.cluster.routing.allocation.allocator.WeightFunction; +import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.util.Maps; import org.elasticsearch.core.Nullable; @@ -23,17 +27,47 @@ public class NodeAllocationStatsProvider { private final WriteLoadForecaster writeLoadForecaster; - public NodeAllocationStatsProvider(WriteLoadForecaster writeLoadForecaster) { + private volatile float indexBalanceFactor; + private volatile float shardBalanceFactor; + private volatile float writeLoadBalanceFactor; + private volatile float diskUsageBalanceFactor; + + public record NodeAllocationAndClusterBalanceStats( + int shards, + int undesiredShards, + double forecastedIngestLoad, + long forecastedDiskUsage, + long currentDiskUsage, + float currentNodeWeight + ) {} + + public NodeAllocationStatsProvider(WriteLoadForecaster writeLoadForecaster, ClusterSettings clusterSettings) { this.writeLoadForecaster = writeLoadForecaster; + clusterSettings.initializeAndWatch(BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING, value -> this.shardBalanceFactor = value); + clusterSettings.initializeAndWatch(BalancedShardsAllocator.INDEX_BALANCE_FACTOR_SETTING, value -> this.indexBalanceFactor = value); + clusterSettings.initializeAndWatch( + BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING, + value -> this.writeLoadBalanceFactor = value + ); + clusterSettings.initializeAndWatch( + BalancedShardsAllocator.DISK_USAGE_BALANCE_FACTOR_SETTING, + value -> this.diskUsageBalanceFactor = value + ); } - public Map stats( - ClusterState clusterState, + public Map stats( + Metadata metadata, + RoutingNodes routingNodes, ClusterInfo clusterInfo, @Nullable DesiredBalance desiredBalance ) { - var stats = Maps.newMapWithExpectedSize(clusterState.getRoutingNodes().size()); - for (RoutingNode node : clusterState.getRoutingNodes()) { + var weightFunction = new WeightFunction(shardBalanceFactor, indexBalanceFactor, writeLoadBalanceFactor, diskUsageBalanceFactor); + var avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes); + var avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes); + var avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(clusterInfo, metadata, routingNodes); + + var stats = Maps.newMapWithExpectedSize(routingNodes.size()); + for (RoutingNode node : routingNodes) { int shards = 0; int undesiredShards = 0; double forecastedWriteLoad = 0.0; @@ -44,7 +78,7 @@ public Map stats( continue; } shards++; - IndexMetadata indexMetadata = clusterState.metadata().getIndexSafe(shardRouting.index()); + IndexMetadata indexMetadata = metadata.getIndexSafe(shardRouting.index()); if (isDesiredAllocation(desiredBalance, shardRouting) == false) { undesiredShards++; } @@ -54,14 +88,23 @@ public Map stats( currentDiskUsage += shardSize; } + float currentNodeWeight = weightFunction.nodeWeight( + shards, + avgShardsPerNode, + forecastedWriteLoad, + avgWriteLoadPerNode, + currentDiskUsage, + avgDiskUsageInBytesPerNode + ); stats.put( node.nodeId(), - new NodeAllocationStats( + new NodeAllocationAndClusterBalanceStats( shards, desiredBalance != null ? undesiredShards : -1, forecastedWriteLoad, forecastedDiskUsage, - currentDiskUsage + currentDiskUsage, + currentNodeWeight ) ); } diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java index 5b8fb0c7e9203..8dd1f14564ce9 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java @@ -168,14 +168,17 @@ private void collectAndRecordNodeWeightStats(Balancer balancer, WeightFunction w Map nodeLevelWeights = new HashMap<>(); for (var entry : balancer.nodes.entrySet()) { var node = entry.getValue(); + var nodeWeight = weightFunction.nodeWeight( + node.numShards(), + balancer.avgShardsPerNode(), + node.writeLoad(), + balancer.avgWriteLoadPerNode(), + node.diskUsageInBytes(), + balancer.avgDiskUsageInBytesPerNode() + ); nodeLevelWeights.put( node.routingNode.node(), - new DesiredBalanceMetrics.NodeWeightStats( - node.numShards(), - node.diskUsageInBytes(), - node.writeLoad(), - weightFunction.nodeWeight(balancer, node) - ) + new DesiredBalanceMetrics.NodeWeightStats(node.numShards(), node.diskUsageInBytes(), node.writeLoad(), nodeWeight) ); } allocation.routingNodes().setBalanceWeightStatsPerNode(nodeLevelWeights); @@ -252,65 +255,6 @@ public float getShardBalance() { return shardBalanceFactor; } - /** - * This class is the primary weight function used to create balanced over nodes and shards in the cluster. - * Currently this function has 3 properties: - *

    - *
  • index balance - balance property over shards per index
  • - *
  • shard balance - balance property over shards per cluster
  • - *
- *

- * Each of these properties are expressed as factor such that the properties factor defines the relative - * importance of the property for the weight function. For example if the weight function should calculate - * the weights only based on a global (shard) balance the index balance can be set to {@code 0.0} and will - * in turn have no effect on the distribution. - *

- * The weight per index is calculated based on the following formula: - *
    - *
  • - * weightindex(node, index) = indexBalance * (node.numShards(index) - avgShardsPerNode(index)) - *
  • - *
  • - * weightnode(node, index) = shardBalance * (node.numShards() - avgShardsPerNode) - *
  • - *
- * weight(node, index) = weightindex(node, index) + weightnode(node, index) - */ - private static class WeightFunction { - - private final float theta0; - private final float theta1; - private final float theta2; - private final float theta3; - - WeightFunction(float shardBalance, float indexBalance, float writeLoadBalance, float diskUsageBalance) { - float sum = shardBalance + indexBalance + writeLoadBalance + diskUsageBalance; - if (sum <= 0.0f) { - throw new IllegalArgumentException("Balance factors must sum to a value > 0 but was: " + sum); - } - theta0 = shardBalance / sum; - theta1 = indexBalance / sum; - theta2 = writeLoadBalance / sum; - theta3 = diskUsageBalance / sum; - } - - float weight(Balancer balancer, ModelNode node, String index) { - final float weightIndex = node.numShards(index) - balancer.avgShardsPerNode(index); - return nodeWeight(balancer, node) + theta1 * weightIndex; - } - - float nodeWeight(Balancer balancer, ModelNode node) { - final float weightShard = node.numShards() - balancer.avgShardsPerNode(); - final float ingestLoad = (float) (node.writeLoad() - balancer.avgWriteLoadPerNode()); - final float diskUsage = (float) (node.diskUsageInBytes() - balancer.avgDiskUsageInBytesPerNode()); - return theta0 * weightShard + theta2 * ingestLoad + theta3 * diskUsage; - } - - float minWeightDelta(Balancer balancer, String index) { - return theta0 * 1 + theta1 * 1 + theta2 * balancer.getShardWriteLoad(index) + theta3 * balancer.maxShardSizeBytes(index); - } - } - /** * A {@link Balancer} */ @@ -335,63 +279,13 @@ private Balancer(WriteLoadForecaster writeLoadForecaster, RoutingAllocation allo this.metadata = allocation.metadata(); this.weight = weight; this.threshold = threshold; - avgShardsPerNode = ((float) metadata.getTotalNumberOfShards()) / routingNodes.size(); - avgWriteLoadPerNode = getTotalWriteLoad(writeLoadForecaster, metadata) / routingNodes.size(); - avgDiskUsageInBytesPerNode = ((double) getTotalDiskUsageInBytes(allocation.clusterInfo(), metadata) / routingNodes.size()); + avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes); + avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes); + avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes); nodes = Collections.unmodifiableMap(buildModelFromAssigned()); sorter = newNodeSorter(); } - private static double getTotalWriteLoad(WriteLoadForecaster writeLoadForecaster, Metadata metadata) { - double writeLoad = 0.0; - for (IndexMetadata indexMetadata : metadata.indices().values()) { - writeLoad += getIndexWriteLoad(writeLoadForecaster, indexMetadata); - } - return writeLoad; - } - - private static double getIndexWriteLoad(WriteLoadForecaster writeLoadForecaster, IndexMetadata indexMetadata) { - var shardWriteLoad = writeLoadForecaster.getForecastedWriteLoad(indexMetadata).orElse(0.0); - return shardWriteLoad * numberOfCopies(indexMetadata); - } - - private static long getTotalDiskUsageInBytes(ClusterInfo clusterInfo, Metadata metadata) { - long totalDiskUsageInBytes = 0; - for (IndexMetadata indexMetadata : metadata.indices().values()) { - totalDiskUsageInBytes += getIndexDiskUsageInBytes(clusterInfo, indexMetadata); - } - return totalDiskUsageInBytes; - } - - // Visible for testing - static long getIndexDiskUsageInBytes(ClusterInfo clusterInfo, IndexMetadata indexMetadata) { - if (indexMetadata.ignoreDiskWatermarks()) { - // disk watermarks are ignored for partial searchable snapshots - // and is equivalent to indexMetadata.isPartialSearchableSnapshot() - return 0; - } - final long forecastedShardSize = indexMetadata.getForecastedShardSizeInBytes().orElse(-1L); - long totalSizeInBytes = 0; - int shardCount = 0; - for (int shard = 0; shard < indexMetadata.getNumberOfShards(); shard++) { - final ShardId shardId = new ShardId(indexMetadata.getIndex(), shard); - final long primaryShardSize = Math.max(forecastedShardSize, clusterInfo.getShardSize(shardId, true, -1L)); - if (primaryShardSize != -1L) { - totalSizeInBytes += primaryShardSize; - shardCount++; - } - final long replicaShardSize = Math.max(forecastedShardSize, clusterInfo.getShardSize(shardId, false, -1L)); - if (replicaShardSize != -1L) { - totalSizeInBytes += replicaShardSize * indexMetadata.getNumberOfReplicas(); - shardCount += indexMetadata.getNumberOfReplicas(); - } - } - if (shardCount == numberOfCopies(indexMetadata)) { - return totalSizeInBytes; - } - return shardCount == 0 ? 0 : (totalSizeInBytes / shardCount) * numberOfCopies(indexMetadata); - } - private static long getShardDiskUsageInBytes(ShardRouting shardRouting, IndexMetadata indexMetadata, ClusterInfo clusterInfo) { if (indexMetadata.ignoreDiskWatermarks()) { // disk watermarks are ignored for partial searchable snapshots @@ -401,10 +295,6 @@ private static long getShardDiskUsageInBytes(ShardRouting shardRouting, IndexMet return Math.max(indexMetadata.getForecastedShardSizeInBytes().orElse(0L), clusterInfo.getShardSize(shardRouting, 0L)); } - private static int numberOfCopies(IndexMetadata indexMetadata) { - return indexMetadata.getNumberOfShards() * (1 + indexMetadata.getNumberOfReplicas()); - } - private float getShardWriteLoad(String index) { return (float) writeLoadForecaster.getForecastedWriteLoad(metadata.index(index)).orElse(0.0); } @@ -1433,7 +1323,7 @@ public float weight(ModelNode node) { } public float minWeightDelta() { - return function.minWeightDelta(balancer, index); + return function.minWeightDelta(balancer.getShardWriteLoad(index), balancer.maxShardSizeBytes(index)); } @Override diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalance.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalance.java index 9de95804b49b2..6ad44fdf3a9c0 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalance.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalance.java @@ -21,7 +21,7 @@ * * @param assignments a set of the (persistent) node IDs to which each {@link ShardId} should be allocated * @param weightsPerNode The node weights calculated based on - * {@link org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WeightFunction#nodeWeight} + * {@link org.elasticsearch.cluster.routing.allocation.allocator.WeightFunction#nodeWeight} */ public record DesiredBalance( long lastConvergedIndex, diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceMetrics.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceMetrics.java index cf8840dc95724..9f6487bdc8abd 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceMetrics.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceMetrics.java @@ -10,7 +10,7 @@ package org.elasticsearch.cluster.routing.allocation.allocator; import org.elasticsearch.cluster.node.DiscoveryNode; -import org.elasticsearch.cluster.routing.allocation.NodeAllocationStats; +import org.elasticsearch.cluster.routing.allocation.NodeAllocationStatsProvider.NodeAllocationAndClusterBalanceStats; import org.elasticsearch.telemetry.metric.DoubleWithAttributes; import org.elasticsearch.telemetry.metric.LongWithAttributes; import org.elasticsearch.telemetry.metric.MeterRegistry; @@ -41,6 +41,7 @@ public record NodeWeightStats(long shardCount, double diskUsageInBytes, double w public static final String DESIRED_BALANCE_NODE_DISK_USAGE_METRIC_NAME = "es.allocator.desired_balance.allocations.node_disk_usage_bytes.current"; + public static final String CURRENT_NODE_WEIGHT_METRIC_NAME = "es.allocator.allocations.node.weight.current"; public static final String CURRENT_NODE_SHARD_COUNT_METRIC_NAME = "es.allocator.allocations.node.shard_count.current"; public static final String CURRENT_NODE_WRITE_LOAD_METRIC_NAME = "es.allocator.allocations.node.write_load.current"; public static final String CURRENT_NODE_DISK_USAGE_METRIC_NAME = "es.allocator.allocations.node.disk_usage_bytes.current"; @@ -68,12 +69,13 @@ public record NodeWeightStats(long shardCount, double diskUsageInBytes, double w private volatile long undesiredAllocations; private final AtomicReference> weightStatsPerNodeRef = new AtomicReference<>(Map.of()); - private final AtomicReference> allocationStatsPerNodeRef = new AtomicReference<>(Map.of()); + private final AtomicReference> allocationStatsPerNodeRef = + new AtomicReference<>(Map.of()); public void updateMetrics( AllocationStats allocationStats, Map weightStatsPerNode, - Map nodeAllocationStats + Map nodeAllocationStats ) { assert allocationStats != null : "allocation stats cannot be null"; assert weightStatsPerNode != null : "node balance weight stats cannot be null"; @@ -124,6 +126,12 @@ public DesiredBalanceMetrics(MeterRegistry meterRegistry) { "bytes", this::getDesiredBalanceNodeDiskUsageMetrics ); + meterRegistry.registerDoublesGauge( + CURRENT_NODE_WEIGHT_METRIC_NAME, + "The weight of nodes based on the current allocation state", + "unit", + this::getCurrentNodeWeightMetrics + ); meterRegistry.registerLongsGauge( DESIRED_BALANCE_NODE_SHARD_COUNT_METRIC_NAME, "Shard count of nodes in the computed desired balance", @@ -291,6 +299,18 @@ private List getCurrentNodeUndesiredShardCountMetrics() { return values; } + private List getCurrentNodeWeightMetrics() { + if (nodeIsMaster == false) { + return List.of(); + } + var stats = allocationStatsPerNodeRef.get(); + List doubles = new ArrayList<>(stats.size()); + for (var node : stats.keySet()) { + doubles.add(new DoubleWithAttributes(stats.get(node).currentNodeWeight(), getNodeAttributes(node))); + } + return doubles; + } + private Map getNodeAttributes(DiscoveryNode node) { return Map.of("node_id", node.getId(), "node_name", node.getName()); } diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconciler.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconciler.java index 5ad29debc8f20..2ee905634f760 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconciler.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/DesiredBalanceReconciler.java @@ -20,8 +20,8 @@ import org.elasticsearch.cluster.routing.ShardRouting; import org.elasticsearch.cluster.routing.UnassignedInfo; import org.elasticsearch.cluster.routing.UnassignedInfo.AllocationStatus; -import org.elasticsearch.cluster.routing.allocation.NodeAllocationStats; import org.elasticsearch.cluster.routing.allocation.NodeAllocationStatsProvider; +import org.elasticsearch.cluster.routing.allocation.NodeAllocationStatsProvider.NodeAllocationAndClusterBalanceStats; import org.elasticsearch.cluster.routing.allocation.RoutingAllocation; import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalanceMetrics.AllocationStats; import org.elasticsearch.cluster.routing.allocation.decider.Decision; @@ -159,8 +159,13 @@ void run() { } private void updateDesireBalanceMetrics(AllocationStats allocationStats) { - var stats = nodeAllocationStatsProvider.stats(allocation.getClusterState(), allocation.clusterInfo(), desiredBalance); - Map nodeAllocationStats = new HashMap<>(stats.size()); + var stats = nodeAllocationStatsProvider.stats( + allocation.metadata(), + allocation.routingNodes(), + allocation.clusterInfo(), + desiredBalance + ); + Map nodeAllocationStats = new HashMap<>(stats.size()); for (var entry : stats.entrySet()) { var node = allocation.nodes().get(entry.getKey()); if (node != null) { diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/WeightFunction.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/WeightFunction.java new file mode 100644 index 0000000000000..7203a92b147f6 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/WeightFunction.java @@ -0,0 +1,157 @@ +/* + * 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.cluster.routing.allocation.allocator; + +import org.elasticsearch.cluster.ClusterInfo; +import org.elasticsearch.cluster.metadata.IndexMetadata; +import org.elasticsearch.cluster.metadata.Metadata; +import org.elasticsearch.cluster.routing.RoutingNodes; +import org.elasticsearch.cluster.routing.allocation.WriteLoadForecaster; +import org.elasticsearch.index.shard.ShardId; + +/** + * This class is the primary weight function used to create balanced over nodes and shards in the cluster. + * Currently this function has 3 properties: + *
    + *
  • index balance - balance property over shards per index
  • + *
  • shard balance - balance property over shards per cluster
  • + *
+ *

+ * Each of these properties are expressed as factor such that the properties factor defines the relative + * importance of the property for the weight function. For example if the weight function should calculate + * the weights only based on a global (shard) balance the index balance can be set to {@code 0.0} and will + * in turn have no effect on the distribution. + *

+ * The weight per index is calculated based on the following formula: + *
    + *
  • + * weightindex(node, index) = indexBalance * (node.numShards(index) - avgShardsPerNode(index)) + *
  • + *
  • + * weightnode(node, index) = shardBalance * (node.numShards() - avgShardsPerNode) + *
  • + *
+ * weight(node, index) = weightindex(node, index) + weightnode(node, index) + */ +public class WeightFunction { + + private final float theta0; + private final float theta1; + private final float theta2; + private final float theta3; + + public WeightFunction(float shardBalance, float indexBalance, float writeLoadBalance, float diskUsageBalance) { + float sum = shardBalance + indexBalance + writeLoadBalance + diskUsageBalance; + if (sum <= 0.0f) { + throw new IllegalArgumentException("Balance factors must sum to a value > 0 but was: " + sum); + } + theta0 = shardBalance / sum; + theta1 = indexBalance / sum; + theta2 = writeLoadBalance / sum; + theta3 = diskUsageBalance / sum; + } + + float weight(BalancedShardsAllocator.Balancer balancer, BalancedShardsAllocator.ModelNode node, String index) { + final float weightIndex = node.numShards(index) - balancer.avgShardsPerNode(index); + final float nodeWeight = nodeWeight( + node.numShards(), + balancer.avgShardsPerNode(), + node.writeLoad(), + balancer.avgWriteLoadPerNode(), + node.diskUsageInBytes(), + balancer.avgDiskUsageInBytesPerNode() + ); + return nodeWeight + theta1 * weightIndex; + } + + public float nodeWeight( + int nodeNumShards, + float avgShardsPerNode, + double nodeWriteLoad, + double avgWriteLoadPerNode, + double diskUsageInBytes, + double avgDiskUsageInBytesPerNode + ) { + final float weightShard = nodeNumShards - avgShardsPerNode; + final float ingestLoad = (float) (nodeWriteLoad - avgWriteLoadPerNode); + final float diskUsage = (float) (diskUsageInBytes - avgDiskUsageInBytesPerNode); + return theta0 * weightShard + theta2 * ingestLoad + theta3 * diskUsage; + } + + float minWeightDelta(float shardWriteLoad, float shardSizeBytes) { + return theta0 * 1 + theta1 * 1 + theta2 * shardWriteLoad + theta3 * shardSizeBytes; + } + + public static float avgShardPerNode(Metadata metadata, RoutingNodes routingNodes) { + return ((float) metadata.getTotalNumberOfShards()) / routingNodes.size(); + } + + public static double avgWriteLoadPerNode(WriteLoadForecaster writeLoadForecaster, Metadata metadata, RoutingNodes routingNodes) { + return getTotalWriteLoad(writeLoadForecaster, metadata) / routingNodes.size(); + } + + public static double avgDiskUsageInBytesPerNode(ClusterInfo clusterInfo, Metadata metadata, RoutingNodes routingNodes) { + return ((double) getTotalDiskUsageInBytes(clusterInfo, metadata) / routingNodes.size()); + } + + private static double getTotalWriteLoad(WriteLoadForecaster writeLoadForecaster, Metadata metadata) { + double writeLoad = 0.0; + for (IndexMetadata indexMetadata : metadata.indices().values()) { + writeLoad += getIndexWriteLoad(writeLoadForecaster, indexMetadata); + } + return writeLoad; + } + + private static double getIndexWriteLoad(WriteLoadForecaster writeLoadForecaster, IndexMetadata indexMetadata) { + var shardWriteLoad = writeLoadForecaster.getForecastedWriteLoad(indexMetadata).orElse(0.0); + return shardWriteLoad * numberOfCopies(indexMetadata); + } + + private static int numberOfCopies(IndexMetadata indexMetadata) { + return indexMetadata.getNumberOfShards() * (1 + indexMetadata.getNumberOfReplicas()); + } + + private static long getTotalDiskUsageInBytes(ClusterInfo clusterInfo, Metadata metadata) { + long totalDiskUsageInBytes = 0; + for (IndexMetadata indexMetadata : metadata.indices().values()) { + totalDiskUsageInBytes += getIndexDiskUsageInBytes(clusterInfo, indexMetadata); + } + return totalDiskUsageInBytes; + } + + // Visible for testing + static long getIndexDiskUsageInBytes(ClusterInfo clusterInfo, IndexMetadata indexMetadata) { + if (indexMetadata.ignoreDiskWatermarks()) { + // disk watermarks are ignored for partial searchable snapshots + // and is equivalent to indexMetadata.isPartialSearchableSnapshot() + return 0; + } + final long forecastedShardSize = indexMetadata.getForecastedShardSizeInBytes().orElse(-1L); + long totalSizeInBytes = 0; + int shardCount = 0; + for (int shard = 0; shard < indexMetadata.getNumberOfShards(); shard++) { + final ShardId shardId = new ShardId(indexMetadata.getIndex(), shard); + final long primaryShardSize = Math.max(forecastedShardSize, clusterInfo.getShardSize(shardId, true, -1L)); + if (primaryShardSize != -1L) { + totalSizeInBytes += primaryShardSize; + shardCount++; + } + final long replicaShardSize = Math.max(forecastedShardSize, clusterInfo.getShardSize(shardId, false, -1L)); + if (replicaShardSize != -1L) { + totalSizeInBytes += replicaShardSize * indexMetadata.getNumberOfReplicas(); + shardCount += indexMetadata.getNumberOfReplicas(); + } + } + if (shardCount == numberOfCopies(indexMetadata)) { + return totalSizeInBytes; + } + return shardCount == 0 ? 0 : (totalSizeInBytes / shardCount) * numberOfCopies(indexMetadata); + } +} diff --git a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java index 0efa576a0cddc..35f1780464659 100644 --- a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java @@ -84,7 +84,7 @@ public void testShardStats() { clusterService, () -> clusterInfo, createShardAllocator(), - new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER) + new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER, ClusterSettings.createBuiltInClusterSettings()) ); assertThat( service.stats(), @@ -125,7 +125,7 @@ public void testRelocatingShardIsOnlyCountedOnceOnTargetNode() { clusterService, EmptyClusterInfoService.INSTANCE, createShardAllocator(), - new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER) + new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER, ClusterSettings.createBuiltInClusterSettings()) ); assertThat( service.stats(), @@ -182,7 +182,7 @@ public DesiredBalance getDesiredBalance() { ); } }, - new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER) + new NodeAllocationStatsProvider(TEST_WRITE_LOAD_FORECASTER, ClusterSettings.createBuiltInClusterSettings()) ); assertThat( service.stats(), diff --git a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java index 98c3451329f52..412329e51a485 100644 --- a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java @@ -59,8 +59,8 @@ import static java.util.stream.Collectors.toSet; import static org.elasticsearch.cluster.routing.ShardRoutingState.RELOCATING; import static org.elasticsearch.cluster.routing.TestShardRouting.shardRoutingBuilder; -import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.Balancer.getIndexDiskUsageInBytes; import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.DISK_USAGE_BALANCE_FACTOR_SETTING; +import static org.elasticsearch.cluster.routing.allocation.allocator.WeightFunction.getIndexDiskUsageInBytes; import static org.elasticsearch.cluster.routing.allocation.decider.DiskThresholdDecider.SETTING_IGNORE_DISK_WATERMARKS; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; diff --git a/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java b/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java index a041efc9ad3f1..75cd6da44724d 100644 --- a/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java @@ -19,12 +19,12 @@ import org.elasticsearch.cluster.node.DiscoveryNodeUtils; import org.elasticsearch.cluster.routing.RecoverySource; import org.elasticsearch.cluster.routing.RoutingNode; +import org.elasticsearch.cluster.routing.RoutingNodes; import org.elasticsearch.cluster.routing.RoutingNodesHelper; import org.elasticsearch.cluster.routing.ShardRouting; import org.elasticsearch.cluster.routing.UnassignedInfo; import org.elasticsearch.cluster.routing.allocation.AllocationService; import org.elasticsearch.cluster.routing.allocation.FailedShard; -import org.elasticsearch.cluster.routing.allocation.NodeAllocationStats; import org.elasticsearch.cluster.routing.allocation.NodeAllocationStatsProvider; import org.elasticsearch.cluster.routing.allocation.RoutingAllocation; import org.elasticsearch.cluster.routing.allocation.WriteLoadForecaster; @@ -438,11 +438,13 @@ public void allocateUnassigned( } protected static final NodeAllocationStatsProvider EMPTY_NODE_ALLOCATION_STATS = new NodeAllocationStatsProvider( - WriteLoadForecaster.DEFAULT + WriteLoadForecaster.DEFAULT, + createBuiltInClusterSettings() ) { @Override - public Map stats( - ClusterState clusterState, + public Map stats( + Metadata metadata, + RoutingNodes routingNodes, ClusterInfo clusterInfo, @Nullable DesiredBalance desiredBalance ) { From bfe1aad78044d7adc864ad647e88462f8cdce150 Mon Sep 17 00:00:00 2001 From: Ignacio Vera Date: Tue, 26 Nov 2024 16:47:25 +0100 Subject: [PATCH 050/129] Cleanup BucketsAggregator#rewriteBuckets (#114574) The array is initialized with the flag clearOnResize set to true so we don't need to set the values to 0 again. --- .../search/aggregations/bucket/BucketsAggregator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/BucketsAggregator.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/BucketsAggregator.java index ea667b821a7dd..665dd49e3381d 100644 --- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/BucketsAggregator.java +++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/BucketsAggregator.java @@ -105,7 +105,6 @@ public final void rewriteBuckets(long newNumBuckets, LongUnaryOperator mergeMap) try { docCounts = bigArrays().newLongArray(newNumBuckets, true); success = true; - docCounts.fill(0, newNumBuckets, 0); for (long i = 0; i < oldDocCounts.size(); i++) { long docCount = oldDocCounts.get(i); From 505c54eb94c71b694d44b8cf424be7ab5894e2e5 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 26 Nov 2024 16:59:54 +0100 Subject: [PATCH 051/129] Use feature flags in OperatorPrivilegesIT (#117491) Release runs fail for this suite because some of the actions listed are still behind a feature flag. Closes: https://github.com/elastic/elasticsearch/issues/102992 --- muted-tests.yml | 3 --- .../elasticsearch/xpack/security/operator/Constants.java | 8 +++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/muted-tests.yml b/muted-tests.yml index 49898308e411b..1f092de410f8e 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -226,9 +226,6 @@ tests: - class: org.elasticsearch.xpack.inference.InferenceRestIT method: test {p0=inference/30_semantic_text_inference/Calculates embeddings using the default ELSER 2 endpoint} issue: https://github.com/elastic/elasticsearch/issues/117349 -- class: org.elasticsearch.xpack.security.operator.OperatorPrivilegesIT - method: testEveryActionIsEitherOperatorOnlyOrNonOperator - issue: https://github.com/elastic/elasticsearch/issues/102992 - 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 diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index bfff63442281d..8df10037affdb 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -7,6 +7,8 @@ package org.elasticsearch.xpack.security.operator; +import org.elasticsearch.cluster.metadata.DataStream; + import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -508,9 +510,9 @@ public class Constants { "indices:admin/data_stream/lifecycle/get", "indices:admin/data_stream/lifecycle/put", "indices:admin/data_stream/lifecycle/explain", - "indices:admin/data_stream/options/delete", - "indices:admin/data_stream/options/get", - "indices:admin/data_stream/options/put", + DataStream.isFailureStoreFeatureFlagEnabled() ? "indices:admin/data_stream/options/delete" : null, + DataStream.isFailureStoreFeatureFlagEnabled() ? "indices:admin/data_stream/options/get" : null, + DataStream.isFailureStoreFeatureFlagEnabled() ? "indices:admin/data_stream/options/put" : null, "indices:admin/delete", "indices:admin/flush", "indices:admin/flush[s]", From f57c43cdf5ce8188cc66042b1a8adee420e91825 Mon Sep 17 00:00:00 2001 From: Oleksandr Kolomiiets Date: Tue, 26 Nov 2024 08:09:30 -0800 Subject: [PATCH 052/129] Include a link to downsampling a TSDS using DSL document (#117510) --- docs/reference/data-streams/tsds.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/data-streams/tsds.asciidoc b/docs/reference/data-streams/tsds.asciidoc index 461c0a1272e96..d0d6d4a455c63 100644 --- a/docs/reference/data-streams/tsds.asciidoc +++ b/docs/reference/data-streams/tsds.asciidoc @@ -339,4 +339,5 @@ include::tsds-index-settings.asciidoc[] include::downsampling.asciidoc[] include::downsampling-ilm.asciidoc[] include::downsampling-manual.asciidoc[] +include::downsampling-dsl.asciidoc[] include::tsds-reindex.asciidoc[] From b22d185b7fca8147ec1cfcd993d7c803ce5a240e Mon Sep 17 00:00:00 2001 From: Luigi Dell'Aquila Date: Tue, 26 Nov 2024 17:46:40 +0100 Subject: [PATCH 053/129] ES|QL: fix stats by constant expresson with alias (#117551) --- docs/changelog/117551.yaml | 5 + .../src/main/resources/stats.csv-spec | 12 ++ .../xpack/esql/action/EsqlCapabilities.java | 7 +- .../xpack/esql/session/EsqlSession.java | 2 +- .../session/IndexResolverFieldNamesTests.java | 108 ++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/117551.yaml diff --git a/docs/changelog/117551.yaml b/docs/changelog/117551.yaml new file mode 100644 index 0000000000000..081dd9203d82a --- /dev/null +++ b/docs/changelog/117551.yaml @@ -0,0 +1,5 @@ +pr: 117551 +summary: Fix stats by constant expresson with alias +area: ES|QL +type: bug +issues: [] diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec index 5562028a5935f..f95506ff1982f 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec @@ -2778,6 +2778,18 @@ m:integer | y+1:integer 11 | 12 ; +statsByConstantExpressionWithAliasAndSort +required_capability: fix_stats_by_foldable_expression_2 +FROM employees +| EVAL y = "a" +| STATS count = COUNT() BY x = y +| SORT x +; + +count:long | x:keyword +100 | a +; + filterIsAlwaysTrue required_capability: per_agg_filtering FROM employees 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 08fa7f0a9b213..3eaeceaa86564 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 @@ -531,7 +531,12 @@ public enum Cap { /** * support for aggregations on semantic_text */ - SEMANTIC_TEXT_AGGREGATIONS(EsqlCorePlugin.SEMANTIC_TEXT_FEATURE_FLAG); + SEMANTIC_TEXT_AGGREGATIONS(EsqlCorePlugin.SEMANTIC_TEXT_FEATURE_FLAG), + + /** + * Fix for https://github.com/elastic/elasticsearch/issues/114714, again + */ + FIX_STATS_BY_FOLDABLE_EXPRESSION_2,; private final boolean enabled; 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 25bb6d80d0dd0..8f65914d1c30d 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 @@ -511,7 +511,7 @@ static Set fieldNames(LogicalPlan parsed, Set enrichPolicyMatchF // remove any already discovered UnresolvedAttributes that are in fact aliases defined later down in the tree // for example "from test | eval x = salary | stats max = max(x) by gender" // remove the UnresolvedAttribute "x", since that is an Alias defined in "eval" - AttributeSet planRefs = Expressions.references(p.expressions()); + AttributeSet planRefs = p.references(); p.forEachExpressionDown(Alias.class, alias -> { // do not remove the UnresolvedAttribute that has the same name as its alias, ie "rename id = id" // or the UnresolvedAttributes that are used in Functions that have aliases "STATS id = MAX(id)" diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/session/IndexResolverFieldNamesTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/session/IndexResolverFieldNamesTests.java index 5425f770c49e8..0fe89b24dfc6a 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/session/IndexResolverFieldNamesTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/session/IndexResolverFieldNamesTests.java @@ -353,6 +353,114 @@ public void testDocsStats() { | SORT languages""", Set.of("emp_no", "emp_no.*", "languages", "languages.*")); } + public void testEvalStats() { + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY y""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY y + | SORT y""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y + | SORT x""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | STATS count = COUNT(*) BY first_name + | SORT first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y + | SORT x, first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL first_name = "a" + | STATS count = COUNT(*) BY first_name + | SORT first_name""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY first_name = to_upper(y) + | SORT first_name""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = to_upper(first_name), z = "z" + | STATS count = COUNT(*) BY first_name = to_lower(y), z + | SORT first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y, z = first_name + | SORT x, z""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y, first_name + | SORT x, first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(first_name) BY x = y + | SORT x + | DROP first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y + | MV_EXPAND x""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY first_name, y + | MV_EXPAND first_name""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | MV_EXPAND first_name + | EVAL y = "a" + | STATS count = COUNT(*) BY first_name, y + | SORT y""", Set.of("first_name", "first_name.*")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | MV_EXPAND y + | STATS count = COUNT(*) BY x = y + | SORT x""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY x = y + | STATS count = COUNT(count) by x + | SORT x""", Set.of("_index")); + + assertFieldNames(""" + FROM employees + | EVAL y = "a" + | STATS count = COUNT(*) BY first_name, y + | STATS count = COUNT(count) by x = y + | SORT x""", Set.of("first_name", "first_name.*")); + } + public void testSortWithLimitOne_DropHeight() { assertFieldNames("from employees | sort languages | limit 1 | drop height*", ALL_FIELDS); } From 1866299fa46e387238d28fe4e0d26c713926d47e Mon Sep 17 00:00:00 2001 From: Mikhail Berezovskiy Date: Tue, 26 Nov 2024 12:23:19 -0500 Subject: [PATCH 054/129] Remove HTTP content copies (#117303) --- .../forbidden/es-server-signatures.txt | 2 - docs/changelog/117303.yaml | 5 +++ .../netty4/Netty4TrashingAllocatorIT.java | 2 +- .../system/indices/SystemIndicesQA.java | 7 +-- .../elasticsearch/action/ActionListener.java | 8 ++++ .../common/bytes/BytesReference.java | 23 ---------- .../org/elasticsearch/http/HttpTracer.java | 2 +- .../org/elasticsearch/rest/RestRequest.java | 43 +++++-------------- .../elasticsearch/rest/RestRequestFilter.java | 4 +- .../cluster/RestPutStoredScriptAction.java | 7 ++- .../rest/action/document/RestBulkAction.java | 2 +- .../rest/action/document/RestIndexAction.java | 2 +- .../action/ingest/RestPutPipelineAction.java | 14 ++++-- .../ingest/RestSimulateIngestAction.java | 3 +- .../ingest/RestSimulatePipelineAction.java | 10 +++-- .../action/search/RestMultiSearchAction.java | 6 +-- .../common/bytes/BytesArrayTests.java | 5 --- .../elasticsearch/rest/RestRequestTests.java | 4 +- .../EnterpriseSearchBaseRestHandler.java | 2 +- .../action/RestPostAnalyticsEventAction.java | 42 +++++++++--------- .../rules/action/RestPutQueryRuleAction.java | 2 +- .../action/RestPutQueryRulesetAction.java | 2 +- .../rest/RestPutInferenceModelAction.java | 13 +++--- .../rest/RestUpdateInferenceModelAction.java | 10 ++++- .../logstash/rest/RestPutPipelineAction.java | 2 +- .../xpack/ml/rest/job/RestPostDataAction.java | 10 ++++- .../rest/action/RestMonitoringBulkAction.java | 6 ++- .../xpack/security/audit/AuditUtil.java | 2 +- .../rest/action/SecurityBaseRestHandler.java | 2 +- .../action/user/RestHasPrivilegesAction.java | 4 +- .../rest/RestFindStructureAction.java | 16 +++---- .../rest/action/RestPutWatchAction.java | 24 +++++++---- 32 files changed, 141 insertions(+), 145 deletions(-) create mode 100644 docs/changelog/117303.yaml diff --git a/build-tools-internal/src/main/resources/forbidden/es-server-signatures.txt b/build-tools-internal/src/main/resources/forbidden/es-server-signatures.txt index 68b97050ea012..a9da7995c2b36 100644 --- a/build-tools-internal/src/main/resources/forbidden/es-server-signatures.txt +++ b/build-tools-internal/src/main/resources/forbidden/es-server-signatures.txt @@ -167,5 +167,3 @@ org.elasticsearch.cluster.SnapshotDeletionsInProgress$Entry#(java.lang.Str @defaultMessage Use a Thread constructor with a name, anonymous threads are more difficult to debug java.lang.Thread#(java.lang.Runnable) java.lang.Thread#(java.lang.ThreadGroup, java.lang.Runnable) - -org.elasticsearch.common.bytes.BytesReference#copyBytes(org.elasticsearch.common.bytes.BytesReference) @ This method is a subject for removal. Copying bytes is prone to performance regressions and unnecessary allocations. diff --git a/docs/changelog/117303.yaml b/docs/changelog/117303.yaml new file mode 100644 index 0000000000000..71d134f2cd077 --- /dev/null +++ b/docs/changelog/117303.yaml @@ -0,0 +1,5 @@ +pr: 117303 +summary: Remove HTTP content copies +area: Network +type: enhancement +issues: [] diff --git a/modules/transport-netty4/src/internalClusterTest/java/org/elasticsearch/http/netty4/Netty4TrashingAllocatorIT.java b/modules/transport-netty4/src/internalClusterTest/java/org/elasticsearch/http/netty4/Netty4TrashingAllocatorIT.java index 18c91068ff4f9..f3a10ce228117 100644 --- a/modules/transport-netty4/src/internalClusterTest/java/org/elasticsearch/http/netty4/Netty4TrashingAllocatorIT.java +++ b/modules/transport-netty4/src/internalClusterTest/java/org/elasticsearch/http/netty4/Netty4TrashingAllocatorIT.java @@ -89,7 +89,7 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { - var content = request.releasableContent(); + var content = request.content(); var iter = content.iterator(); return (chan) -> { request.getHttpRequest().release(); diff --git a/qa/system-indices/src/main/java/org/elasticsearch/system/indices/SystemIndicesQA.java b/qa/system-indices/src/main/java/org/elasticsearch/system/indices/SystemIndicesQA.java index 6e15e40efa69a..46c6d1b9228d6 100644 --- a/qa/system-indices/src/main/java/org/elasticsearch/system/indices/SystemIndicesQA.java +++ b/qa/system-indices/src/main/java/org/elasticsearch/system/indices/SystemIndicesQA.java @@ -10,6 +10,7 @@ package org.elasticsearch.system.indices; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.admin.indices.create.CreateIndexRequest; import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.client.internal.node.NodeClient; @@ -177,12 +178,12 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + var content = request.requiredContent(); IndexRequest indexRequest = new IndexRequest(".net-new-system-index-primary"); - indexRequest.source(request.requiredContent(), request.getXContentType()); + indexRequest.source(content, request.getXContentType()); indexRequest.id(request.param("id")); indexRequest.setRefreshPolicy(request.param("refresh")); - - return channel -> client.index(indexRequest, new RestToXContentListener<>(channel)); + return channel -> client.index(indexRequest, ActionListener.withRef(new RestToXContentListener<>(channel), content)); } @Override diff --git a/server/src/main/java/org/elasticsearch/action/ActionListener.java b/server/src/main/java/org/elasticsearch/action/ActionListener.java index 890c3251e4f9a..a158669d936fe 100644 --- a/server/src/main/java/org/elasticsearch/action/ActionListener.java +++ b/server/src/main/java/org/elasticsearch/action/ActionListener.java @@ -475,4 +475,12 @@ static void runWithResource( ActionListener.run(ActionListener.runBefore(listener, resource::close), l -> action.accept(l, resource)); } + /** + * Increments ref count and returns a listener that will decrement ref count on listener completion. + */ + static ActionListener withRef(ActionListener listener, RefCounted ref) { + ref.mustIncRef(); + return releaseAfter(listener, ref::decRef); + } + } diff --git a/server/src/main/java/org/elasticsearch/common/bytes/BytesReference.java b/server/src/main/java/org/elasticsearch/common/bytes/BytesReference.java index 51e6512072e41..ddcfc1ea7eed8 100644 --- a/server/src/main/java/org/elasticsearch/common/bytes/BytesReference.java +++ b/server/src/main/java/org/elasticsearch/common/bytes/BytesReference.java @@ -74,29 +74,6 @@ static ByteBuffer[] toByteBuffers(BytesReference reference) { } } - /** - * Allocates new buffer and copy bytes from given BytesReference. - * - * @deprecated copying bytes is a right place for performance regression and unnecessary allocations. - * This method exists to serve very few places that struggle to handle reference counted buffers. - */ - @Deprecated(forRemoval = true) - static BytesReference copyBytes(BytesReference bytesReference) { - byte[] arr = new byte[bytesReference.length()]; - int offset = 0; - final BytesRefIterator iterator = bytesReference.iterator(); - try { - BytesRef slice; - while ((slice = iterator.next()) != null) { - System.arraycopy(slice.bytes, slice.offset, arr, offset, slice.length); - offset += slice.length; - } - return new BytesArray(arr); - } catch (IOException e) { - throw new AssertionError(e); - } - } - /** * Returns BytesReference composed of the provided ByteBuffers. */ diff --git a/server/src/main/java/org/elasticsearch/http/HttpTracer.java b/server/src/main/java/org/elasticsearch/http/HttpTracer.java index d6daf11c0539a..3d8360e6ee3fa 100644 --- a/server/src/main/java/org/elasticsearch/http/HttpTracer.java +++ b/server/src/main/java/org/elasticsearch/http/HttpTracer.java @@ -94,7 +94,7 @@ HttpTracer maybeLogRequest(RestRequest restRequest, @Nullable Exception e) { private void logFullContent(RestRequest restRequest) { try (var stream = HttpBodyTracer.getBodyOutputStream(restRequest.getRequestId(), HttpBodyTracer.Type.REQUEST)) { - restRequest.releasableContent().writeTo(stream); + restRequest.content().writeTo(stream); } catch (Exception e2) { assert false : e2; // no real IO here } diff --git a/server/src/main/java/org/elasticsearch/rest/RestRequest.java b/server/src/main/java/org/elasticsearch/rest/RestRequest.java index 17d85a8eabb1c..a04bdcb32f2b4 100644 --- a/server/src/main/java/org/elasticsearch/rest/RestRequest.java +++ b/server/src/main/java/org/elasticsearch/rest/RestRequest.java @@ -23,7 +23,6 @@ import org.elasticsearch.core.CheckedConsumer; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.RestApiVersion; -import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.Tuple; import org.elasticsearch.http.HttpBody; @@ -303,22 +302,13 @@ public boolean isFullContent() { return httpRequest.body().isFull(); } - /** - * Returns a copy of HTTP content. The copy is GC-managed and does not require reference counting. - * Please use {@link #releasableContent()} to avoid content copy. - */ - @SuppressForbidden(reason = "temporarily support content copy while migrating RestHandlers to ref counted pooled buffers") - public BytesReference content() { - return BytesReference.copyBytes(releasableContent()); - } - /** * Returns a direct reference to the network buffer containing the request body. The HTTP layers will release their references to this * buffer as soon as they have finished the synchronous steps of processing the request on the network thread, which will by default * release the buffer back to the pool where it may be re-used for another request. If you need to keep the buffer alive past the end of * these synchronous steps, acquire your own reference to this buffer and release it once it's no longer needed. */ - public ReleasableBytesReference releasableContent() { + public ReleasableBytesReference content() { this.contentConsumed = true; var bytes = httpRequest.body().asFull().bytes(); if (bytes.hasReferences() == false) { @@ -338,32 +328,19 @@ public HttpBody.Stream contentStream() { return httpRequest.body().asStream(); } - private void ensureContent() { + /** + * Returns reference to the network buffer of HTTP content or throw an exception if the body or content type is missing. + * See {@link #content()}. + */ + public ReleasableBytesReference requiredContent() { if (hasContent() == false) { throw new ElasticsearchParseException("request body is required"); } else if (xContentType.get() == null) { throwValidationException("unknown content type"); } - } - - /** - * @return copy of the request body or throw an exception if the body or content type is missing. - * See {@link #content()}. Please use {@link #requiredReleasableContent()} to avoid content copy. - */ - public final BytesReference requiredContent() { - ensureContent(); return content(); } - /** - * Returns reference to the network buffer of HTTP content or throw an exception if the body or content type is missing. - * See {@link #releasableContent()}. It's a recommended method to handle HTTP content without copying it. - */ - public ReleasableBytesReference requiredReleasableContent() { - ensureContent(); - return releasableContent(); - } - private static void throwValidationException(String msg) { ValidationException unknownContentType = new ValidationException(); unknownContentType.addValidationError(msg); @@ -596,7 +573,7 @@ public final boolean hasContentOrSourceParam() { * if you need to handle the absence request content gracefully. */ public final XContentParser contentOrSourceParamParser() throws IOException { - Tuple tuple = contentOrSourceParam(); + Tuple tuple = contentOrSourceParam(); return XContentHelper.createParserNotCompressed(parserConfig, tuple.v2(), tuple.v1().xContent().type()); } @@ -607,7 +584,7 @@ public final XContentParser contentOrSourceParamParser() throws IOException { */ public final void withContentOrSourceParamParserOrNull(CheckedConsumer withParser) throws IOException { if (hasContentOrSourceParam()) { - Tuple tuple = contentOrSourceParam(); + Tuple tuple = contentOrSourceParam(); try (XContentParser parser = XContentHelper.createParserNotCompressed(parserConfig, tuple.v2(), tuple.v1())) { withParser.accept(parser); } @@ -620,7 +597,7 @@ public final void withContentOrSourceParamParserOrNull(CheckedConsumer contentOrSourceParam() { + public final Tuple contentOrSourceParam() { if (hasContentOrSourceParam() == false) { throw new ElasticsearchParseException("request body or source parameter is required"); } else if (hasContent()) { @@ -636,7 +613,7 @@ public final Tuple contentOrSourceParam() { if (xContentType == null) { throwValidationException("Unknown value for source_content_type [" + typeParam + "]"); } - return new Tuple<>(xContentType, bytes); + return new Tuple<>(xContentType, ReleasableBytesReference.wrap(bytes)); } public ParsedMediaType getParsedAccept() { diff --git a/server/src/main/java/org/elasticsearch/rest/RestRequestFilter.java b/server/src/main/java/org/elasticsearch/rest/RestRequestFilter.java index 57b4d2990c8e0..7c90d9168e6c8 100644 --- a/server/src/main/java/org/elasticsearch/rest/RestRequestFilter.java +++ b/server/src/main/java/org/elasticsearch/rest/RestRequestFilter.java @@ -45,10 +45,10 @@ public boolean hasContent() { } @Override - public ReleasableBytesReference releasableContent() { + public ReleasableBytesReference content() { if (filteredBytes == null) { Tuple> result = XContentHelper.convertToMap( - restRequest.requiredReleasableContent(), + restRequest.requiredContent(), true, restRequest.getXContentType() ); diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestPutStoredScriptAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestPutStoredScriptAction.java index 4451117fa4792..a698dc3f30577 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestPutStoredScriptAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestPutStoredScriptAction.java @@ -8,6 +8,7 @@ */ package org.elasticsearch.rest.action.admin.cluster; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.admin.cluster.storedscripts.PutStoredScriptRequest; import org.elasticsearch.action.admin.cluster.storedscripts.TransportPutStoredScriptAction; import org.elasticsearch.client.internal.node.NodeClient; @@ -57,6 +58,10 @@ public RestChannelConsumer prepareRequest(RestRequest request, NodeClient client request.getXContentType(), StoredScriptSource.parse(content, xContentType) ); - return channel -> client.execute(TransportPutStoredScriptAction.TYPE, putRequest, new RestToXContentListener<>(channel)); + return channel -> client.execute( + TransportPutStoredScriptAction.TYPE, + putRequest, + ActionListener.withRef(new RestToXContentListener<>(channel), content) + ); } } diff --git a/server/src/main/java/org/elasticsearch/rest/action/document/RestBulkAction.java b/server/src/main/java/org/elasticsearch/rest/action/document/RestBulkAction.java index 9428ef5390b2f..dea7b7138d0d0 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/document/RestBulkAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/document/RestBulkAction.java @@ -103,7 +103,7 @@ public RestChannelConsumer prepareRequest(final RestRequest request, final NodeC boolean defaultRequireDataStream = request.paramAsBoolean(DocWriteRequest.REQUIRE_DATA_STREAM, false); bulkRequest.timeout(request.paramAsTime("timeout", BulkShardRequest.DEFAULT_TIMEOUT)); bulkRequest.setRefreshPolicy(request.param("refresh")); - ReleasableBytesReference content = request.requiredReleasableContent(); + ReleasableBytesReference content = request.requiredContent(); try { bulkRequest.add( diff --git a/server/src/main/java/org/elasticsearch/rest/action/document/RestIndexAction.java b/server/src/main/java/org/elasticsearch/rest/action/document/RestIndexAction.java index d81ac03492d59..d40c6225cc7b4 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/document/RestIndexAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/document/RestIndexAction.java @@ -106,7 +106,7 @@ public RestChannelConsumer prepareRequest(RestRequest request, final NodeClient @Override public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { - ReleasableBytesReference source = request.requiredReleasableContent(); + ReleasableBytesReference source = request.requiredContent(); IndexRequest indexRequest = new IndexRequest(request.param("index")); indexRequest.id(request.param("id")); indexRequest.routing(request.param("routing")); diff --git a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestPutPipelineAction.java b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestPutPipelineAction.java index 269d9b08ab66b..c6b3daa38d663 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestPutPipelineAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestPutPipelineAction.java @@ -9,10 +9,11 @@ package org.elasticsearch.rest.action.ingest; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ingest.PutPipelineRequest; import org.elasticsearch.action.ingest.PutPipelineTransportAction; import org.elasticsearch.client.internal.node.NodeClient; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.core.Tuple; import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; @@ -56,15 +57,20 @@ public RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient cl } } - Tuple sourceTuple = restRequest.contentOrSourceParam(); + Tuple sourceTuple = restRequest.contentOrSourceParam(); + var content = sourceTuple.v2(); final var request = new PutPipelineRequest( getMasterNodeTimeout(restRequest), getAckTimeout(restRequest), restRequest.param("id"), - sourceTuple.v2(), + content, sourceTuple.v1(), ifVersion ); - return channel -> client.execute(PutPipelineTransportAction.TYPE, request, new RestToXContentListener<>(channel)); + return channel -> client.execute( + PutPipelineTransportAction.TYPE, + request, + ActionListener.withRef(new RestToXContentListener<>(channel), content) + ); } } diff --git a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulateIngestAction.java b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulateIngestAction.java index c825a8198e6e4..978b6d1c3a92d 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulateIngestAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulateIngestAction.java @@ -16,6 +16,7 @@ import org.elasticsearch.action.bulk.SimulateBulkRequest; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.Tuple; import org.elasticsearch.ingest.ConfigurationUtils; @@ -72,7 +73,7 @@ public RestChannelConsumer prepareRequest(final RestRequest request, final NodeC String defaultIndex = request.param("index"); FetchSourceContext defaultFetchSourceContext = FetchSourceContext.parseFromRestRequest(request); String defaultPipeline = request.param("pipeline"); - Tuple sourceTuple = request.contentOrSourceParam(); + Tuple sourceTuple = request.contentOrSourceParam(); Map sourceMap = XContentHelper.convertToMap(sourceTuple.v2(), false, sourceTuple.v1()).v2(); Map> pipelineSubstitutions = (Map>) sourceMap.remove( "pipeline_substitutions" diff --git a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulatePipelineAction.java b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulatePipelineAction.java index f85b89f774477..faf977b54885d 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulatePipelineAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/ingest/RestSimulatePipelineAction.java @@ -9,9 +9,10 @@ package org.elasticsearch.rest.action.ingest; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ingest.SimulatePipelineRequest; import org.elasticsearch.client.internal.node.NodeClient; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.core.Tuple; import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; @@ -46,10 +47,13 @@ public String getName() { @Override public RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) throws IOException { - Tuple sourceTuple = restRequest.contentOrSourceParam(); + Tuple sourceTuple = restRequest.contentOrSourceParam(); + var content = sourceTuple.v2(); SimulatePipelineRequest request = new SimulatePipelineRequest(sourceTuple.v2(), sourceTuple.v1(), restRequest.getRestApiVersion()); request.setId(restRequest.param("id")); request.setVerbose(restRequest.paramAsBoolean("verbose", false)); - return channel -> client.admin().cluster().simulatePipeline(request, new RestToXContentListener<>(channel)); + return channel -> client.admin() + .cluster() + .simulatePipeline(request, ActionListener.withRef(new RestToXContentListener<>(channel), content)); } } diff --git a/server/src/main/java/org/elasticsearch/rest/action/search/RestMultiSearchAction.java b/server/src/main/java/org/elasticsearch/rest/action/search/RestMultiSearchAction.java index aeb182978e1eb..89775b4ca8e15 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/search/RestMultiSearchAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/search/RestMultiSearchAction.java @@ -17,7 +17,7 @@ import org.elasticsearch.common.CheckedBiConsumer; import org.elasticsearch.common.Strings; import org.elasticsearch.common.TriFunction; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Tuple; import org.elasticsearch.features.NodeFeature; @@ -184,9 +184,9 @@ public static void parseMultiLineRequest( boolean ccsMinimizeRoundtrips = request.paramAsBoolean("ccs_minimize_roundtrips", true); String routing = request.param("routing"); - final Tuple sourceTuple = request.contentOrSourceParam(); + final Tuple sourceTuple = request.contentOrSourceParam(); final XContent xContent = sourceTuple.v1().xContent(); - final BytesReference data = sourceTuple.v2(); + final ReleasableBytesReference data = sourceTuple.v2(); MultiSearchRequest.readMultiLineFormat( xContent, request.contentParserConfig(), diff --git a/server/src/test/java/org/elasticsearch/common/bytes/BytesArrayTests.java b/server/src/test/java/org/elasticsearch/common/bytes/BytesArrayTests.java index 3fd8535cd5c27..e067be6b1b0da 100644 --- a/server/src/test/java/org/elasticsearch/common/bytes/BytesArrayTests.java +++ b/server/src/test/java/org/elasticsearch/common/bytes/BytesArrayTests.java @@ -108,9 +108,4 @@ public void testGetDoubleLE() { assertThat(e.getMessage(), equalTo("Index 9 out of bounds for length 9")); } - public void testCopyBytes() { - var data = randomByteArrayOfLength(between(1024, 1024 * 1024 * 50)); - var copy = BytesReference.copyBytes(new BytesArray(data)); - assertArrayEquals(data, BytesReference.toBytes(copy)); - } } diff --git a/server/src/test/java/org/elasticsearch/rest/RestRequestTests.java b/server/src/test/java/org/elasticsearch/rest/RestRequestTests.java index 8a0ca5ba6c8a5..b391b77503400 100644 --- a/server/src/test/java/org/elasticsearch/rest/RestRequestTests.java +++ b/server/src/test/java/org/elasticsearch/rest/RestRequestTests.java @@ -12,7 +12,7 @@ import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.common.ValidationException; import org.elasticsearch.common.bytes.BytesArray; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.core.CheckedConsumer; import org.elasticsearch.http.HttpBody; import org.elasticsearch.http.HttpChannel; @@ -321,7 +321,7 @@ public String uri() { } @Override - public BytesReference content() { + public ReleasableBytesReference content() { return restRequest.content(); } } diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchBaseRestHandler.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchBaseRestHandler.java index 214f9150dfcc5..aa200f7ae9acb 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchBaseRestHandler.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchBaseRestHandler.java @@ -32,7 +32,7 @@ protected final BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest r // We need to consume parameters and content from the REST request in order to bypass unrecognized param errors // and return a license error. request.params().keySet().forEach(key -> request.param(key, "")); - request.releasableContent(); + request.content(); return channel -> channel.sendResponse( new RestResponse(channel, LicenseUtils.newComplianceException(this.licenseState, this.product)) ); diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPostAnalyticsEventAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPostAnalyticsEventAction.java index 34292c4669333..5706e5e384053 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPostAnalyticsEventAction.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPostAnalyticsEventAction.java @@ -7,8 +7,9 @@ package org.elasticsearch.xpack.application.analytics.action; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.network.InetAddresses; import org.elasticsearch.core.Tuple; import org.elasticsearch.license.XPackLicenseState; @@ -48,11 +49,26 @@ public List routes() { @Override protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) { - PostAnalyticsEventAction.Request request = buidRequest(restRequest); + Tuple sourceTuple = restRequest.contentOrSourceParam(); + + var content = sourceTuple.v2(); + PostAnalyticsEventAction.RequestBuilder builder = PostAnalyticsEventAction.Request.builder( + restRequest.param("collection_name"), + restRequest.param("event_type"), + sourceTuple.v1(), + content + ); + + builder.debug(restRequest.paramAsBoolean("debug", false)); + + final Map> headers = restRequest.getHeaders(); + builder.headers(headers); + builder.clientAddress(getClientAddress(restRequest, headers)); + return channel -> client.execute( PostAnalyticsEventAction.INSTANCE, - request, - new RestToXContentListener<>(channel, r -> RestStatus.ACCEPTED) + builder.request(), + ActionListener.withRef(new RestToXContentListener<>(channel, r -> RestStatus.ACCEPTED), content) ); } @@ -71,22 +87,4 @@ private static InetAddress getClientAddress(RestRequest restRequest, Map sourceTuple = restRequest.contentOrSourceParam(); - - PostAnalyticsEventAction.RequestBuilder builder = PostAnalyticsEventAction.Request.builder( - restRequest.param("collection_name"), - restRequest.param("event_type"), - sourceTuple.v1(), - sourceTuple.v2() - ); - - builder.debug(restRequest.paramAsBoolean("debug", false)); - - final Map> headers = restRequest.getHeaders(); - builder.headers(headers); - builder.clientAddress(getClientAddress(restRequest, headers)); - - return builder.request(); - } } diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java index 4addd97465bf2..1660502d77920 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java @@ -43,7 +43,7 @@ protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeC PutQueryRuleAction.Request request = new PutQueryRuleAction.Request( restRequest.param("ruleset_id"), restRequest.param("rule_id"), - restRequest.content(), + restRequest.requiredContent(), restRequest.getXContentType() ); return channel -> client.execute( diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetAction.java index a43ac70327e77..db20e66845f35 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetAction.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetAction.java @@ -42,7 +42,7 @@ public List routes() { protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) throws IOException { PutQueryRulesetAction.Request request = new PutQueryRulesetAction.Request( restRequest.param("ruleset_id"), - restRequest.content(), + restRequest.requiredContent(), restRequest.getXContentType() ); return channel -> client.execute( diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestPutInferenceModelAction.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestPutInferenceModelAction.java index 0523160ee19c2..655e11996d522 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestPutInferenceModelAction.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestPutInferenceModelAction.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.inference.rest; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.inference.TaskType; import org.elasticsearch.rest.BaseRestHandler; @@ -48,12 +49,12 @@ protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient taskType = TaskType.ANY; // task type must be defined in the body } - var request = new PutInferenceModelAction.Request( - taskType, - inferenceEntityId, - restRequest.requiredContent(), - restRequest.getXContentType() + var content = restRequest.requiredContent(); + var request = new PutInferenceModelAction.Request(taskType, inferenceEntityId, content, restRequest.getXContentType()); + return channel -> client.execute( + PutInferenceModelAction.INSTANCE, + request, + ActionListener.withRef(new RestToXContentListener<>(channel), content) ); - return channel -> client.execute(PutInferenceModelAction.INSTANCE, request, new RestToXContentListener<>(channel)); } } diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestUpdateInferenceModelAction.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestUpdateInferenceModelAction.java index 9405a6752538c..120731a4f8e66 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestUpdateInferenceModelAction.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/rest/RestUpdateInferenceModelAction.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.inference.rest; import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.inference.TaskType; import org.elasticsearch.rest.BaseRestHandler; @@ -50,13 +51,18 @@ protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient throw new ElasticsearchStatusException("Inference ID must be provided in the path", RestStatus.BAD_REQUEST); } + var content = restRequest.requiredContent(); var request = new UpdateInferenceModelAction.Request( inferenceEntityId, - restRequest.requiredContent(), + content, restRequest.getXContentType(), taskType, RestUtils.getMasterNodeTimeout(restRequest) ); - return channel -> client.execute(UpdateInferenceModelAction.INSTANCE, request, new RestToXContentListener<>(channel)); + return channel -> client.execute( + UpdateInferenceModelAction.INSTANCE, + request, + ActionListener.withRef(new RestToXContentListener<>(channel), content) + ); } } diff --git a/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/rest/RestPutPipelineAction.java b/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/rest/RestPutPipelineAction.java index 2ea56b147bf9c..a9992e168bc66 100644 --- a/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/rest/RestPutPipelineAction.java +++ b/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/rest/RestPutPipelineAction.java @@ -49,7 +49,7 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli } return restChannel -> { - final String content = request.releasableContent().utf8ToString(); + final String content = request.content().utf8ToString(); client.execute( PutPipelineAction.INSTANCE, new PutPipelineRequest(id, content, request.getXContentType()), diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/rest/job/RestPostDataAction.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/rest/job/RestPostDataAction.java index 48c6abde3010a..0fcad773100ff 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/rest/job/RestPostDataAction.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/rest/job/RestPostDataAction.java @@ -6,6 +6,7 @@ */ package org.elasticsearch.xpack.ml.rest.job; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.core.UpdateForV9; import org.elasticsearch.rest.BaseRestHandler; @@ -51,9 +52,14 @@ protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient PostDataAction.Request request = new PostDataAction.Request(restRequest.param(Job.ID.getPreferredName())); request.setResetStart(restRequest.param(PostDataAction.Request.RESET_START.getPreferredName(), DEFAULT_RESET_START)); request.setResetEnd(restRequest.param(PostDataAction.Request.RESET_END.getPreferredName(), DEFAULT_RESET_END)); - request.setContent(restRequest.content(), restRequest.getXContentType()); + var content = restRequest.content(); + request.setContent(content, restRequest.getXContentType()); - return channel -> client.execute(PostDataAction.INSTANCE, request, new RestToXContentListener<>(channel, r -> RestStatus.ACCEPTED)); + return channel -> client.execute( + PostDataAction.INSTANCE, + request, + ActionListener.withRef(new RestToXContentListener<>(channel, r -> RestStatus.ACCEPTED), content) + ); } @Override diff --git a/x-pack/plugin/monitoring/src/main/java/org/elasticsearch/xpack/monitoring/rest/action/RestMonitoringBulkAction.java b/x-pack/plugin/monitoring/src/main/java/org/elasticsearch/xpack/monitoring/rest/action/RestMonitoringBulkAction.java index b69b958a27ce6..762cbffacb082 100644 --- a/x-pack/plugin/monitoring/src/main/java/org/elasticsearch/xpack/monitoring/rest/action/RestMonitoringBulkAction.java +++ b/x-pack/plugin/monitoring/src/main/java/org/elasticsearch/xpack/monitoring/rest/action/RestMonitoringBulkAction.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.monitoring.rest.action; import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.common.Strings; import org.elasticsearch.rest.BaseRestHandler; @@ -93,8 +94,9 @@ public RestChannelConsumer prepareRequest(RestRequest request, NodeClient client final long intervalMillis = parseTimeValue(intervalAsString, INTERVAL).getMillis(); final MonitoringBulkRequestBuilder requestBuilder = new MonitoringBulkRequestBuilder(client); - requestBuilder.add(system, request.content(), request.getXContentType(), timestamp, intervalMillis); - return channel -> requestBuilder.execute(getRestBuilderListener(channel)); + var content = request.content(); + requestBuilder.add(system, content, request.getXContentType(), timestamp, intervalMillis); + return channel -> requestBuilder.execute(ActionListener.withRef(getRestBuilderListener(channel), content)); } @Override diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditUtil.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditUtil.java index 429b632cdac18..58516b1d8324d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditUtil.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditUtil.java @@ -27,7 +27,7 @@ public class AuditUtil { public static String restRequestContent(RestRequest request) { if (request.hasContent()) { - var content = request.releasableContent(); + var content = request.content(); try { return XContentHelper.convertToJson(content, false, false, request.getXContentType()); } catch (IOException ioe) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/SecurityBaseRestHandler.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/SecurityBaseRestHandler.java index df21f5d4eeb0b..d5d11ea42e345 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/SecurityBaseRestHandler.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/SecurityBaseRestHandler.java @@ -75,7 +75,7 @@ protected final RestChannelConsumer prepareRequest(RestRequest request, NodeClie return innerPrepareRequest(request, client); } else { request.params().keySet().forEach(key -> request.param(key, "")); - request.releasableContent(); // mark content consumed + request.content(); // mark content consumed return channel -> channel.sendResponse(new RestResponse(channel, failedFeature)); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestHasPrivilegesAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestHasPrivilegesAction.java index f2233a7e19fd0..8029ed3ba45e4 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestHasPrivilegesAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestHasPrivilegesAction.java @@ -8,7 +8,7 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.client.internal.node.NodeClient; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Tuple; import org.elasticsearch.license.XPackLicenseState; @@ -77,7 +77,7 @@ public RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient c * Consume the body immediately. This ensures that if there is a body and we later reject the request (e.g., because security is not * enabled) that the REST infrastructure will not reject the request for not having consumed the body. */ - final Tuple content = request.contentOrSourceParam(); + final Tuple content = request.contentOrSourceParam(); final String username = getUsername(request); if (username == null) { return restChannel -> { throw new ElasticsearchSecurityException("there is no authenticated user"); }; diff --git a/x-pack/plugin/text-structure/src/main/java/org/elasticsearch/xpack/textstructure/rest/RestFindStructureAction.java b/x-pack/plugin/text-structure/src/main/java/org/elasticsearch/xpack/textstructure/rest/RestFindStructureAction.java index 5078572dee5fd..f47a25409b821 100644 --- a/x-pack/plugin/text-structure/src/main/java/org/elasticsearch/xpack/textstructure/rest/RestFindStructureAction.java +++ b/x-pack/plugin/text-structure/src/main/java/org/elasticsearch/xpack/textstructure/rest/RestFindStructureAction.java @@ -6,7 +6,7 @@ */ package org.elasticsearch.xpack.textstructure.rest; -import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.core.UpdateForV9; import org.elasticsearch.rest.BaseRestHandler; @@ -50,14 +50,14 @@ public String getName() { protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) { FindStructureAction.Request request = new FindStructureAction.Request(); RestFindStructureArgumentsParser.parse(restRequest, request); + var content = restRequest.requiredContent(); + request.setSample(content); - if (restRequest.hasContent()) { - request.setSample(restRequest.content()); - } else { - throw new ElasticsearchParseException("request body is required"); - } - - return channel -> client.execute(FindStructureAction.INSTANCE, request, new RestToXContentListener<>(channel)); + return channel -> client.execute( + FindStructureAction.INSTANCE, + request, + ActionListener.withRef(new RestToXContentListener<>(channel), content) + ); } @Override diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/rest/action/RestPutWatchAction.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/rest/action/RestPutWatchAction.java index 9dba72b1f64c3..0ed27a4073653 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/rest/action/RestPutWatchAction.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/rest/action/RestPutWatchAction.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.watcher.rest.action; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.common.lucene.uid.Versions; import org.elasticsearch.protocol.xpack.watcher.PutWatchRequest; @@ -42,19 +43,24 @@ public String getName() { @Override protected RestChannelConsumer prepareRequest(final RestRequest request, NodeClient client) { - PutWatchRequest putWatchRequest = new PutWatchRequest(request.param("id"), request.content(), request.getXContentType()); + var content = request.content(); + PutWatchRequest putWatchRequest = new PutWatchRequest(request.param("id"), content, request.getXContentType()); putWatchRequest.setVersion(request.paramAsLong("version", Versions.MATCH_ANY)); putWatchRequest.setIfSeqNo(request.paramAsLong("if_seq_no", putWatchRequest.getIfSeqNo())); putWatchRequest.setIfPrimaryTerm(request.paramAsLong("if_primary_term", putWatchRequest.getIfPrimaryTerm())); putWatchRequest.setActive(request.paramAsBoolean("active", putWatchRequest.isActive())); - return channel -> client.execute(PutWatchAction.INSTANCE, putWatchRequest, new RestBuilderListener<>(channel) { - @Override - public RestResponse buildResponse(PutWatchResponse response, XContentBuilder builder) throws Exception { - response.toXContent(builder, request); - RestStatus status = response.isCreated() ? CREATED : OK; - return new RestResponse(status, builder); - } - }); + return channel -> client.execute( + PutWatchAction.INSTANCE, + putWatchRequest, + ActionListener.withRef(new RestBuilderListener<>(channel) { + @Override + public RestResponse buildResponse(PutWatchResponse response, XContentBuilder builder) throws Exception { + response.toXContent(builder, request); + RestStatus status = response.isCreated() ? CREATED : OK; + return new RestResponse(status, builder); + } + }, content) + ); } private static final Set FILTERED_FIELDS = Set.of( From f05c9b07f801e49e1a95f7665485464dcda862ee Mon Sep 17 00:00:00 2001 From: Mark Tozzi Date: Tue, 26 Nov 2024 13:45:13 -0500 Subject: [PATCH 055/129] ESQL Add some tests for sorting the date nanos union type (#117567) --- .../src/main/resources/union_types.csv-spec | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec index af987b13acc82..bf6e2f8ae0893 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec @@ -626,6 +626,65 @@ sample_data_ts_nanos | 2023-10-23T12:27:28.948123456Z | 172.21.2.113 | 27648 sample_data_ts_nanos | 2023-10-23T12:15:03.360123456Z | 172.21.2.162 | 3450233 | Connected to 10.1.0.3 ; +multiIndex sort millis and nanos as nanos +required_capability: to_date_nanos +required_capability: union_types +required_capability: metadata_fields +required_capability: union_types_remove_fields + +FROM sample_data, sample_data_ts_nanos METADATA _index +| EVAL ts = TO_DATE_NANOS(@timestamp) +| KEEP _index, ts, client_ip, event_duration, message +| SORT ts DESC +; + +_index:keyword | ts:date_nanos | client_ip:ip | event_duration:long | message:keyword +sample_data_ts_nanos | 2023-10-23T13:55:01.543123456Z | 172.21.3.15 | 1756467 | Connected to 10.1.0.1 +sample_data | 2023-10-23T13:55:01.543000000Z | 172.21.3.15 | 1756467 | Connected to 10.1.0.1 +sample_data_ts_nanos | 2023-10-23T13:53:55.832123456Z | 172.21.3.15 | 5033755 | Connection error +sample_data | 2023-10-23T13:53:55.832000000Z | 172.21.3.15 | 5033755 | Connection error +sample_data_ts_nanos | 2023-10-23T13:52:55.015123456Z | 172.21.3.15 | 8268153 | Connection error +sample_data | 2023-10-23T13:52:55.015000000Z | 172.21.3.15 | 8268153 | Connection error +sample_data_ts_nanos | 2023-10-23T13:51:54.732123456Z | 172.21.3.15 | 725448 | Connection error +sample_data | 2023-10-23T13:51:54.732000000Z | 172.21.3.15 | 725448 | Connection error +sample_data_ts_nanos | 2023-10-23T13:33:34.937123456Z | 172.21.0.5 | 1232382 | Disconnected +sample_data | 2023-10-23T13:33:34.937000000Z | 172.21.0.5 | 1232382 | Disconnected +sample_data_ts_nanos | 2023-10-23T12:27:28.948123456Z | 172.21.2.113 | 2764889 | Connected to 10.1.0.2 +sample_data | 2023-10-23T12:27:28.948000000Z | 172.21.2.113 | 2764889 | Connected to 10.1.0.2 +sample_data_ts_nanos | 2023-10-23T12:15:03.360123456Z | 172.21.2.162 | 3450233 | Connected to 10.1.0.3 +sample_data | 2023-10-23T12:15:03.360000000Z | 172.21.2.162 | 3450233 | Connected to 10.1.0.3 +; + +multiIndex sort millis and nanos as millis +required_capability: to_date_nanos +required_capability: union_types +required_capability: metadata_fields +required_capability: union_types_remove_fields + +FROM sample_data, sample_data_ts_nanos METADATA _index +| EVAL ts = TO_DATETIME(@timestamp) +| KEEP _index, ts, client_ip, event_duration, message +| SORT ts DESC, _index DESC +; + +_index:keyword | ts:datetime | client_ip:ip | event_duration:long | message:keyword +sample_data_ts_nanos | 2023-10-23T13:55:01.543Z | 172.21.3.15 | 1756467 | Connected to 10.1.0.1 +sample_data | 2023-10-23T13:55:01.543Z | 172.21.3.15 | 1756467 | Connected to 10.1.0.1 +sample_data_ts_nanos | 2023-10-23T13:53:55.832Z | 172.21.3.15 | 5033755 | Connection error +sample_data | 2023-10-23T13:53:55.832Z | 172.21.3.15 | 5033755 | Connection error +sample_data_ts_nanos | 2023-10-23T13:52:55.015Z | 172.21.3.15 | 8268153 | Connection error +sample_data | 2023-10-23T13:52:55.015Z | 172.21.3.15 | 8268153 | Connection error +sample_data_ts_nanos | 2023-10-23T13:51:54.732Z | 172.21.3.15 | 725448 | Connection error +sample_data | 2023-10-23T13:51:54.732Z | 172.21.3.15 | 725448 | Connection error +sample_data_ts_nanos | 2023-10-23T13:33:34.937Z | 172.21.0.5 | 1232382 | Disconnected +sample_data | 2023-10-23T13:33:34.937Z | 172.21.0.5 | 1232382 | Disconnected +sample_data_ts_nanos | 2023-10-23T12:27:28.948Z | 172.21.2.113 | 2764889 | Connected to 10.1.0.2 +sample_data | 2023-10-23T12:27:28.948Z | 172.21.2.113 | 2764889 | Connected to 10.1.0.2 +sample_data_ts_nanos | 2023-10-23T12:15:03.360Z | 172.21.2.162 | 3450233 | Connected to 10.1.0.3 +sample_data | 2023-10-23T12:15:03.360Z | 172.21.2.162 | 3450233 | Connected to 10.1.0.3 +; + + multiIndexTsNanosRenameToNanosWithFiltering required_capability: to_date_nanos required_capability: date_nanos_binary_comparison From 094a81510c65e9ddd294137c369a716f707c1482 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 26 Nov 2024 20:05:26 +0000 Subject: [PATCH 056/129] Add `@UpdateForV9` annotations to `PutStoredScriptRequest` (#117582) We can remove some fields from `PutStoredScriptRequest` once the v9.0 transport protocol can deviate from the v8.last one. This commit adds reminder annotations to do this. Relates #117566 --- .../storedscripts/PutStoredScriptRequest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java index 8e453cd5bac3a..c3bdfc5a594c0 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/storedscripts/PutStoredScriptRequest.java @@ -18,6 +18,7 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.core.UpdateForV9; import org.elasticsearch.script.StoredScriptSource; import org.elasticsearch.xcontent.ToXContentFragment; import org.elasticsearch.xcontent.XContentBuilder; @@ -36,8 +37,20 @@ public class PutStoredScriptRequest extends AcknowledgedRequest Date: Tue, 26 Nov 2024 20:05:45 +0000 Subject: [PATCH 057/129] Add `@UpdateForV10` annotation to `allow_insecure_settings` (#117571) This hasn't really been necessary since reloadable secure settings landed in 7.0. It's been deprecated for a long time and the last known user has agreed to stop using it in v9. This commit adds a reminder to drop this functionality entirely in v10. --- .../java/org/elasticsearch/common/settings/SecureSetting.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/main/java/org/elasticsearch/common/settings/SecureSetting.java b/server/src/main/java/org/elasticsearch/common/settings/SecureSetting.java index 3d4f0d2d9dbf7..64fe57b3ea373 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/SecureSetting.java +++ b/server/src/main/java/org/elasticsearch/common/settings/SecureSetting.java @@ -11,6 +11,7 @@ import org.elasticsearch.common.util.ArrayUtils; import org.elasticsearch.core.Booleans; +import org.elasticsearch.core.UpdateForV10; import java.io.InputStream; import java.security.GeneralSecurityException; @@ -26,6 +27,7 @@ public abstract class SecureSetting extends Setting { /** Determines whether legacy settings with sensitive values should be allowed. */ + @UpdateForV10(owner = UpdateForV10.Owner.DISTRIBUTED_COORDINATION) // this should no longer be in use, even in v9, so can go away in v10 private static final boolean ALLOW_INSECURE_SETTINGS = Booleans.parseBoolean(System.getProperty("es.allow_insecure_settings", "false")); private static final Set ALLOWED_PROPERTIES = EnumSet.of( From 2e9ef4059fd049f45e67325a1ebfb79ef2d78561 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:28:36 +1100 Subject: [PATCH 058/129] Mute org.elasticsearch.reservedstate.service.FileSettingsServiceTests testStopWorksInMiddleOfProcessing #117591 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 1f092de410f8e..a54520fa66adf 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -243,6 +243,9 @@ tests: - 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 +- class: org.elasticsearch.reservedstate.service.FileSettingsServiceTests + method: testStopWorksInMiddleOfProcessing + issue: https://github.com/elastic/elasticsearch/issues/117591 # Examples: # From 82be243b648f9fe61705f8caa31f931ad0c95d9c Mon Sep 17 00:00:00 2001 From: Mark Vieira Date: Tue, 26 Nov 2024 14:54:31 -0800 Subject: [PATCH 059/129] Refactor preview feature task to better support composite builds (#117594) --- .../src/main/groovy/elasticsearch.ide.gradle | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/build-tools-internal/src/main/groovy/elasticsearch.ide.gradle b/build-tools-internal/src/main/groovy/elasticsearch.ide.gradle index 9237c3ae8918c..895cca2af7967 100644 --- a/build-tools-internal/src/main/groovy/elasticsearch.ide.gradle +++ b/build-tools-internal/src/main/groovy/elasticsearch.ide.gradle @@ -142,13 +142,18 @@ if (providers.systemProperty('idea.active').getOrNull() == 'true') { description = 'Enables preview features on native library module' dependsOn tasks.named("enableExternalConfiguration") - doLast { - ['main', 'test'].each { sourceSet -> - modifyXml(".idea/modules/libs/native/elasticsearch.libs.native.${sourceSet}.iml") { xml -> - xml.component.find { it.'@name' == 'NewModuleRootManager' }?.'@LANGUAGE_LEVEL' = 'JDK_21_PREVIEW' + ext { + enablePreview = { moduleFile, languageLevel -> + modifyXml(moduleFile) { xml -> + xml.component.find { it.'@name' == 'NewModuleRootManager' }?.'@LANGUAGE_LEVEL' = languageLevel } } } + + doLast { + enablePreview('.idea/modules/libs/native/elasticsearch.libs.native.main.iml', 'JDK_21_PREVIEW') + enablePreview('.idea/modules/libs/native/elasticsearch.libs.native.test.iml', 'JDK_21_PREVIEW') + } } tasks.register('buildDependencyArtifacts') { From 433a00c0ee70ee285987f7ee9125be791bb22b86 Mon Sep 17 00:00:00 2001 From: Max Hniebergall <137079448+maxhniebergall@users.noreply.github.com> Date: Tue, 26 Nov 2024 18:00:19 -0500 Subject: [PATCH 060/129] [ML] Fix for Deberta tokenizer when input sequence exceeds 512 tokens (#117595) * Add test and fix * Update docs/changelog/117595.yaml * Remove test which wasn't working --- docs/changelog/117595.yaml | 5 +++ .../nlp/tokenizers/NlpTokenizer.java | 23 ++++++++++++++ .../nlp/TextSimilarityProcessorTests.java | 31 +++++++++++++++++++ .../tokenizers/DebertaV2TokenizerTests.java | 4 +-- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/117595.yaml diff --git a/docs/changelog/117595.yaml b/docs/changelog/117595.yaml new file mode 100644 index 0000000000000..9360c372ac97e --- /dev/null +++ b/docs/changelog/117595.yaml @@ -0,0 +1,5 @@ +pr: 117595 +summary: Fix for Deberta tokenizer when input sequence exceeds 512 tokens +area: Machine Learning +type: bug +issues: [] diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/NlpTokenizer.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/NlpTokenizer.java index 0b4a5b651d8d4..930dbee304790 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/NlpTokenizer.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/NlpTokenizer.java @@ -331,6 +331,29 @@ public List tokenize(String seq1, String seq2, Tokeni tokenIdsSeq2 = tokenIdsSeq2.subList(0, maxSequenceLength() - extraTokens - tokenIdsSeq1.size()); tokenPositionMapSeq2 = tokenPositionMapSeq2.subList(0, maxSequenceLength() - extraTokens - tokenIdsSeq1.size()); } + case BALANCED -> { + isTruncated = true; + int firstSequenceLength = 0; + + if (tokenIdsSeq2.size() > (maxSequenceLength() - getNumExtraTokensForSeqPair()) / 2) { + firstSequenceLength = min(tokenIdsSeq1.size(), (maxSequenceLength() - getNumExtraTokensForSeqPair()) / 2); + } else { + firstSequenceLength = min( + tokenIdsSeq1.size(), + maxSequenceLength() - tokenIdsSeq2.size() - getNumExtraTokensForSeqPair() + ); + } + int secondSequenceLength = min( + tokenIdsSeq2.size(), + maxSequenceLength() - firstSequenceLength - getNumExtraTokensForSeqPair() + ); + + tokenIdsSeq1 = tokenIdsSeq1.subList(0, firstSequenceLength); + tokenPositionMapSeq1 = tokenPositionMapSeq1.subList(0, firstSequenceLength); + + tokenIdsSeq2 = tokenIdsSeq2.subList(0, secondSequenceLength); + tokenPositionMapSeq2 = tokenPositionMapSeq2.subList(0, secondSequenceLength); + } case NONE -> throw ExceptionsHelper.badRequestException( "Input too large. The tokenized input length [{}] exceeds the maximum sequence length [{}]", numTokens, diff --git a/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/TextSimilarityProcessorTests.java b/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/TextSimilarityProcessorTests.java index 3590793b81abd..7460e17055a00 100644 --- a/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/TextSimilarityProcessorTests.java +++ b/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/TextSimilarityProcessorTests.java @@ -10,11 +10,13 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.core.ml.inference.results.TextSimilarityInferenceResults; import org.elasticsearch.xpack.core.ml.inference.trainedmodel.BertTokenization; +import org.elasticsearch.xpack.core.ml.inference.trainedmodel.DebertaV2Tokenization; import org.elasticsearch.xpack.core.ml.inference.trainedmodel.TextSimilarityConfig; import org.elasticsearch.xpack.core.ml.inference.trainedmodel.Tokenization; import org.elasticsearch.xpack.core.ml.inference.trainedmodel.VocabularyConfig; import org.elasticsearch.xpack.ml.inference.nlp.tokenizers.BertTokenizationResult; import org.elasticsearch.xpack.ml.inference.nlp.tokenizers.BertTokenizer; +import org.elasticsearch.xpack.ml.inference.nlp.tokenizers.DebertaV2Tokenizer; import org.elasticsearch.xpack.ml.inference.nlp.tokenizers.TokenizationResult; import org.elasticsearch.xpack.ml.inference.pytorch.results.PyTorchInferenceResult; @@ -22,6 +24,8 @@ import java.util.List; import static org.elasticsearch.xpack.ml.inference.nlp.tokenizers.BertTokenizerTests.TEST_CASED_VOCAB; +import static org.elasticsearch.xpack.ml.inference.nlp.tokenizers.DebertaV2TokenizerTests.TEST_CASE_SCORES; +import static org.elasticsearch.xpack.ml.inference.nlp.tokenizers.DebertaV2TokenizerTests.TEST_CASE_VOCAB; import static org.hamcrest.Matchers.closeTo; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -62,6 +66,33 @@ public void testProcessor() throws IOException { assertThat(result.predictedValue(), closeTo(42, 1e-6)); } + public void testBalancedTruncationWithLongInput() throws IOException { + String question = "Is Elasticsearch scalable?"; + StringBuilder longInputBuilder = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + longInputBuilder.append(TEST_CASE_VOCAB.get(randomIntBetween(0, TEST_CASE_VOCAB.size() - 1))).append(i).append(" "); + } + String longInput = longInputBuilder.toString().trim(); + + DebertaV2Tokenization tokenization = new DebertaV2Tokenization(false, true, null, Tokenization.Truncate.BALANCED, -1); + DebertaV2Tokenizer tokenizer = DebertaV2Tokenizer.builder(TEST_CASE_VOCAB, TEST_CASE_SCORES, tokenization).build(); + TextSimilarityConfig textSimilarityConfig = new TextSimilarityConfig( + question, + new VocabularyConfig(""), + tokenization, + "result", + TextSimilarityConfig.SpanScoreFunction.MAX + ); + TextSimilarityProcessor processor = new TextSimilarityProcessor(tokenizer); + TokenizationResult tokenizationResult = processor.getRequestBuilder(textSimilarityConfig) + .buildRequest(List.of(longInput), "1", Tokenization.Truncate.BALANCED, -1, null) + .tokenization(); + + // Assert that the tokenization result is as expected + assertThat(tokenizationResult.anyTruncated(), is(true)); + assertThat(tokenizationResult.getTokenization(0).tokenIds().length, equalTo(512)); + } + public void testResultFunctions() { BertTokenization tokenization = new BertTokenization(false, true, 384, Tokenization.Truncate.NONE, 128); BertTokenizer tokenizer = BertTokenizer.builder(TEST_CASED_VOCAB, tokenization).build(); diff --git a/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/DebertaV2TokenizerTests.java b/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/DebertaV2TokenizerTests.java index a8461de8630ae..fc070ec25dc68 100644 --- a/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/DebertaV2TokenizerTests.java +++ b/x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/inference/nlp/tokenizers/DebertaV2TokenizerTests.java @@ -23,7 +23,7 @@ public class DebertaV2TokenizerTests extends ESTestCase { - private static final List TEST_CASE_VOCAB = List.of( + public static final List TEST_CASE_VOCAB = List.of( DebertaV2Tokenizer.CLASS_TOKEN, DebertaV2Tokenizer.PAD_TOKEN, DebertaV2Tokenizer.SEPARATOR_TOKEN, @@ -48,7 +48,7 @@ public class DebertaV2TokenizerTests extends ESTestCase { "<0xAD>", "▁" ); - private static final List TEST_CASE_SCORES = List.of( + public static final List TEST_CASE_SCORES = List.of( 0.0, 0.0, 0.0, From edd9d96fdf7141840a6051ec99883e4769a13b29 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Wed, 27 Nov 2024 11:08:13 +1100 Subject: [PATCH 061/129] Add a blank line between java and javax imports (#117602) This PR updates java and javax imports layout in editconfig to be consistent with spotless --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index cf4f74744d2b4..774fd201ef8d5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -209,7 +209,7 @@ indent_size = 4 max_line_length = 140 ij_java_class_count_to_use_import_on_demand = 999 ij_java_names_count_to_use_import_on_demand = 999 -ij_java_imports_layout = *,|,com.**,|,org.**,|,java.**,javax.**,|,$* +ij_java_imports_layout = *,|,com.**,|,org.**,|,java.**,|,javax.**,|,$* [*.json] indent_size = 2 From c5d155ec2b7f60ca68a75be784e1eae90e5ddf2f Mon Sep 17 00:00:00 2001 From: Mark Vieira Date: Tue, 26 Nov 2024 16:17:40 -0800 Subject: [PATCH 062/129] Increase test cluster node startup timeout (#117603) --- .../elasticsearch/gradle/testclusters/ElasticsearchNode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/testclusters/ElasticsearchNode.java b/build-tools/src/main/java/org/elasticsearch/gradle/testclusters/ElasticsearchNode.java index 90162591cfcef..4cb67e249b0b0 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/testclusters/ElasticsearchNode.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/testclusters/ElasticsearchNode.java @@ -98,7 +98,7 @@ public class ElasticsearchNode implements TestClusterConfiguration { private static final int ES_DESTROY_TIMEOUT = 20; private static final TimeUnit ES_DESTROY_TIMEOUT_UNIT = TimeUnit.SECONDS; - private static final int NODE_UP_TIMEOUT = 2; + private static final int NODE_UP_TIMEOUT = 3; private static final TimeUnit NODE_UP_TIMEOUT_UNIT = TimeUnit.MINUTES; private static final int ADDITIONAL_CONFIG_TIMEOUT = 15; private static final TimeUnit ADDITIONAL_CONFIG_TIMEOUT_UNIT = TimeUnit.SECONDS; From e7a9dcb180f9f12ccdf876eaa427b86ca873715d Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:48:12 +1100 Subject: [PATCH 063/129] Mute org.elasticsearch.repositories.s3.RepositoryS3ClientYamlTestSuiteIT org.elasticsearch.repositories.s3.RepositoryS3ClientYamlTestSuiteIT #117596 --- muted-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index a54520fa66adf..c97e46375c597 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -246,6 +246,8 @@ tests: - class: org.elasticsearch.reservedstate.service.FileSettingsServiceTests method: testStopWorksInMiddleOfProcessing issue: https://github.com/elastic/elasticsearch/issues/117591 +- class: org.elasticsearch.repositories.s3.RepositoryS3ClientYamlTestSuiteIT + issue: https://github.com/elastic/elasticsearch/issues/117596 # Examples: # From 1988bf10880749cef8a3d554c098eea4d8e4870b Mon Sep 17 00:00:00 2001 From: Martijn van Groningen Date: Wed, 27 Nov 2024 07:38:33 +0100 Subject: [PATCH 064/129] Add has_custom_cutoff_date to logsdb usage. (#117550) Indicates whether es.mapping.synthetic_source_fallback_to_stored_source.cutoff_date_restricted_override system property has been configured. A follow up from #116647 --- .../org/elasticsearch/TransportVersions.java | 2 + .../application/LogsDBFeatureSetUsage.java | 23 ++++++++-- .../logsdb/qa/with-custom-cutoff/build.gradle | 19 ++++++++ .../xpack/logsdb/LogsdbWithBasicRestIT.java | 45 +++++++++++++++++++ .../logsdb/LogsDBUsageTransportAction.java | 8 +++- .../logsdb/SyntheticSourceLicenseService.java | 5 +-- 6 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugin/logsdb/qa/with-custom-cutoff/build.gradle create mode 100644 x-pack/plugin/logsdb/qa/with-custom-cutoff/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsdbWithBasicRestIT.java diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index 6567f48d6c232..dda7d7e5d4c4c 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -204,9 +204,11 @@ static TransportVersion def(int id) { public static final TransportVersion FAST_REFRESH_RCO_2 = def(8_795_00_0); 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 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); /* * STOP! READ THIS FIRST! No, really, * ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _ diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/application/LogsDBFeatureSetUsage.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/application/LogsDBFeatureSetUsage.java index 2758ef73a98da..b32e95c5fc9d8 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/application/LogsDBFeatureSetUsage.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/application/LogsDBFeatureSetUsage.java @@ -22,6 +22,7 @@ public final class LogsDBFeatureSetUsage extends XPackFeatureUsage { private final int indicesWithSyntheticSource; private final long numDocs; private final long sizeInBytes; + private final boolean hasCustomCutoffDate; public LogsDBFeatureSetUsage(StreamInput input) throws IOException { super(input); @@ -34,6 +35,13 @@ public LogsDBFeatureSetUsage(StreamInput input) throws IOException { numDocs = 0; sizeInBytes = 0; } + var transportVersion = input.getTransportVersion(); + if (transportVersion.isPatchFrom(TransportVersions.LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE_FIX_8_17) + || transportVersion.onOrAfter(TransportVersions.LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE)) { + hasCustomCutoffDate = input.readBoolean(); + } else { + hasCustomCutoffDate = false; + } } @Override @@ -45,6 +53,11 @@ public void writeTo(StreamOutput out) throws IOException { out.writeVLong(numDocs); out.writeVLong(sizeInBytes); } + var transportVersion = out.getTransportVersion(); + if (transportVersion.isPatchFrom(TransportVersions.LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE_FIX_8_17) + || transportVersion.onOrAfter(TransportVersions.LOGSDB_TELEMETRY_CUSTOM_CUTOFF_DATE)) { + out.writeBoolean(hasCustomCutoffDate); + } } public LogsDBFeatureSetUsage( @@ -53,13 +66,15 @@ public LogsDBFeatureSetUsage( int indicesCount, int indicesWithSyntheticSource, long numDocs, - long sizeInBytes + long sizeInBytes, + boolean hasCustomCutoffDate ) { super(XPackField.LOGSDB, available, enabled); this.indicesCount = indicesCount; this.indicesWithSyntheticSource = indicesWithSyntheticSource; this.numDocs = numDocs; this.sizeInBytes = sizeInBytes; + this.hasCustomCutoffDate = hasCustomCutoffDate; } @Override @@ -74,11 +89,12 @@ protected void innerXContent(XContentBuilder builder, Params params) throws IOEx builder.field("indices_with_synthetic_source", indicesWithSyntheticSource); builder.field("num_docs", numDocs); builder.field("size_in_bytes", sizeInBytes); + builder.field("has_custom_cutoff_date", hasCustomCutoffDate); } @Override public int hashCode() { - return Objects.hash(available, enabled, indicesCount, indicesWithSyntheticSource, numDocs, sizeInBytes); + return Objects.hash(available, enabled, indicesCount, indicesWithSyntheticSource, numDocs, sizeInBytes, hasCustomCutoffDate); } @Override @@ -95,6 +111,7 @@ public boolean equals(Object obj) { && Objects.equals(indicesCount, other.indicesCount) && Objects.equals(indicesWithSyntheticSource, other.indicesWithSyntheticSource) && Objects.equals(numDocs, other.numDocs) - && Objects.equals(sizeInBytes, other.sizeInBytes); + && Objects.equals(sizeInBytes, other.sizeInBytes) + && Objects.equals(hasCustomCutoffDate, other.hasCustomCutoffDate); } } diff --git a/x-pack/plugin/logsdb/qa/with-custom-cutoff/build.gradle b/x-pack/plugin/logsdb/qa/with-custom-cutoff/build.gradle new file mode 100644 index 0000000000000..9729ac9c29cef --- /dev/null +++ b/x-pack/plugin/logsdb/qa/with-custom-cutoff/build.gradle @@ -0,0 +1,19 @@ +/* + * 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. + */ + +apply plugin: 'elasticsearch.internal-java-rest-test' + +dependencies { + javaRestTestImplementation(testArtifact(project(xpackModule('core')))) +} + +tasks.named("javaRestTest").configure { + // This test cluster is using a BASIC license and FIPS 140 mode is not supported in BASIC + buildParams.withFipsEnabledOnly(it) + + usesDefaultDistribution() +} diff --git a/x-pack/plugin/logsdb/qa/with-custom-cutoff/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsdbWithBasicRestIT.java b/x-pack/plugin/logsdb/qa/with-custom-cutoff/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsdbWithBasicRestIT.java new file mode 100644 index 0000000000000..3266e2e6e4757 --- /dev/null +++ b/x-pack/plugin/logsdb/qa/with-custom-cutoff/src/javaRestTest/java/org/elasticsearch/xpack/logsdb/LogsdbWithBasicRestIT.java @@ -0,0 +1,45 @@ +/* + * 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.logsdb; + +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.cluster.local.distribution.DistributionType; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.hamcrest.Matchers; +import org.junit.ClassRule; + +import java.io.IOException; +import java.util.Map; + +public class LogsdbWithBasicRestIT extends ESRestTestCase { + + @ClassRule + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .systemProperty("es.mapping.synthetic_source_fallback_to_stored_source.cutoff_date_restricted_override", "2027-12-31T23:59") + .setting("xpack.security.enabled", "false") + .setting("cluster.logsdb.enabled", "true") + .build(); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + public void testCustomCutoffDateUsage() throws IOException { + var response = getAsMap("/_xpack/usage"); + Map usage = (Map) response.get("logsdb"); + assertThat(usage, Matchers.hasEntry("available", true)); + assertThat(usage, Matchers.hasEntry("enabled", true)); + assertThat(usage, Matchers.hasEntry("indices_count", 0)); + assertThat(usage, Matchers.hasEntry("indices_with_synthetic_source", 0)); + assertThat(usage, Matchers.hasEntry("num_docs", 0)); + assertThat(usage, Matchers.hasEntry("size_in_bytes", 0)); + assertThat(usage, Matchers.hasEntry("has_custom_cutoff_date", true)); + } +} diff --git a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBUsageTransportAction.java b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBUsageTransportAction.java index 62e1eef3e0e97..f4fa2a29d79a0 100644 --- a/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBUsageTransportAction.java +++ b/x-pack/plugin/logsdb/src/main/java/org/elasticsearch/xpack/logsdb/LogsDBUsageTransportAction.java @@ -77,6 +77,7 @@ protected void masterOperation( } } final boolean enabled = LogsDBPlugin.CLUSTER_LOGSDB_ENABLED.get(clusterService.getSettings()); + final boolean hasCustomCutoffDate = System.getProperty(SyntheticSourceLicenseService.CUTOFF_DATE_SYS_PROP_NAME) != null; if (featureService.clusterHasFeature(state, XPackFeatures.LOGSDB_TELMETRY_STATS)) { final DiscoveryNode[] nodes = state.nodes().getDataNodes().values().toArray(DiscoveryNode[]::new); final var statsRequest = new IndexModeStatsActionType.StatsRequest(nodes); @@ -91,13 +92,16 @@ protected void masterOperation( finalNumIndices, finalNumIndicesWithSyntheticSources, indexStats.numDocs(), - indexStats.numBytes() + indexStats.numBytes(), + hasCustomCutoffDate ) ); })); } else { listener.onResponse( - new XPackUsageFeatureResponse(new LogsDBFeatureSetUsage(true, enabled, numIndices, numIndicesWithSyntheticSources, 0L, 0L)) + new XPackUsageFeatureResponse( + new LogsDBFeatureSetUsage(true, enabled, numIndices, numIndicesWithSyntheticSources, 0L, 0L, hasCustomCutoffDate) + ) ); } } 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 1b3513f15a86a..71de2f7909835 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 @@ -27,8 +27,7 @@ final class SyntheticSourceLicenseService { static final String MAPPINGS_FEATURE_FAMILY = "mappings"; // You can only override this property if you received explicit approval from Elastic. - private static final String CUTOFF_DATE_SYS_PROP_NAME = - "es.mapping.synthetic_source_fallback_to_stored_source.cutoff_date_restricted_override"; + 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(); @@ -129,7 +128,7 @@ private static long getCutoffDate(String cutoffDateAsString) { LOGGER.info( "Configuring [{}] to [{}]", CUTOFF_DATE_SYS_PROP_NAME, - LocalDateTime.ofInstant(Instant.ofEpochSecond(cutoffDate), ZoneOffset.UTC) + LocalDateTime.ofInstant(Instant.ofEpochMilli(cutoffDate), ZoneOffset.UTC) ); return cutoffDate; } else { From ef8ffc5ada043b1f71052cdd919b5ee419472c1a Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:13:43 +0100 Subject: [PATCH 065/129] Update docker.elastic.co/wolfi/chainguard-base:latest Docker digest to 32f06b1 (#117564) Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> --- .../main/java/org/elasticsearch/gradle/internal/DockerBase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java index 71e968557cefe..0fb75b59b6096 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/DockerBase.java @@ -22,7 +22,7 @@ public enum DockerBase { // Chainguard based wolfi image with latest jdk // This is usually updated via renovatebot // spotless:off - WOLFI("docker.elastic.co/wolfi/chainguard-base:latest@sha256:55b297da5151d2a2997e8ab9729fe1304e4869389d7090ab7031cc29530f69f8", + WOLFI("docker.elastic.co/wolfi/chainguard-base:latest@sha256:32f06b169bb4b0f257fbb10e8c8379f06d3ee1355c89b3327cb623781a29590e", "-wolfi", "apk" ), From 6130fbb0ea012b29f94a62df1d39abfcda247555 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 27 Nov 2024 08:17:42 +0000 Subject: [PATCH 066/129] Implement lifecycle on `SimulatePipelineRequest` (#117585) Rather than releasing the REST request body after computing the response, we can link the lifecycles of the REST and transport requests and release the REST request body sooner. Not that we expect these bodies to be particularly large in this case, but still it's a better pattern to follow. --- .../ingest/geoip/GeoIpDownloaderIT.java | 4 +-- .../elasticsearch/ingest/IngestClientIT.java | 3 +- .../ingest/SimulatePipelineRequest.java | 31 ++++++++++++++++--- .../SimulatePipelineRequestBuilder.java | 3 +- .../ingest/RestSimulatePipelineAction.java | 8 ++--- .../ingest/SimulatePipelineRequestTests.java | 9 ++---- .../ingest/IngestPipelineTestUtils.java | 16 ++++++++++ .../xpack/enrich/EnrichProcessorIT.java | 11 +++---- .../license/MachineLearningLicensingIT.java | 18 +++-------- .../TransportPreviewTransformAction.java | 6 +++- 10 files changed, 68 insertions(+), 41 deletions(-) diff --git a/modules/ingest-geoip/src/internalClusterTest/java/org/elasticsearch/ingest/geoip/GeoIpDownloaderIT.java b/modules/ingest-geoip/src/internalClusterTest/java/org/elasticsearch/ingest/geoip/GeoIpDownloaderIT.java index f8c8d2bd359f3..dd177fed5732a 100644 --- a/modules/ingest-geoip/src/internalClusterTest/java/org/elasticsearch/ingest/geoip/GeoIpDownloaderIT.java +++ b/modules/ingest-geoip/src/internalClusterTest/java/org/elasticsearch/ingest/geoip/GeoIpDownloaderIT.java @@ -41,7 +41,6 @@ import org.elasticsearch.search.sort.SortOrder; import org.elasticsearch.test.junit.annotations.TestLogging; import org.elasticsearch.xcontent.XContentBuilder; -import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xcontent.json.JsonXContent; import org.junit.After; @@ -67,6 +66,7 @@ import java.util.zip.GZIPInputStream; import static org.elasticsearch.ingest.ConfigurationUtils.readStringProperty; +import static org.elasticsearch.ingest.IngestPipelineTestUtils.jsonSimulatePipelineRequest; import static org.elasticsearch.ingest.geoip.GeoIpTestUtils.copyDefaultDatabases; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; @@ -494,7 +494,7 @@ private SimulateDocumentBaseResult simulatePipeline() throws IOException { builder.endObject(); bytes = BytesReference.bytes(builder); } - SimulatePipelineRequest simulateRequest = new SimulatePipelineRequest(bytes, XContentType.JSON); + SimulatePipelineRequest simulateRequest = jsonSimulatePipelineRequest(bytes); simulateRequest.setId("_id"); // Avoid executing on a coordinating only node, because databases are not available there and geoip processor won't do any lookups. // (some test seeds repeatedly hit such nodes causing failures) diff --git a/server/src/internalClusterTest/java/org/elasticsearch/ingest/IngestClientIT.java b/server/src/internalClusterTest/java/org/elasticsearch/ingest/IngestClientIT.java index c25ce822f8755..81a39dbe1f9f7 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/ingest/IngestClientIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/ingest/IngestClientIT.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Map; +import static org.elasticsearch.ingest.IngestPipelineTestUtils.jsonSimulatePipelineRequest; import static org.elasticsearch.ingest.IngestPipelineTestUtils.putJsonPipelineRequest; import static org.elasticsearch.test.NodeRoles.nonIngestNode; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; @@ -97,7 +98,7 @@ public void testSimulate() throws Exception { if (randomBoolean()) { response = clusterAdmin().prepareSimulatePipeline(bytes, XContentType.JSON).setId("_id").get(); } else { - SimulatePipelineRequest request = new SimulatePipelineRequest(bytes, XContentType.JSON); + SimulatePipelineRequest request = jsonSimulatePipelineRequest(bytes); request.setId("_id"); response = clusterAdmin().simulatePipeline(request).get(); } diff --git a/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequest.java b/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequest.java index 9cfc441490859..d6a2d81fdb7d3 100644 --- a/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequest.java +++ b/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequest.java @@ -12,6 +12,7 @@ import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.logging.DeprecationLogger; @@ -41,19 +42,20 @@ public class SimulatePipelineRequest extends ActionRequest implements ToXContent private static final DeprecationLogger deprecationLogger = DeprecationLogger.getLogger(SimulatePipelineRequest.class); private String id; private boolean verbose; - private final BytesReference source; + private final ReleasableBytesReference source; private final XContentType xContentType; private RestApiVersion restApiVersion; /** * Creates a new request with the given source and its content type */ - public SimulatePipelineRequest(BytesReference source, XContentType xContentType) { + public SimulatePipelineRequest(ReleasableBytesReference source, XContentType xContentType) { this(source, xContentType, RestApiVersion.current()); } - public SimulatePipelineRequest(BytesReference source, XContentType xContentType, RestApiVersion restApiVersion) { + public SimulatePipelineRequest(ReleasableBytesReference source, XContentType xContentType, RestApiVersion restApiVersion) { this.source = Objects.requireNonNull(source); + assert source.hasReferences(); this.xContentType = Objects.requireNonNull(xContentType); this.restApiVersion = restApiVersion; } @@ -62,7 +64,7 @@ public SimulatePipelineRequest(BytesReference source, XContentType xContentType, super(in); id = in.readOptionalString(); verbose = in.readBoolean(); - source = in.readBytesReference(); + source = in.readReleasableBytesReference(); xContentType = in.readEnum(XContentType.class); } @@ -88,6 +90,7 @@ public void setVerbose(boolean verbose) { } public BytesReference getSource() { + assert source.hasReferences(); return source; } @@ -250,4 +253,24 @@ private static List parseDocs(Map config, RestAp public RestApiVersion getRestApiVersion() { return restApiVersion; } + + @Override + public final void incRef() { + source.incRef(); + } + + @Override + public final boolean tryIncRef() { + return source.tryIncRef(); + } + + @Override + public final boolean decRef() { + return source.decRef(); + } + + @Override + public final boolean hasReferences() { + return source.hasReferences(); + } } diff --git a/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequestBuilder.java b/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequestBuilder.java index 05e30685c6a9b..931b86d15e24b 100644 --- a/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequestBuilder.java +++ b/server/src/main/java/org/elasticsearch/action/ingest/SimulatePipelineRequestBuilder.java @@ -12,6 +12,7 @@ import org.elasticsearch.action.ActionRequestBuilder; import org.elasticsearch.client.internal.ElasticsearchClient; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.xcontent.XContentType; public class SimulatePipelineRequestBuilder extends ActionRequestBuilder { @@ -20,7 +21,7 @@ public class SimulatePipelineRequestBuilder extends ActionRequestBuilder sourceTuple = restRequest.contentOrSourceParam(); - var content = sourceTuple.v2(); - SimulatePipelineRequest request = new SimulatePipelineRequest(sourceTuple.v2(), sourceTuple.v1(), restRequest.getRestApiVersion()); + final var request = new SimulatePipelineRequest(sourceTuple.v2(), sourceTuple.v1(), restRequest.getRestApiVersion()); request.setId(restRequest.param("id")); request.setVerbose(restRequest.paramAsBoolean("verbose", false)); - return channel -> client.admin() - .cluster() - .simulatePipeline(request, ActionListener.withRef(new RestToXContentListener<>(channel), content)); + return channel -> client.admin().cluster().simulatePipeline(request, new RestToXContentListener<>(channel)); } } diff --git a/server/src/test/java/org/elasticsearch/action/ingest/SimulatePipelineRequestTests.java b/server/src/test/java/org/elasticsearch/action/ingest/SimulatePipelineRequestTests.java index 58ff9ec421889..983c2e7d65032 100644 --- a/server/src/test/java/org/elasticsearch/action/ingest/SimulatePipelineRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/ingest/SimulatePipelineRequestTests.java @@ -16,14 +16,14 @@ import org.elasticsearch.xcontent.XContentType; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import static org.elasticsearch.ingest.IngestPipelineTestUtils.jsonSimulatePipelineRequest; import static org.hamcrest.CoreMatchers.equalTo; public class SimulatePipelineRequestTests extends ESTestCase { public void testSerialization() throws IOException { - SimulatePipelineRequest request = new SimulatePipelineRequest(new BytesArray(""), XContentType.JSON); + SimulatePipelineRequest request = jsonSimulatePipelineRequest(new BytesArray("")); // Sometimes we set an id if (randomBoolean()) { request.setId(randomAlphaOfLengthBetween(1, 10)); @@ -44,10 +44,7 @@ public void testSerialization() throws IOException { } public void testSerializationWithXContent() throws IOException { - SimulatePipelineRequest request = new SimulatePipelineRequest( - new BytesArray("{}".getBytes(StandardCharsets.UTF_8)), - XContentType.JSON - ); + SimulatePipelineRequest request = jsonSimulatePipelineRequest("{}"); assertEquals(XContentType.JSON, request.getXContentType()); BytesStreamOutput output = new BytesStreamOutput(); diff --git a/test/framework/src/main/java/org/elasticsearch/ingest/IngestPipelineTestUtils.java b/test/framework/src/main/java/org/elasticsearch/ingest/IngestPipelineTestUtils.java index 8fd3c61d4c9da..9888b1eb661ff 100644 --- a/test/framework/src/main/java/org/elasticsearch/ingest/IngestPipelineTestUtils.java +++ b/test/framework/src/main/java/org/elasticsearch/ingest/IngestPipelineTestUtils.java @@ -14,11 +14,13 @@ import org.elasticsearch.action.ingest.DeletePipelineTransportAction; import org.elasticsearch.action.ingest.PutPipelineRequest; import org.elasticsearch.action.ingest.PutPipelineTransportAction; +import org.elasticsearch.action.ingest.SimulatePipelineRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; import org.elasticsearch.client.internal.ElasticsearchClient; import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; import org.elasticsearch.test.ESTestCase; @@ -124,4 +126,18 @@ public void onFailure(Exception e) { ); } } + + /** + * Construct a new {@link SimulatePipelineRequest} whose content is the given JSON document, represented as a {@link String}. + */ + public static SimulatePipelineRequest jsonSimulatePipelineRequest(String jsonString) { + return jsonSimulatePipelineRequest(new BytesArray(jsonString)); + } + + /** + * Construct a new {@link SimulatePipelineRequest} whose content is the given JSON document, represented as a {@link BytesReference}. + */ + public static SimulatePipelineRequest jsonSimulatePipelineRequest(BytesReference jsonBytes) { + return new SimulatePipelineRequest(ReleasableBytesReference.wrap(jsonBytes), XContentType.JSON); + } } diff --git a/x-pack/plugin/enrich/src/internalClusterTest/java/org/elasticsearch/xpack/enrich/EnrichProcessorIT.java b/x-pack/plugin/enrich/src/internalClusterTest/java/org/elasticsearch/xpack/enrich/EnrichProcessorIT.java index d646aed11d7d9..5fc16034465d4 100644 --- a/x-pack/plugin/enrich/src/internalClusterTest/java/org/elasticsearch/xpack/enrich/EnrichProcessorIT.java +++ b/x-pack/plugin/enrich/src/internalClusterTest/java/org/elasticsearch/xpack/enrich/EnrichProcessorIT.java @@ -9,9 +9,7 @@ import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.action.ingest.SimulateDocumentBaseResult; -import org.elasticsearch.action.ingest.SimulatePipelineRequest; import org.elasticsearch.action.support.WriteRequest; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.ingest.common.IngestCommonPlugin; import org.elasticsearch.plugins.Plugin; @@ -27,6 +25,7 @@ import java.util.Collection; import java.util.List; +import static org.elasticsearch.ingest.IngestPipelineTestUtils.jsonSimulatePipelineRequest; import static org.elasticsearch.xpack.enrich.AbstractEnrichTestCase.createSourceIndices; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; @@ -90,7 +89,7 @@ public void testEnrichCacheValuesCannotBeCorrupted() { var executePolicyRequest = new ExecuteEnrichPolicyAction.Request(TEST_REQUEST_TIMEOUT, policyName); client().execute(ExecuteEnrichPolicyAction.INSTANCE, executePolicyRequest).actionGet(); - var simulatePipelineRequest = new SimulatePipelineRequest(new BytesArray(""" + var simulatePipelineRequest = jsonSimulatePipelineRequest(""" { "pipeline": { "processors": [ @@ -119,7 +118,7 @@ public void testEnrichCacheValuesCannotBeCorrupted() { } ] } - """), XContentType.JSON); + """); var response = clusterAdmin().simulatePipeline(simulatePipelineRequest).actionGet(); var result = (SimulateDocumentBaseResult) response.getResults().get(0); assertThat(result.getFailure(), nullValue()); @@ -132,7 +131,7 @@ public void testEnrichCacheValuesCannotBeCorrupted() { assertThat(statsResponse.getCacheStats().get(0).misses(), equalTo(1L)); assertThat(statsResponse.getCacheStats().get(0).hits(), equalTo(0L)); - simulatePipelineRequest = new SimulatePipelineRequest(new BytesArray(""" + simulatePipelineRequest = jsonSimulatePipelineRequest(""" { "pipeline": { "processors": [ @@ -155,7 +154,7 @@ public void testEnrichCacheValuesCannotBeCorrupted() { } ] } - """), XContentType.JSON); + """); response = clusterAdmin().simulatePipeline(simulatePipelineRequest).actionGet(); result = (SimulateDocumentBaseResult) response.getResults().get(0); assertThat(result.getFailure(), nullValue()); diff --git a/x-pack/plugin/ml/src/internalClusterTest/java/org/elasticsearch/license/MachineLearningLicensingIT.java b/x-pack/plugin/ml/src/internalClusterTest/java/org/elasticsearch/license/MachineLearningLicensingIT.java index 08d09f70cb46b..479fb20650b18 100644 --- a/x-pack/plugin/ml/src/internalClusterTest/java/org/elasticsearch/license/MachineLearningLicensingIT.java +++ b/x-pack/plugin/ml/src/internalClusterTest/java/org/elasticsearch/license/MachineLearningLicensingIT.java @@ -11,14 +11,12 @@ import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.action.ingest.SimulateDocumentBaseResult; import org.elasticsearch.action.ingest.SimulatePipelineAction; -import org.elasticsearch.action.ingest.SimulatePipelineRequest; import org.elasticsearch.action.ingest.SimulatePipelineResponse; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; import org.elasticsearch.cluster.ClusterState; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Strings; import org.elasticsearch.core.TimeValue; @@ -61,13 +59,13 @@ import org.elasticsearch.xpack.ml.support.BaseMlIntegTestCase; import org.junit.Before; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import static org.elasticsearch.ingest.IngestPipelineTestUtils.jsonSimulatePipelineRequest; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasItem; @@ -541,11 +539,7 @@ public void testMachineLearningCreateInferenceProcessorRestricted() { }}] }""", pipeline); PlainActionFuture simulatePipelineListener = new PlainActionFuture<>(); - client().execute( - SimulatePipelineAction.INSTANCE, - new SimulatePipelineRequest(new BytesArray(simulateSource.getBytes(StandardCharsets.UTF_8)), XContentType.JSON), - simulatePipelineListener - ); + client().execute(SimulatePipelineAction.INSTANCE, jsonSimulatePipelineRequest(simulateSource), simulatePipelineListener); assertThat(simulatePipelineListener.actionGet().getResults(), is(not(empty()))); @@ -575,7 +569,7 @@ public void testMachineLearningCreateInferenceProcessorRestricted() { // Simulating the pipeline should fail SimulateDocumentBaseResult simulateResponse = (SimulateDocumentBaseResult) client().execute( SimulatePipelineAction.INSTANCE, - new SimulatePipelineRequest(new BytesArray(simulateSource.getBytes(StandardCharsets.UTF_8)), XContentType.JSON) + jsonSimulatePipelineRequest(simulateSource) ).actionGet().getResults().get(0); assertThat(simulateResponse.getFailure(), is(not(nullValue()))); assertThat((simulateResponse.getFailure()).getCause(), is(instanceOf(ElasticsearchSecurityException.class))); @@ -588,11 +582,7 @@ public void testMachineLearningCreateInferenceProcessorRestricted() { putJsonPipeline("test_infer_license_pipeline", pipeline); PlainActionFuture simulatePipelineListenerNewLicense = new PlainActionFuture<>(); - client().execute( - SimulatePipelineAction.INSTANCE, - new SimulatePipelineRequest(new BytesArray(simulateSource.getBytes(StandardCharsets.UTF_8)), XContentType.JSON), - simulatePipelineListenerNewLicense - ); + client().execute(SimulatePipelineAction.INSTANCE, jsonSimulatePipelineRequest(simulateSource), simulatePipelineListenerNewLicense); assertThat(simulatePipelineListenerNewLicense.actionGet().getResults(), is(not(empty()))); diff --git a/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/action/TransportPreviewTransformAction.java b/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/action/TransportPreviewTransformAction.java index 36237d2705205..60f00da195974 100644 --- a/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/action/TransportPreviewTransformAction.java +++ b/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/action/TransportPreviewTransformAction.java @@ -21,6 +21,7 @@ import org.elasticsearch.cluster.node.DiscoveryNode; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.ReleasableBytesReference; import org.elasticsearch.common.logging.HeaderWarning; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.EsExecutors; @@ -282,7 +283,10 @@ private void getPreview( builder.startObject(); builder.field("docs", results); builder.endObject(); - var pipelineRequest = new SimulatePipelineRequest(BytesReference.bytes(builder), XContentType.JSON); + var pipelineRequest = new SimulatePipelineRequest( + ReleasableBytesReference.wrap(BytesReference.bytes(builder)), + XContentType.JSON + ); pipelineRequest.setId(pipeline); parentTaskClient.execute(SimulatePipelineAction.INSTANCE, pipelineRequest, pipelineResponseActionListener); } From c11e3c22991d39a95b71e992024d80d8eb677419 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 27 Nov 2024 08:18:54 +0000 Subject: [PATCH 067/129] Log shard `completed snapshot` message at `TRACE` (#117569) This message is on the happy path, no need to log it at `DEBUG`. Relates ES-8773 --- .../org/elasticsearch/snapshots/SnapshotShardsService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/snapshots/SnapshotShardsService.java b/server/src/main/java/org/elasticsearch/snapshots/SnapshotShardsService.java index 7b2066f243771..234c0239a68ce 100644 --- a/server/src/main/java/org/elasticsearch/snapshots/SnapshotShardsService.java +++ b/server/src/main/java/org/elasticsearch/snapshots/SnapshotShardsService.java @@ -425,9 +425,9 @@ public void onResponse(ShardSnapshotResult shardSnapshotResult) { final ShardGeneration newGeneration = shardSnapshotResult.getGeneration(); assert newGeneration != null; assert newGeneration.equals(snapshotStatus.generation()); - if (logger.isDebugEnabled()) { + if (logger.isTraceEnabled()) { final IndexShardSnapshotStatus.Copy lastSnapshotStatus = snapshotStatus.asCopy(); - logger.debug( + logger.trace( "[{}][{}] completed snapshot to [{}] with status [{}] at generation [{}]", shardId, snapshot, From 04dd9c22dae13e7a5ab67e8c3ea4b8228784f21a Mon Sep 17 00:00:00 2001 From: Iraklis Psaroudakis Date: Wed, 27 Nov 2024 12:10:22 +0200 Subject: [PATCH 068/129] Make fast refresh ineffective for search routing (#117455) Re-introduction of ES PR #114619. Now, fast refresh indices route searches/gets to search shards in stateless. Thus, this PR removes unnecessary code and simplifies some things. Relates ES-9563 --- ...ansportUnpromotableShardRefreshAction.java | 15 --------- .../action/get/TransportGetAction.java | 12 +++---- .../get/TransportShardMultiGetAction.java | 13 +++----- .../cluster/routing/IndexRoutingTable.java | 2 +- .../cluster/routing/OperationRouting.java | 19 +---------- .../cluster/routing/ShardRouting.java | 3 +- .../routing/IndexRoutingTableTests.java | 33 +++---------------- 7 files changed, 15 insertions(+), 82 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/refresh/TransportUnpromotableShardRefreshAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/refresh/TransportUnpromotableShardRefreshAction.java index 4458c008babcd..6c24ec2d17604 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/refresh/TransportUnpromotableShardRefreshAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/refresh/TransportUnpromotableShardRefreshAction.java @@ -24,9 +24,6 @@ import java.util.List; -import static org.elasticsearch.TransportVersions.FAST_REFRESH_RCO_2; -import static org.elasticsearch.index.IndexSettings.INDEX_FAST_REFRESH_SETTING; - public class TransportUnpromotableShardRefreshAction extends TransportBroadcastUnpromotableAction< UnpromotableShardRefreshRequest, ActionResponse.Empty> { @@ -76,18 +73,6 @@ protected void unpromotableShardOperation( return; } - // During an upgrade to FAST_REFRESH_RCO_2, we expect search shards to be first upgraded before the primary is upgraded. Thus, - // when the primary is upgraded, and starts to deliver unpromotable refreshes, we expect the search shards to be upgraded already. - // Note that the fast refresh setting is final. - // TODO: remove assertion (ES-9563) - assert INDEX_FAST_REFRESH_SETTING.get(shard.indexSettings().getSettings()) == false - || transportService.getLocalNodeConnection().getTransportVersion().onOrAfter(FAST_REFRESH_RCO_2) - : "attempted to refresh a fast refresh search shard " - + shard - + " on transport version " - + transportService.getLocalNodeConnection().getTransportVersion() - + " (before FAST_REFRESH_RCO_2)"; - ActionListener.run(responseListener, listener -> { shard.waitForPrimaryTermAndGeneration( request.getPrimaryTerm(), diff --git a/server/src/main/java/org/elasticsearch/action/get/TransportGetAction.java b/server/src/main/java/org/elasticsearch/action/get/TransportGetAction.java index fb4b3907d2bfd..a2c7c8664e81a 100644 --- a/server/src/main/java/org/elasticsearch/action/get/TransportGetAction.java +++ b/server/src/main/java/org/elasticsearch/action/get/TransportGetAction.java @@ -28,9 +28,9 @@ import org.elasticsearch.cluster.ClusterStateObserver; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; import org.elasticsearch.cluster.node.DiscoveryNode; -import org.elasticsearch.cluster.routing.OperationRouting; import org.elasticsearch.cluster.routing.PlainShardIterator; import org.elasticsearch.cluster.routing.ShardIterator; +import org.elasticsearch.cluster.routing.ShardRouting; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.core.TimeValue; @@ -109,10 +109,7 @@ protected ShardIterator shards(ClusterState state, InternalRequest request) { if (iterator == null) { return null; } - return new PlainShardIterator( - iterator.shardId(), - iterator.getShardRoutings().stream().filter(shardRouting -> OperationRouting.canSearchShard(shardRouting, state)).toList() - ); + return new PlainShardIterator(iterator.shardId(), iterator.getShardRoutings().stream().filter(ShardRouting::isSearchable).toList()); } @Override @@ -129,9 +126,8 @@ protected void asyncShardOperation(GetRequest request, ShardId shardId, ActionLi handleGetOnUnpromotableShard(request, indexShard, listener); return; } - // TODO: adapt assertion to assert only that it is not stateless (ES-9563) - assert DiscoveryNode.isStateless(clusterService.getSettings()) == false || indexShard.indexSettings().isFastRefresh() - : "in Stateless a promotable to primary shard can receive a TransportGetAction only if an index has the fast refresh setting"; + assert DiscoveryNode.isStateless(clusterService.getSettings()) == false + : "in Stateless a promotable to primary shard should not receive a TransportGetAction"; if (request.realtime()) { // we are not tied to a refresh cycle here anyway asyncGet(request, shardId, listener); } else { diff --git a/server/src/main/java/org/elasticsearch/action/get/TransportShardMultiGetAction.java b/server/src/main/java/org/elasticsearch/action/get/TransportShardMultiGetAction.java index 93e1b18ec64c6..0fa770df8e4ef 100644 --- a/server/src/main/java/org/elasticsearch/action/get/TransportShardMultiGetAction.java +++ b/server/src/main/java/org/elasticsearch/action/get/TransportShardMultiGetAction.java @@ -28,9 +28,9 @@ import org.elasticsearch.cluster.ClusterStateObserver; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; import org.elasticsearch.cluster.node.DiscoveryNode; -import org.elasticsearch.cluster.routing.OperationRouting; import org.elasticsearch.cluster.routing.PlainShardIterator; import org.elasticsearch.cluster.routing.ShardIterator; +import org.elasticsearch.cluster.routing.ShardRouting; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.core.TimeValue; @@ -113,10 +113,7 @@ protected ShardIterator shards(ClusterState state, InternalRequest request) { if (iterator == null) { return null; } - return new PlainShardIterator( - iterator.shardId(), - iterator.getShardRoutings().stream().filter(shardRouting -> OperationRouting.canSearchShard(shardRouting, state)).toList() - ); + return new PlainShardIterator(iterator.shardId(), iterator.getShardRoutings().stream().filter(ShardRouting::isSearchable).toList()); } @Override @@ -128,10 +125,8 @@ protected void asyncShardOperation(MultiGetShardRequest request, ShardId shardId handleMultiGetOnUnpromotableShard(request, indexShard, listener); return; } - // TODO: adapt assertion to assert only that it is not stateless (ES-9563) - assert DiscoveryNode.isStateless(clusterService.getSettings()) == false || indexShard.indexSettings().isFastRefresh() - : "in Stateless a promotable to primary shard can receive a TransportShardMultiGetAction only if an index has " - + "the fast refresh setting"; + assert DiscoveryNode.isStateless(clusterService.getSettings()) == false + : "in Stateless a promotable to primary shard should not receive a TransportShardMultiGetAction"; if (request.realtime()) { // we are not tied to a refresh cycle here anyway asyncShardMultiGet(request, shardId, listener); } else { diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/IndexRoutingTable.java b/server/src/main/java/org/elasticsearch/cluster/routing/IndexRoutingTable.java index 7cb0e457e36c7..bcacf21fcedbf 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/IndexRoutingTable.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/IndexRoutingTable.java @@ -241,7 +241,7 @@ public boolean readyForSearch(ClusterState clusterState) { boolean found = false; for (int idx = 0; idx < shardRoutingTable.size(); idx++) { ShardRouting shardRouting = shardRoutingTable.shard(idx); - if (shardRouting.active() && OperationRouting.canSearchShard(shardRouting, clusterState)) { + if (shardRouting.active() && shardRouting.isSearchable()) { found = true; break; } diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/OperationRouting.java b/server/src/main/java/org/elasticsearch/cluster/routing/OperationRouting.java index 13fc874f52e9f..5e2dbf1c5df5d 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/OperationRouting.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/OperationRouting.java @@ -32,9 +32,6 @@ import java.util.Set; import java.util.stream.Collectors; -import static org.elasticsearch.TransportVersions.FAST_REFRESH_RCO_2; -import static org.elasticsearch.index.IndexSettings.INDEX_FAST_REFRESH_SETTING; - public class OperationRouting { public static final Setting USE_ADAPTIVE_REPLICA_SELECTION_SETTING = Setting.boolSetting( @@ -151,7 +148,7 @@ private static List statefulShardsThatHandleSearches(ShardIterator } private static List statelessShardsThatHandleSearches(ClusterState clusterState, ShardIterator iterator) { - return iterator.getShardRoutings().stream().filter(shardRouting -> canSearchShard(shardRouting, clusterState)).toList(); + return iterator.getShardRoutings().stream().filter(ShardRouting::isSearchable).toList(); } public static ShardIterator getShards(ClusterState clusterState, ShardId shardId) { @@ -304,18 +301,4 @@ public ShardId shardId(ClusterState clusterState, String index, String id, @Null IndexMetadata indexMetadata = indexMetadata(clusterState, index); return new ShardId(indexMetadata.getIndex(), IndexRouting.fromIndexMetadata(indexMetadata).getShard(id, routing)); } - - public static boolean canSearchShard(ShardRouting shardRouting, ClusterState clusterState) { - // TODO: remove if and always return isSearchable (ES-9563) - if (INDEX_FAST_REFRESH_SETTING.get(clusterState.metadata().index(shardRouting.index()).getSettings())) { - // Until all the cluster is upgraded, we send searches/gets to the primary (even if it has been upgraded) to execute locally. - if (clusterState.getMinTransportVersion().onOrAfter(FAST_REFRESH_RCO_2)) { - return shardRouting.isSearchable(); - } else { - return shardRouting.isPromotableToPrimary(); - } - } else { - return shardRouting.isSearchable(); - } - } } diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/ShardRouting.java b/server/src/main/java/org/elasticsearch/cluster/routing/ShardRouting.java index 319786b558ddd..157d28e61057c 100644 --- a/server/src/main/java/org/elasticsearch/cluster/routing/ShardRouting.java +++ b/server/src/main/java/org/elasticsearch/cluster/routing/ShardRouting.java @@ -935,8 +935,7 @@ public boolean isPromotableToPrimary() { } /** - * Determine if role searchable. Consumers should prefer {@link OperationRouting#canSearchShard(ShardRouting, ClusterState)} to - * determine if a shard can be searched and {@link IndexRoutingTable#readyForSearch(ClusterState)} to determine if an index + * Determine if role searchable. Consumers should prefer {@link IndexRoutingTable#readyForSearch(ClusterState)} to determine if an index * is ready to be searched. */ public boolean isSearchable() { diff --git a/server/src/test/java/org/elasticsearch/cluster/routing/IndexRoutingTableTests.java b/server/src/test/java/org/elasticsearch/cluster/routing/IndexRoutingTableTests.java index e5786b1b3449e..912326162e5c4 100644 --- a/server/src/test/java/org/elasticsearch/cluster/routing/IndexRoutingTableTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/routing/IndexRoutingTableTests.java @@ -9,7 +9,6 @@ package org.elasticsearch.cluster.routing; -import org.elasticsearch.TransportVersion; import org.elasticsearch.cluster.ClusterState; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.settings.Settings; @@ -20,7 +19,6 @@ import java.util.List; -import static org.elasticsearch.TransportVersions.FAST_REFRESH_RCO_2; import static org.elasticsearch.index.IndexSettings.INDEX_FAST_REFRESH_SETTING; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -29,21 +27,10 @@ public class IndexRoutingTableTests extends ESTestCase { public void testReadyForSearch() { - innerReadyForSearch(false, false); - innerReadyForSearch(false, true); - innerReadyForSearch(true, false); - innerReadyForSearch(true, true); - } - - // TODO: remove if (fastRefresh && beforeFastRefreshRCO) branches (ES-9563) - private void innerReadyForSearch(boolean fastRefresh, boolean beforeFastRefreshRCO) { Index index = new Index(randomIdentifier(), UUIDs.randomBase64UUID()); ClusterState clusterState = mock(ClusterState.class, Mockito.RETURNS_DEEP_STUBS); when(clusterState.metadata().index(any(Index.class)).getSettings()).thenReturn( - Settings.builder().put(INDEX_FAST_REFRESH_SETTING.getKey(), fastRefresh).build() - ); - when(clusterState.getMinTransportVersion()).thenReturn( - beforeFastRefreshRCO ? TransportVersion.fromId(FAST_REFRESH_RCO_2.id() - 1_00_0) : TransportVersion.current() + Settings.builder().put(INDEX_FAST_REFRESH_SETTING.getKey(), randomBoolean()).build() ); // 2 primaries that are search and index ShardId p1 = new ShardId(index, 0); @@ -63,11 +50,7 @@ private void innerReadyForSearch(boolean fastRefresh, boolean beforeFastRefreshR shardTable1 = new IndexShardRoutingTable(p1, List.of(getShard(p1, true, ShardRoutingState.STARTED, ShardRouting.Role.INDEX_ONLY))); shardTable2 = new IndexShardRoutingTable(p2, List.of(getShard(p2, true, ShardRoutingState.STARTED, ShardRouting.Role.INDEX_ONLY))); indexRoutingTable = new IndexRoutingTable(index, new IndexShardRoutingTable[] { shardTable1, shardTable2 }); - if (fastRefresh && beforeFastRefreshRCO) { - assertTrue(indexRoutingTable.readyForSearch(clusterState)); - } else { - assertFalse(indexRoutingTable.readyForSearch(clusterState)); - } + assertFalse(indexRoutingTable.readyForSearch(clusterState)); // 2 unassigned primaries that are index only shardTable1 = new IndexShardRoutingTable( @@ -99,11 +82,7 @@ private void innerReadyForSearch(boolean fastRefresh, boolean beforeFastRefreshR ) ); indexRoutingTable = new IndexRoutingTable(index, new IndexShardRoutingTable[] { shardTable1, shardTable2 }); - if (fastRefresh && beforeFastRefreshRCO) { - assertTrue(indexRoutingTable.readyForSearch(clusterState)); - } else { - assertFalse(indexRoutingTable.readyForSearch(clusterState)); - } + assertFalse(indexRoutingTable.readyForSearch(clusterState)); // 2 primaries that are index only with some replicas that are all available shardTable1 = new IndexShardRoutingTable( @@ -143,11 +122,7 @@ private void innerReadyForSearch(boolean fastRefresh, boolean beforeFastRefreshR ) ); indexRoutingTable = new IndexRoutingTable(index, new IndexShardRoutingTable[] { shardTable1, shardTable2 }); - if (fastRefresh && beforeFastRefreshRCO) { - assertFalse(indexRoutingTable.readyForSearch(clusterState)); - } else { - assertTrue(indexRoutingTable.readyForSearch(clusterState)); - } + assertTrue(indexRoutingTable.readyForSearch(clusterState)); // 2 primaries that are index only with at least 1 replica per primary that is available shardTable1 = new IndexShardRoutingTable( From d7737e73065dd30da18c409616d242ee7f30ff3e Mon Sep 17 00:00:00 2001 From: Shamil Date: Wed, 27 Nov 2024 13:17:34 +0300 Subject: [PATCH 069/129] [ML] Remove ChunkingOptions parameter (#117235) --- docs/changelog/117235.yaml | 5 +++++ .../inference/ChunkingOptions.java | 19 ------------------- .../inference/InferenceService.java | 6 ------ .../TestDenseInferenceServiceExtension.java | 2 -- .../mock/TestRerankingServiceExtension.java | 2 -- .../TestSparseInferenceServiceExtension.java | 2 -- ...stStreamingCompletionServiceExtension.java | 2 -- .../ShardBulkInferenceActionFilter.java | 12 +----------- .../inference/services/SenderService.java | 5 +---- .../AlibabaCloudSearchService.java | 2 -- .../amazonbedrock/AmazonBedrockService.java | 2 -- .../services/anthropic/AnthropicService.java | 2 -- .../azureaistudio/AzureAiStudioService.java | 2 -- .../azureopenai/AzureOpenAiService.java | 2 -- .../services/cohere/CohereService.java | 2 -- .../elastic/ElasticInferenceService.java | 2 -- .../ElasticsearchInternalService.java | 5 +---- .../googleaistudio/GoogleAiStudioService.java | 2 -- .../googlevertexai/GoogleVertexAiService.java | 2 -- .../huggingface/HuggingFaceService.java | 2 -- .../elser/HuggingFaceElserService.java | 2 -- .../ibmwatsonx/IbmWatsonxService.java | 2 -- .../services/mistral/MistralService.java | 2 -- .../services/openai/OpenAiService.java | 2 -- .../ShardBulkInferenceActionFilterTests.java | 4 ++-- .../services/SenderServiceTests.java | 2 -- .../AlibabaCloudSearchServiceTests.java | 13 +------------ .../AmazonBedrockServiceTests.java | 2 -- .../AzureAiStudioServiceTests.java | 2 -- .../azureopenai/AzureOpenAiServiceTests.java | 2 -- .../services/cohere/CohereServiceTests.java | 3 --- .../elastic/ElasticInferenceServiceTests.java | 2 -- .../ElasticsearchInternalServiceTests.java | 8 -------- .../GoogleAiStudioServiceTests.java | 12 +----------- .../HuggingFaceElserServiceTests.java | 2 -- .../huggingface/HuggingFaceServiceTests.java | 3 --- .../ibmwatsonx/IbmWatsonxServiceTests.java | 12 +----------- .../services/mistral/MistralServiceTests.java | 2 -- .../services/openai/OpenAiServiceTests.java | 2 -- 39 files changed, 13 insertions(+), 146 deletions(-) create mode 100644 docs/changelog/117235.yaml delete mode 100644 server/src/main/java/org/elasticsearch/inference/ChunkingOptions.java diff --git a/docs/changelog/117235.yaml b/docs/changelog/117235.yaml new file mode 100644 index 0000000000000..dbf0b4cc18388 --- /dev/null +++ b/docs/changelog/117235.yaml @@ -0,0 +1,5 @@ +pr: 117235 +summary: "Deprecate `ChunkingOptions` parameter" +area: ES|QL +type: enhancement +issues: [] diff --git a/server/src/main/java/org/elasticsearch/inference/ChunkingOptions.java b/server/src/main/java/org/elasticsearch/inference/ChunkingOptions.java deleted file mode 100644 index 5953e2cb44ebf..0000000000000 --- a/server/src/main/java/org/elasticsearch/inference/ChunkingOptions.java +++ /dev/null @@ -1,19 +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", 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.inference; - -import org.elasticsearch.core.Nullable; - -public record ChunkingOptions(@Nullable Integer windowSize, @Nullable Integer span) { - - public boolean settingsArePresent() { - return windowSize != null || span != null; - } -} diff --git a/server/src/main/java/org/elasticsearch/inference/InferenceService.java b/server/src/main/java/org/elasticsearch/inference/InferenceService.java index c6e09f61befa0..4497254aad1f0 100644 --- a/server/src/main/java/org/elasticsearch/inference/InferenceService.java +++ b/server/src/main/java/org/elasticsearch/inference/InferenceService.java @@ -112,16 +112,11 @@ void infer( ); /** - * Chunk long text according to {@code chunkingOptions} or the - * model defaults if {@code chunkingOptions} contains unset - * values. - * * @param model The model * @param query Inference query, mainly for re-ranking * @param input Inference input * @param taskSettings Settings in the request to override the model's defaults * @param inputType For search, ingest etc - * @param chunkingOptions The window and span options to apply * @param timeout The timeout for the request * @param listener Chunked Inference result listener */ @@ -131,7 +126,6 @@ void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ); diff --git a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestDenseInferenceServiceExtension.java b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestDenseInferenceServiceExtension.java index 2ddc4f6c3e2f6..ae11a02d312e2 100644 --- a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestDenseInferenceServiceExtension.java +++ b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestDenseInferenceServiceExtension.java @@ -18,7 +18,6 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceExtension; @@ -140,7 +139,6 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestRerankingServiceExtension.java b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestRerankingServiceExtension.java index 2075c1b1924bf..9320571572f0a 100644 --- a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestRerankingServiceExtension.java +++ b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestRerankingServiceExtension.java @@ -17,7 +17,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceExtension; @@ -128,7 +127,6 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestSparseInferenceServiceExtension.java b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestSparseInferenceServiceExtension.java index 3d6f0ce6eba05..fe0223cce0323 100644 --- a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestSparseInferenceServiceExtension.java +++ b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestSparseInferenceServiceExtension.java @@ -17,7 +17,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceExtension; @@ -131,7 +130,6 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestStreamingCompletionServiceExtension.java b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestStreamingCompletionServiceExtension.java index 595b92a6be66b..6d7983bc8cb53 100644 --- a/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestStreamingCompletionServiceExtension.java +++ b/x-pack/plugin/inference/qa/test-service-plugin/src/main/java/org/elasticsearch/xpack/inference/mock/TestStreamingCompletionServiceExtension.java @@ -19,7 +19,6 @@ import org.elasticsearch.common.xcontent.ChunkedToXContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceExtension; @@ -160,7 +159,6 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java index dd59230e575c4..d178e927aa65d 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java @@ -30,7 +30,6 @@ import org.elasticsearch.core.Releasable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.InferenceService; import org.elasticsearch.inference.InferenceServiceRegistry; import org.elasticsearch.inference.InputType; @@ -337,16 +336,7 @@ private void onFinish() { } }; inferenceProvider.service() - .chunkedInfer( - inferenceProvider.model(), - null, - inputs, - Map.of(), - InputType.INGEST, - new ChunkingOptions(null, null), - TimeValue.MAX_VALUE, - completionListener - ); + .chunkedInfer(inferenceProvider.model(), null, inputs, Map.of(), InputType.INGEST, TimeValue.MAX_VALUE, completionListener); } private FieldInferenceResponseAccumulator ensureResponseAccumulatorSlot(int id) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/SenderService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/SenderService.java index b8a99227cf517..8e2dac1ef9db2 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/SenderService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/SenderService.java @@ -12,7 +12,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.InferenceService; import org.elasticsearch.inference.InferenceServiceResults; import org.elasticsearch.inference.InputType; @@ -76,13 +75,12 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { init(); // a non-null query is not supported and is dropped by all providers - doChunkedInfer(model, new DocumentsOnlyInput(input), taskSettings, inputType, chunkingOptions, timeout, listener); + doChunkedInfer(model, new DocumentsOnlyInput(input), taskSettings, inputType, timeout, listener); } protected abstract void doInfer( @@ -99,7 +97,6 @@ protected abstract void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java index 6d77663f49ece..d7ac7caed7efc 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -289,7 +288,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java index a69b9d2c70405..48b3c3df03e11 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockService.java @@ -17,7 +17,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -114,7 +113,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java index eba7353f2b12e..b3d503de8e3eb 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/anthropic/AnthropicService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -220,7 +219,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioService.java index a2f8dc409585e..bba331fc0b5df 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioService.java @@ -16,7 +16,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -107,7 +106,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java index 2f3a935cdf010..16c94dfa9ad94 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -261,7 +260,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java index cc67470686a02..b3d8b3b6efce3 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/cohere/CohereService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -260,7 +259,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceService.java index e7ce5903163d4..1f08c06edaa91 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceService.java @@ -16,7 +16,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -109,7 +108,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java index 6d124906d65bd..2ec3a9d629434 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalService.java @@ -19,7 +19,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceResults; @@ -676,11 +675,10 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { - chunkedInfer(model, null, input, taskSettings, inputType, chunkingOptions, timeout, listener); + chunkedInfer(model, null, input, taskSettings, inputType, timeout, listener); } @Override @@ -690,7 +688,6 @@ public void chunkedInfer( List input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioService.java index 1c01ebbe2c0e4..57a8a66a3f3a6 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -315,7 +314,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java index 204593464a4ad..857d475499aae 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/googlevertexai/GoogleVertexAiService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -213,7 +212,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceService.java index eede14a975234..51cca72f26054 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -116,7 +115,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/elser/HuggingFaceElserService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/elser/HuggingFaceElserService.java index a2e22e24172cf..75920efa251f2 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/elser/HuggingFaceElserService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/huggingface/elser/HuggingFaceElserService.java @@ -16,7 +16,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -88,7 +87,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java index 592900d117b39..ea263fb77a2da 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -283,7 +282,6 @@ protected void doChunkedInfer( DocumentsOnlyInput input, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/mistral/MistralService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/mistral/MistralService.java index 2e810c357f8bd..fe0edb851902b 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/mistral/MistralService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/mistral/MistralService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -95,7 +94,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/openai/OpenAiService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/openai/OpenAiService.java index 81ab87a461696..20ff1c617d21f 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/openai/OpenAiService.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/openai/OpenAiService.java @@ -15,7 +15,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -264,7 +263,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java index 770e6e3cb9cf4..2416aeb62ff33 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java @@ -291,7 +291,7 @@ private static ShardBulkInferenceActionFilter createFilter(ThreadPool threadPool StaticModel model = (StaticModel) invocationOnMock.getArguments()[0]; List inputs = (List) invocationOnMock.getArguments()[2]; ActionListener> listener = (ActionListener< - List>) invocationOnMock.getArguments()[7]; + List>) invocationOnMock.getArguments()[6]; Runnable runnable = () -> { List results = new ArrayList<>(); for (String input : inputs) { @@ -310,7 +310,7 @@ private static ShardBulkInferenceActionFilter createFilter(ThreadPool threadPool } return null; }; - doAnswer(chunkedInferAnswer).when(inferenceService).chunkedInfer(any(), any(), any(), any(), any(), any(), any(), any()); + doAnswer(chunkedInferAnswer).when(inferenceService).chunkedInfer(any(), any(), any(), any(), any(), any(), any()); Answer modelAnswer = invocationOnMock -> { String inferenceId = (String) invocationOnMock.getArguments()[0]; diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/SenderServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/SenderServiceTests.java index d8402c28cec87..47a96bf78dda1 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/SenderServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/SenderServiceTests.java @@ -12,7 +12,6 @@ import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySettingsConfiguration; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -126,7 +125,6 @@ protected void doChunkedInfer( DocumentsOnlyInput inputs, Map taskSettings, InputType inputType, - ChunkingOptions chunkingOptions, TimeValue timeout, ActionListener> listener ) { diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java index b6d29ccab9a49..a154ded395822 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/alibabacloudsearch/AlibabaCloudSearchServiceTests.java @@ -16,7 +16,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -401,7 +400,6 @@ public void testChunkedInfer_InvalidTaskType() throws IOException { List.of("foo", "bar"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); @@ -420,16 +418,7 @@ private void testChunkedInfer(TaskType taskType, ChunkingSettings chunkingSettin var model = createModelForTaskType(taskType, chunkingSettings); PlainActionFuture> listener = new PlainActionFuture<>(); - service.chunkedInfer( - model, - null, - input, - new HashMap<>(), - InputType.INGEST, - new ChunkingOptions(null, null), - InferenceAction.Request.DEFAULT_TIMEOUT, - listener - ); + service.chunkedInfer(model, null, input, new HashMap<>(), InputType.INGEST, InferenceAction.Request.DEFAULT_TIMEOUT, listener); var results = listener.actionGet(TIMEOUT); assertThat(results, instanceOf(List.class)); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java index e583e50075ee7..35b5642b7a60c 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/amazonbedrock/AmazonBedrockServiceTests.java @@ -20,7 +20,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -1559,7 +1558,6 @@ private void testChunkedInfer(AmazonBedrockEmbeddingsModel model) throws IOExcep List.of("abc", "xyz"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioServiceTests.java index 76ea7a5bde5ca..8636ba8890e87 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureaistudio/AzureAiStudioServiceTests.java @@ -20,7 +20,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -1194,7 +1193,6 @@ private void testChunkedInfer(AzureAiStudioEmbeddingsModel model) throws IOExcep List.of("foo", "bar"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java index dc1970e26a3f8..b0c590e237a44 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/azureopenai/AzureOpenAiServiceTests.java @@ -20,7 +20,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -1343,7 +1342,6 @@ private void testChunkedInfer(AzureOpenAiEmbeddingsModel model) throws IOExcepti List.of("foo", "bar"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java index 30f3b344a268c..259a32aa6254d 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/cohere/CohereServiceTests.java @@ -21,7 +21,6 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -1451,7 +1450,6 @@ private void testChunkedInfer(CohereEmbeddingsModel model) throws IOException { List.of("foo", "bar"), new HashMap<>(), InputType.UNSPECIFIED, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); @@ -1543,7 +1541,6 @@ public void testChunkedInfer_BatchesCalls_Bytes() throws IOException { List.of("foo", "bar"), new HashMap<>(), InputType.UNSPECIFIED, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceServiceTests.java index 3767ac496d183..d3101099d06c7 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elastic/ElasticInferenceServiceTests.java @@ -17,7 +17,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.EmptySecretSettings; import org.elasticsearch.inference.EmptyTaskSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -461,7 +460,6 @@ public void testChunkedInfer_PassesThrough() throws IOException { List.of("input text"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalServiceTests.java index 9a4d0dda82238..306509ea60cfc 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/elasticsearch/ElasticsearchInternalServiceTests.java @@ -25,7 +25,6 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptyTaskSettings; import org.elasticsearch.inference.InferenceResults; @@ -902,7 +901,6 @@ private void testChunkInfer_e5(ChunkingSettings chunkingSettings) throws Interru List.of("foo", "bar"), Map.of(), InputType.SEARCH, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, latchedListener ); @@ -973,7 +971,6 @@ private void testChunkInfer_Sparse(ChunkingSettings chunkingSettings) throws Int List.of("foo", "bar"), Map.of(), InputType.SEARCH, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, latchedListener ); @@ -1044,7 +1041,6 @@ private void testChunkInfer_Elser(ChunkingSettings chunkingSettings) throws Inte List.of("foo", "bar"), Map.of(), InputType.SEARCH, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, latchedListener ); @@ -1090,7 +1086,6 @@ public void testChunkInferSetsTokenization() { List.of("foo", "bar"), Map.of(), InputType.SEARCH, - null, InferenceAction.Request.DEFAULT_TIMEOUT, ActionListener.wrap(r -> fail("unexpected result"), e -> fail(e.getMessage())) ); @@ -1102,7 +1097,6 @@ public void testChunkInferSetsTokenization() { List.of("foo", "bar"), Map.of(), InputType.SEARCH, - new ChunkingOptions(256, null), InferenceAction.Request.DEFAULT_TIMEOUT, ActionListener.wrap(r -> fail("unexpected result"), e -> fail(e.getMessage())) ); @@ -1155,7 +1149,6 @@ public void testChunkInfer_FailsBatch() throws InterruptedException { List.of("foo", "bar", "baz"), Map.of(), InputType.SEARCH, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, latchedListener ); @@ -1228,7 +1221,6 @@ public void testChunkingLargeDocument() throws InterruptedException { List.of(input), Map.of(), InputType.SEARCH, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, latchedListener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioServiceTests.java index bc8020d8d88fe..375c583cce13a 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/googleaistudio/GoogleAiStudioServiceTests.java @@ -19,7 +19,6 @@ import org.elasticsearch.core.Strings; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptyTaskSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -870,16 +869,7 @@ private void testChunkedInfer(String modelId, String apiKey, GoogleAiStudioEmbed webServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseJson)); PlainActionFuture> listener = new PlainActionFuture<>(); - service.chunkedInfer( - model, - null, - input, - new HashMap<>(), - InputType.INGEST, - new ChunkingOptions(null, null), - InferenceAction.Request.DEFAULT_TIMEOUT, - listener - ); + service.chunkedInfer(model, null, input, new HashMap<>(), InputType.INGEST, InferenceAction.Request.DEFAULT_TIMEOUT, listener); var results = listener.actionGet(TIMEOUT); assertThat(results, hasSize(2)); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceElserServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceElserServiceTests.java index df82f1ed393bf..8f0e481213cdf 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceElserServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceElserServiceTests.java @@ -15,7 +15,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InputType; import org.elasticsearch.test.ESTestCase; @@ -98,7 +97,6 @@ public void testChunkedInfer_CallsInfer_Elser_ConvertsFloatResponse() throws IOE List.of("abc"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceServiceTests.java index 0ff4bd805ea36..022cbecd1ea6a 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/huggingface/HuggingFaceServiceTests.java @@ -19,7 +19,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -782,7 +781,6 @@ public void testChunkedInfer_CallsInfer_TextEmbedding_ConvertsFloatResponse() th List.of("abc"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); @@ -838,7 +836,6 @@ public void testChunkedInfer() throws IOException { List.of("abc"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java index 1261e3834437b..5aa826f1d80fe 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/ibmwatsonx/IbmWatsonxServiceTests.java @@ -19,7 +19,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.EmptyTaskSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; @@ -686,16 +685,7 @@ private void testChunkedInfer_Batches(ChunkingSettings chunkingSettings) throws getUrl(webServer) ); PlainActionFuture> listener = new PlainActionFuture<>(); - service.chunkedInfer( - model, - null, - input, - new HashMap<>(), - InputType.INGEST, - new ChunkingOptions(null, null), - InferenceAction.Request.DEFAULT_TIMEOUT, - listener - ); + service.chunkedInfer(model, null, input, new HashMap<>(), InputType.INGEST, InferenceAction.Request.DEFAULT_TIMEOUT, listener); var results = listener.actionGet(TIMEOUT); assertThat(results, hasSize(2)); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/mistral/MistralServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/mistral/MistralServiceTests.java index 71e9eac9a6635..73bf03fd43ec5 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/mistral/MistralServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/mistral/MistralServiceTests.java @@ -19,7 +19,6 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -673,7 +672,6 @@ public void testChunkedInfer(MistralEmbeddingsModel model) throws IOException { List.of("abc", "def"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/openai/OpenAiServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/openai/OpenAiServiceTests.java index 509a1f8a3d010..76b5d6fee2c59 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/openai/OpenAiServiceTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/openai/OpenAiServiceTests.java @@ -20,7 +20,6 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.TimeValue; import org.elasticsearch.inference.ChunkedInferenceServiceResults; -import org.elasticsearch.inference.ChunkingOptions; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.InferenceServiceConfiguration; import org.elasticsearch.inference.InferenceServiceResults; @@ -1558,7 +1557,6 @@ private void testChunkedInfer(OpenAiEmbeddingsModel model) throws IOException { List.of("foo", "bar"), new HashMap<>(), InputType.INGEST, - new ChunkingOptions(null, null), InferenceAction.Request.DEFAULT_TIMEOUT, listener ); From 9799d0082b5ca39f598dd71beda2c7823f88444b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenzo=20Dematt=C3=A9?= Date: Wed, 27 Nov 2024 11:31:02 +0100 Subject: [PATCH 070/129] [Entitlements] Add support for instrumenting constructors (#117332) --- .../impl/InstrumentationServiceImpl.java | 9 +- .../impl/InstrumenterImpl.java | 11 +- .../impl/InstrumentationServiceImplTests.java | 56 ++++++++++ .../impl/InstrumenterTests.java | 103 ++++++++++++++++-- .../bridge/EntitlementChecker.java | 14 +++ .../EntitlementInitialization.java | 4 - .../api/ElasticsearchEntitlementChecker.java | 34 ++++++ .../runtime/policy/FlagEntitlementType.java | 3 +- .../runtime/policy/PolicyManager.java | 2 +- .../test/entitlements/EntitlementsIT.java | 7 ++ .../entitlements/EntitlementsCheckPlugin.java | 3 +- ...estEntitlementsCheckClassLoaderAction.java | 54 +++++++++ .../bootstrap/Elasticsearch.java | 4 +- 13 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/RestEntitlementsCheckClassLoaderAction.java diff --git a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java index a3bbb611f3e68..16bd04e60c5e3 100644 --- a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java +++ b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java @@ -91,15 +91,18 @@ static MethodKey parseCheckerMethodSignature(String checkerMethodName, Type[] ch String.format( Locale.ROOT, "Checker method %s has incorrect name format. " - + "It should be either check$$methodName (instance) or check$package_ClassName$methodName (static)", + + "It should be either check$$methodName (instance), check$package_ClassName$methodName (static) or " + + "check$package_ClassName$ (ctor)", checkerMethodName ) ); } - // No "className" (check$$methodName) -> method is static, and we'll get the class from the actual typed argument + // No "className" (check$$methodName) -> method is instance, and we'll get the class from the actual typed argument final boolean targetMethodIsStatic = classNameStartIndex + 1 != classNameEndIndex; - final String targetMethodName = checkerMethodName.substring(classNameEndIndex + 1); + // No "methodName" (check$package_ClassName$) -> method is ctor + final boolean targetMethodIsCtor = classNameEndIndex + 1 == checkerMethodName.length(); + final String targetMethodName = targetMethodIsCtor ? "" : checkerMethodName.substring(classNameEndIndex + 1); final String targetClassName; final List targetParameterTypes; diff --git a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java index dc20b16400f3d..4d762dc997383 100644 --- a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java +++ b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java @@ -154,11 +154,12 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, Str var mv = super.visitMethod(access, name, descriptor, signature, exceptions); if (isAnnotationPresent == false) { boolean isStatic = (access & ACC_STATIC) != 0; + boolean isCtor = "".equals(name); var key = new MethodKey(className, name, Stream.of(Type.getArgumentTypes(descriptor)).map(Type::getInternalName).toList()); var instrumentationMethod = instrumentationMethods.get(key); if (instrumentationMethod != null) { // LOGGER.debug("Will instrument method {}", key); - return new EntitlementMethodVisitor(Opcodes.ASM9, mv, isStatic, descriptor, instrumentationMethod); + return new EntitlementMethodVisitor(Opcodes.ASM9, mv, isStatic, isCtor, descriptor, instrumentationMethod); } else { // LOGGER.trace("Will not instrument method {}", key); } @@ -187,6 +188,7 @@ private void addClassAnnotationIfNeeded() { class EntitlementMethodVisitor extends MethodVisitor { private final boolean instrumentedMethodIsStatic; + private final boolean instrumentedMethodIsCtor; private final String instrumentedMethodDescriptor; private final CheckerMethod instrumentationMethod; private boolean hasCallerSensitiveAnnotation = false; @@ -195,11 +197,13 @@ class EntitlementMethodVisitor extends MethodVisitor { int api, MethodVisitor methodVisitor, boolean instrumentedMethodIsStatic, + boolean instrumentedMethodIsCtor, String instrumentedMethodDescriptor, CheckerMethod instrumentationMethod ) { super(api, methodVisitor); this.instrumentedMethodIsStatic = instrumentedMethodIsStatic; + this.instrumentedMethodIsCtor = instrumentedMethodIsCtor; this.instrumentedMethodDescriptor = instrumentedMethodDescriptor; this.instrumentationMethod = instrumentationMethod; } @@ -260,14 +264,15 @@ private void pushCallerClass() { private void forwardIncomingArguments() { int localVarIndex = 0; - if (instrumentedMethodIsStatic == false) { + if (instrumentedMethodIsCtor) { + localVarIndex++; + } else if (instrumentedMethodIsStatic == false) { mv.visitVarInsn(Opcodes.ALOAD, localVarIndex++); } for (Type type : Type.getArgumentTypes(instrumentedMethodDescriptor)) { mv.visitVarInsn(type.getOpcode(Opcodes.ILOAD), localVarIndex); localVarIndex += type.getSize(); } - } private void invokeInstrumentationMethod() { diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java index c0ff5d59d3c72..5eee0bf27d1df 100644 --- a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java @@ -45,6 +45,12 @@ interface TestCheckerOverloads { void check$org_example_TestTargetClass$staticMethodWithOverload(Class clazz, int x, String y); } + interface TestCheckerCtors { + void check$org_example_TestTargetClass$(Class clazz); + + void check$org_example_TestTargetClass$(Class clazz, int x, String y); + } + public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundException { Map methodsMap = instrumentationService.lookupMethodsToInstrument(TestChecker.class.getName()); @@ -142,6 +148,38 @@ public void testInstrumentationTargetLookupWithOverloads() throws IOException, C ); } + public void testInstrumentationTargetLookupWithCtors() throws IOException, ClassNotFoundException { + Map methodsMap = instrumentationService.lookupMethodsToInstrument(TestCheckerCtors.class.getName()); + + assertThat(methodsMap, aMapWithSize(2)); + assertThat( + methodsMap, + hasEntry( + equalTo(new MethodKey("org/example/TestTargetClass", "", List.of("I", "java/lang/String"))), + equalTo( + new CheckerMethod( + "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerCtors", + "check$org_example_TestTargetClass$", + List.of("Ljava/lang/Class;", "I", "Ljava/lang/String;") + ) + ) + ) + ); + assertThat( + methodsMap, + hasEntry( + equalTo(new MethodKey("org/example/TestTargetClass", "", List.of())), + equalTo( + new CheckerMethod( + "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerCtors", + "check$org_example_TestTargetClass$", + List.of("Ljava/lang/Class;") + ) + ) + ) + ); + } + public void testParseCheckerMethodSignatureStaticMethod() { var methodKey = InstrumentationServiceImpl.parseCheckerMethodSignature( "check$org_example_TestClass$staticMethod", @@ -169,6 +207,24 @@ public void testParseCheckerMethodSignatureStaticMethodInnerClass() { assertThat(methodKey, equalTo(new MethodKey("org/example/TestClass$InnerClass", "staticMethod", List.of()))); } + public void testParseCheckerMethodSignatureCtor() { + var methodKey = InstrumentationServiceImpl.parseCheckerMethodSignature( + "check$org_example_TestClass$", + new Type[] { Type.getType(Class.class) } + ); + + assertThat(methodKey, equalTo(new MethodKey("org/example/TestClass", "", List.of()))); + } + + public void testParseCheckerMethodSignatureCtorWithArgs() { + var methodKey = InstrumentationServiceImpl.parseCheckerMethodSignature( + "check$org_example_TestClass$", + new Type[] { Type.getType(Class.class), Type.getType("I"), Type.getType(String.class) } + ); + + assertThat(methodKey, equalTo(new MethodKey("org/example/TestClass", "", List.of("I", "java/lang/String")))); + } + public void testParseCheckerMethodSignatureIncorrectName() { var exception = assertThrows( IllegalArgumentException.class, diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java index e3f5539999be5..40f0162d2eaa2 100644 --- a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java @@ -23,12 +23,15 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLStreamHandlerFactory; import java.util.Arrays; +import java.util.List; import java.util.Map; import static org.elasticsearch.entitlement.instrumentation.impl.ASMUtils.bytecode2text; import static org.elasticsearch.entitlement.instrumentation.impl.InstrumenterImpl.getClassFileInfo; -import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.startsWith; import static org.objectweb.asm.Opcodes.INVOKESTATIC; @@ -72,6 +75,11 @@ public interface Testable { * They must not throw {@link TestException}. */ public static class ClassToInstrument implements Testable { + + public ClassToInstrument() {} + + public ClassToInstrument(int arg) {} + public static void systemExit(int status) { assertEquals(123, status); } @@ -91,12 +99,20 @@ public static void someStaticMethod(int arg, String anotherArg) {} static final class TestException extends RuntimeException {} + /** + * Interface to test specific, "synthetic" cases (e.g. overloaded methods, overloaded constructors, etc.) that + * may be not present/may be difficult to find or not clear in the production EntitlementChecker interface + */ public interface MockEntitlementChecker extends EntitlementChecker { void checkSomeStaticMethod(Class clazz, int arg); void checkSomeStaticMethod(Class clazz, int arg, String anotherArg); void checkSomeInstanceMethod(Class clazz, Testable that, int arg, String anotherArg); + + void checkCtor(Class clazz); + + void checkCtor(Class clazz, int arg); } /** @@ -118,6 +134,9 @@ public static class TestEntitlementChecker implements MockEntitlementChecker { int checkSomeStaticMethodIntStringCallCount = 0; int checkSomeInstanceMethodCallCount = 0; + int checkCtorCallCount = 0; + int checkCtorIntCallCount = 0; + @Override public void check$java_lang_System$exit(Class callerClass, int status) { checkSystemExitCallCount++; @@ -126,6 +145,27 @@ public static class TestEntitlementChecker implements MockEntitlementChecker { throwIfActive(); } + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls) {} + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent) {} + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {} + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent) {} + + @Override + public void check$java_net_URLClassLoader$( + Class callerClass, + String name, + URL[] urls, + ClassLoader parent, + URLStreamHandlerFactory factory + ) {} + private void throwIfActive() { if (isActive) { throw new TestException(); @@ -161,6 +201,21 @@ public void checkSomeInstanceMethod(Class callerClass, Testable that, int arg assertEquals("def", anotherArg); throwIfActive(); } + + @Override + public void checkCtor(Class callerClass) { + checkCtorCallCount++; + assertSame(InstrumenterTests.class, callerClass); + throwIfActive(); + } + + @Override + public void checkCtor(Class callerClass, int arg) { + checkCtorIntCallCount++; + assertSame(InstrumenterTests.class, callerClass); + assertEquals(123, arg); + throwIfActive(); + } } public void testClassIsInstrumented() throws Exception { @@ -225,7 +280,7 @@ public void testClassIsNotInstrumentedTwice() throws Exception { getTestEntitlementChecker().checkSystemExitCallCount = 0; assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123)); - assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(1)); + assertEquals(1, getTestEntitlementChecker().checkSystemExitCallCount); } public void testClassAllMethodsAreInstrumentedFirstPass() throws Exception { @@ -259,10 +314,10 @@ public void testClassAllMethodsAreInstrumentedFirstPass() throws Exception { getTestEntitlementChecker().checkSystemExitCallCount = 0; assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123)); - assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(1)); + assertEquals(1, getTestEntitlementChecker().checkSystemExitCallCount); assertThrows(TestException.class, () -> callStaticMethod(newClass, "anotherSystemExit", 123)); - assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(2)); + assertEquals(2, getTestEntitlementChecker().checkSystemExitCallCount); } public void testInstrumenterWorksWithOverloads() throws Exception { @@ -294,8 +349,8 @@ public void testInstrumenterWorksWithOverloads() throws Exception { assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123, "abc")); - assertThat(getTestEntitlementChecker().checkSomeStaticMethodIntCallCount, is(1)); - assertThat(getTestEntitlementChecker().checkSomeStaticMethodIntStringCallCount, is(1)); + assertEquals(1, getTestEntitlementChecker().checkSomeStaticMethodIntCallCount); + assertEquals(1, getTestEntitlementChecker().checkSomeStaticMethodIntStringCallCount); } public void testInstrumenterWorksWithInstanceMethodsAndOverloads() throws Exception { @@ -327,7 +382,41 @@ public void testInstrumenterWorksWithInstanceMethodsAndOverloads() throws Except testTargetClass.someMethod(123); assertThrows(TestException.class, () -> testTargetClass.someMethod(123, "def")); - assertThat(getTestEntitlementChecker().checkSomeInstanceMethodCallCount, is(1)); + assertEquals(1, getTestEntitlementChecker().checkSomeInstanceMethodCallCount); + } + + public void testInstrumenterWorksWithConstructors() throws Exception { + var classToInstrument = ClassToInstrument.class; + + Map methods = Map.of( + new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of()), + getCheckerMethod(MockEntitlementChecker.class, "checkCtor", Class.class), + new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of("I")), + getCheckerMethod(MockEntitlementChecker.class, "checkCtor", Class.class, int.class) + ); + + var instrumenter = createInstrumenter(methods); + + byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); + + if (logger.isTraceEnabled()) { + logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); + } + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW", + newBytecode + ); + + getTestEntitlementChecker().isActive = true; + + var ex = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor().newInstance()); + assertThat(ex.getCause(), instanceOf(TestException.class)); + var ex2 = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor(int.class).newInstance(123)); + assertThat(ex2.getCause(), instanceOf(TestException.class)); + + assertEquals(1, getTestEntitlementChecker().checkCtorCallCount); + assertEquals(1, getTestEntitlementChecker().checkCtorIntCallCount); } /** This test doesn't replace classToInstrument in-place but instead loads a separate diff --git a/libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java b/libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java index 167c93c90df5c..ad0f14bcf4478 100644 --- a/libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java +++ b/libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java @@ -9,6 +9,20 @@ package org.elasticsearch.entitlement.bridge; +import java.net.URL; +import java.net.URLStreamHandlerFactory; + public interface EntitlementChecker { void check$java_lang_System$exit(Class callerClass, int status); + + // URLClassLoader ctor + void check$java_net_URLClassLoader$(Class callerClass, URL[] urls); + + void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent); + + void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory); + + void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent); + + void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory); } diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java index ca57e7b255bca..1f87e067e04f1 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java @@ -169,10 +169,6 @@ private static ElasticsearchEntitlementChecker initChecker() throws IOException } } - private static String internalName(Class c) { - return c.getName().replace('.', '/'); - } - private static final InstrumentationService INSTRUMENTER_FACTORY = new ProviderLocator<>( "entitlement", InstrumentationService.class, diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java index 790416ca5659a..28a080470c043 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java @@ -13,6 +13,9 @@ import org.elasticsearch.entitlement.runtime.policy.FlagEntitlementType; import org.elasticsearch.entitlement.runtime.policy.PolicyManager; +import java.net.URL; +import java.net.URLStreamHandlerFactory; + /** * Implementation of the {@link EntitlementChecker} interface, providing additional * API methods for managing the checks. @@ -29,4 +32,35 @@ public ElasticsearchEntitlementChecker(PolicyManager policyManager) { public void check$java_lang_System$exit(Class callerClass, int status) { policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.SYSTEM_EXIT); } + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls) { + policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.CREATE_CLASSLOADER); + } + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent) { + policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.CREATE_CLASSLOADER); + } + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) { + policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.CREATE_CLASSLOADER); + } + + @Override + public void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent) { + policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.CREATE_CLASSLOADER); + } + + @Override + public void check$java_net_URLClassLoader$( + Class callerClass, + String name, + URL[] urls, + ClassLoader parent, + URLStreamHandlerFactory factory + ) { + policyManager.checkFlagEntitlement(callerClass, FlagEntitlementType.CREATE_CLASSLOADER); + } } diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FlagEntitlementType.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FlagEntitlementType.java index 60490baf41a10..d40235ee12166 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FlagEntitlementType.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FlagEntitlementType.java @@ -10,5 +10,6 @@ package org.elasticsearch.entitlement.runtime.policy; public enum FlagEntitlementType { - SYSTEM_EXIT; + SYSTEM_EXIT, + CREATE_CLASSLOADER; } diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java index c06dc09758de5..b3fb5b75a1d5a 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java @@ -66,7 +66,7 @@ public void checkFlagEntitlement(Class callerClass, FlagEntitlementType type) // TODO: this will be checked using policies if (requestingModule.isNamed() && requestingModule.getName().equals("org.elasticsearch.server") - && type == FlagEntitlementType.SYSTEM_EXIT) { + && (type == FlagEntitlementType.SYSTEM_EXIT || type == FlagEntitlementType.CREATE_CLASSLOADER)) { logger.debug("Allowed: caller [{}] in module [{}] has entitlement [{}]", callerClass, requestingModule.getName(), type); return; } diff --git a/qa/entitlements/src/javaRestTest/java/org/elasticsearch/test/entitlements/EntitlementsIT.java b/qa/entitlements/src/javaRestTest/java/org/elasticsearch/test/entitlements/EntitlementsIT.java index 8b3629527f918..f8bae10492ba8 100644 --- a/qa/entitlements/src/javaRestTest/java/org/elasticsearch/test/entitlements/EntitlementsIT.java +++ b/qa/entitlements/src/javaRestTest/java/org/elasticsearch/test/entitlements/EntitlementsIT.java @@ -39,4 +39,11 @@ public void testCheckSystemExit() { ); assertThat(exception.getMessage(), containsString("not_entitled_exception")); } + + public void testCheckCreateURLClassLoader() { + var exception = expectThrows(IOException.class, () -> { + client().performRequest(new Request("GET", "/_entitlement/_check_create_url_classloader")); + }); + assertThat(exception.getMessage(), containsString("not_entitled_exception")); + } } diff --git a/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/EntitlementsCheckPlugin.java b/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/EntitlementsCheckPlugin.java index f3821c065eceb..94ad54c8c8ba8 100644 --- a/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/EntitlementsCheckPlugin.java +++ b/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/EntitlementsCheckPlugin.java @@ -22,7 +22,6 @@ import org.elasticsearch.rest.RestController; import org.elasticsearch.rest.RestHandler; -import java.util.Collections; import java.util.List; import java.util.function.Predicate; import java.util.function.Supplier; @@ -42,6 +41,6 @@ public List getRestHandlers( final Supplier nodesInCluster, Predicate clusterSupportsFeature ) { - return Collections.singletonList(new RestEntitlementsCheckSystemExitAction()); + return List.of(new RestEntitlementsCheckSystemExitAction(), new RestEntitlementsCheckClassLoaderAction()); } } diff --git a/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/RestEntitlementsCheckClassLoaderAction.java b/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/RestEntitlementsCheckClassLoaderAction.java new file mode 100644 index 0000000000000..0b5ca28739ed0 --- /dev/null +++ b/qa/entitlements/src/main/java/org/elasticsearch/test/entitlements/RestEntitlementsCheckClassLoaderAction.java @@ -0,0 +1,54 @@ +/* + * 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.test.entitlements; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.client.internal.node.NodeClient; +import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RestRequest; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.GET; + +public class RestEntitlementsCheckClassLoaderAction extends BaseRestHandler { + + private static final Logger logger = LogManager.getLogger(RestEntitlementsCheckClassLoaderAction.class); + + RestEntitlementsCheckClassLoaderAction() {} + + @Override + public List routes() { + return List.of(new Route(GET, "/_entitlement/_check_create_url_classloader")); + } + + @Override + public String getName() { + return "check_classloader_action"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + logger.info("RestEntitlementsCheckClassLoaderAction rest handler [{}]", request.path()); + if (request.path().equals("/_entitlement/_check_create_url_classloader")) { + return channel -> { + logger.info("Calling new URLClassLoader"); + try (var classLoader = new URLClassLoader("test", new URL[0], this.getClass().getClassLoader())) { + logger.info("Created URLClassLoader [{}]", classLoader.getName()); + } + }; + } + + throw new UnsupportedOperationException(); + } +} diff --git a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java index 95e5b00a2805f..b7774259bf289 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java @@ -210,7 +210,7 @@ private static void initPhase2(Bootstrap bootstrap) throws IOException { bootstrap.setPluginsLoader(pluginsLoader); if (Boolean.parseBoolean(System.getProperty("es.entitlements.enabled"))) { - logger.info("Bootstrapping Entitlements"); + LogManager.getLogger(Elasticsearch.class).info("Bootstrapping Entitlements"); List> pluginData = new ArrayList<>(); Set moduleBundles = PluginsUtils.getModuleBundles(nodeEnv.modulesFile()); @@ -225,7 +225,7 @@ private static void initPhase2(Bootstrap bootstrap) throws IOException { EntitlementBootstrap.bootstrap(pluginData, callerClass -> null); } else { // install SM after natives, shutdown hooks, etc. - logger.info("Bootstrapping java SecurityManager"); + LogManager.getLogger(Elasticsearch.class).info("Bootstrapping java SecurityManager"); org.elasticsearch.bootstrap.Security.configure( nodeEnv, SECURITY_FILTER_BAD_DEFAULTS_SETTING.get(args.nodeSettings()), From 9e610894143483ef234d447c420f08ccae73648d Mon Sep 17 00:00:00 2001 From: George Wallace Date: Wed, 27 Nov 2024 03:39:07 -0700 Subject: [PATCH 071/129] [DOCS] : swap allocation sections (#116518) Co-authored-by: Liam Thompson <32779855+leemthompo@users.noreply.github.com> --- .../inference/service-elser.asciidoc | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/docs/reference/inference/service-elser.asciidoc b/docs/reference/inference/service-elser.asciidoc index 262bdfbca002f..c1cc23c8c9adb 100644 --- a/docs/reference/inference/service-elser.asciidoc +++ b/docs/reference/inference/service-elser.asciidoc @@ -102,10 +102,39 @@ If `adaptive_allocations` is enabled, do not set this value, because it's automa Sets the number of threads used by each model allocation during inference. This generally increases the speed per inference request. The inference process is a compute-bound process; `threads_per_allocations` must not exceed the number of available allocated processors per node. Must be a power of 2. Max allowed value is 32. +[discrete] +[[inference-example-elser-adaptive-allocation]] +==== ELSER service example with adaptive allocations + +When adaptive allocations are enabled, the number of allocations of the model is set automatically based on the current load. + +NOTE: For more information on how to optimize your ELSER endpoints, refer to {ml-docs}/ml-nlp-elser.html#elser-recommendations[the ELSER recommendations] section in the model documentation. +To learn more about model autoscaling, refer to the {ml-docs}/ml-nlp-auto-scale.html[trained model autoscaling] page. + +The following example shows how to create an {infer} endpoint called `my-elser-model` to perform a `sparse_embedding` task type and configure adaptive allocations. + +The request below will automatically download the ELSER model if it isn't already downloaded and then deploy the model. + +[source,console] +------------------------------------------------------------ +PUT _inference/sparse_embedding/my-elser-model +{ + "service": "elser", + "service_settings": { + "adaptive_allocations": { + "enabled": true, + "min_number_of_allocations": 3, + "max_number_of_allocations": 10 + }, + "num_threads": 1 + } +} +------------------------------------------------------------ +// TEST[skip:TBD] [discrete] [[inference-example-elser]] -==== ELSER service example +==== ELSER service example without adaptive allocations The following example shows how to create an {infer} endpoint called `my-elser-model` to perform a `sparse_embedding` task type. Refer to the {ml-docs}/ml-nlp-elser.html[ELSER model documentation] for more info. @@ -151,32 +180,4 @@ You might see a 502 bad gateway error in the response when using the {kib} Conso This error usually just reflects a timeout, while the model downloads in the background. You can check the download progress in the {ml-app} UI. If using the Python client, you can set the `timeout` parameter to a higher value. -==== - -[discrete] -[[inference-example-elser-adaptive-allocation]] -==== Setting adaptive allocations for the ELSER service - -NOTE: For more information on how to optimize your ELSER endpoints, refer to {ml-docs}/ml-nlp-elser.html#elser-recommendations[the ELSER recommendations] section in the model documentation. -To learn more about model autoscaling, refer to the {ml-docs}/ml-nlp-auto-scale.html[trained model autoscaling] page. - -The following example shows how to create an {infer} endpoint called `my-elser-model` to perform a `sparse_embedding` task type and configure adaptive allocations. - -The request below will automatically download the ELSER model if it isn't already downloaded and then deploy the model. - -[source,console] ------------------------------------------------------------- -PUT _inference/sparse_embedding/my-elser-model -{ - "service": "elser", - "service_settings": { - "adaptive_allocations": { - "enabled": true, - "min_number_of_allocations": 3, - "max_number_of_allocations": 10 - }, - "num_threads": 1 - } -} ------------------------------------------------------------- -// TEST[skip:TBD] +==== \ No newline at end of file From 9946cea34dc711d6cc48fa49784e804f2421088d Mon Sep 17 00:00:00 2001 From: Armin Braun Date: Wed, 27 Nov 2024 11:52:23 +0100 Subject: [PATCH 072/129] Turn RankFeatureShardPhase into utility class (#117616) This class has no state, no need to pass instances of it around all its members can be static to simplify node construction and the code overall. --- .../elasticsearch/node/NodeConstruction.java | 1 - .../node/NodeServiceProvider.java | 3 --- .../org/elasticsearch/search/SearchModule.java | 5 ----- .../elasticsearch/search/SearchService.java | 7 ++----- .../rank/feature/RankFeatureShardPhase.java | 8 ++++---- .../rank/RankFeatureShardPhaseTests.java | 18 ++++++------------ .../snapshots/SnapshotResiliencyTests.java | 2 -- .../java/org/elasticsearch/node/MockNode.java | 4 ---- .../search/MockSearchService.java | 3 --- 9 files changed, 12 insertions(+), 39 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java index 2488ac894a612..795fe9e2771f0 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java +++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java @@ -1099,7 +1099,6 @@ private void construct( threadPool, scriptService, bigArrays, - searchModule.getRankFeatureShardPhase(), searchModule.getFetchPhase(), responseCollectorService, circuitBreakerService, diff --git a/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java b/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java index 8f2dc4e532ae0..a49958c476416 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java +++ b/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java @@ -35,7 +35,6 @@ import org.elasticsearch.script.ScriptService; import org.elasticsearch.search.SearchService; import org.elasticsearch.search.fetch.FetchPhase; -import org.elasticsearch.search.rank.feature.RankFeatureShardPhase; import org.elasticsearch.tasks.TaskManager; import org.elasticsearch.telemetry.tracing.Tracer; import org.elasticsearch.threadpool.ThreadPool; @@ -119,7 +118,6 @@ SearchService newSearchService( ThreadPool threadPool, ScriptService scriptService, BigArrays bigArrays, - RankFeatureShardPhase rankFeatureShardPhase, FetchPhase fetchPhase, ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, @@ -132,7 +130,6 @@ SearchService newSearchService( threadPool, scriptService, bigArrays, - rankFeatureShardPhase, fetchPhase, responseCollectorService, circuitBreakerService, diff --git a/server/src/main/java/org/elasticsearch/search/SearchModule.java b/server/src/main/java/org/elasticsearch/search/SearchModule.java index b8f50c6f9a62f..09e25350ad4fd 100644 --- a/server/src/main/java/org/elasticsearch/search/SearchModule.java +++ b/server/src/main/java/org/elasticsearch/search/SearchModule.java @@ -231,7 +231,6 @@ import org.elasticsearch.search.rank.RankDoc; import org.elasticsearch.search.rank.RankShardResult; import org.elasticsearch.search.rank.feature.RankFeatureDoc; -import org.elasticsearch.search.rank.feature.RankFeatureShardPhase; import org.elasticsearch.search.rank.feature.RankFeatureShardResult; import org.elasticsearch.search.rescore.QueryRescorerBuilder; import org.elasticsearch.search.rescore.RescorerBuilder; @@ -1299,10 +1298,6 @@ private void registerQuery(QuerySpec spec) { ); } - public RankFeatureShardPhase getRankFeatureShardPhase() { - return new RankFeatureShardPhase(); - } - public FetchPhase getFetchPhase() { return new FetchPhase(fetchSubPhases); } diff --git a/server/src/main/java/org/elasticsearch/search/SearchService.java b/server/src/main/java/org/elasticsearch/search/SearchService.java index a11c4013a9c9b..84bdc017ce970 100644 --- a/server/src/main/java/org/elasticsearch/search/SearchService.java +++ b/server/src/main/java/org/elasticsearch/search/SearchService.java @@ -286,7 +286,6 @@ public class SearchService extends AbstractLifecycleComponent implements IndexEv private final BigArrays bigArrays; private final FetchPhase fetchPhase; - private final RankFeatureShardPhase rankFeatureShardPhase; private volatile Executor searchExecutor; private volatile boolean enableQueryPhaseParallelCollection; @@ -325,7 +324,6 @@ public SearchService( ThreadPool threadPool, ScriptService scriptService, BigArrays bigArrays, - RankFeatureShardPhase rankFeatureShardPhase, FetchPhase fetchPhase, ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, @@ -339,7 +337,6 @@ public SearchService( this.scriptService = scriptService; this.responseCollectorService = responseCollectorService; this.bigArrays = bigArrays; - this.rankFeatureShardPhase = rankFeatureShardPhase; this.fetchPhase = fetchPhase; this.multiBucketConsumerService = new MultiBucketConsumerService( clusterService, @@ -751,9 +748,9 @@ public void executeRankFeaturePhase(RankFeatureShardRequest request, SearchShard searchContext.rankFeatureResult().incRef(); return searchContext.rankFeatureResult(); } - rankFeatureShardPhase.prepareForFetch(searchContext, request); + RankFeatureShardPhase.prepareForFetch(searchContext, request); fetchPhase.execute(searchContext, docIds, null); - rankFeatureShardPhase.processFetch(searchContext); + RankFeatureShardPhase.processFetch(searchContext); var rankFeatureResult = searchContext.rankFeatureResult(); rankFeatureResult.incRef(); return rankFeatureResult; diff --git a/server/src/main/java/org/elasticsearch/search/rank/feature/RankFeatureShardPhase.java b/server/src/main/java/org/elasticsearch/search/rank/feature/RankFeatureShardPhase.java index 68463eecfb11d..e64bbe3c39d79 100644 --- a/server/src/main/java/org/elasticsearch/search/rank/feature/RankFeatureShardPhase.java +++ b/server/src/main/java/org/elasticsearch/search/rank/feature/RankFeatureShardPhase.java @@ -35,9 +35,9 @@ public final class RankFeatureShardPhase { public static final RankFeatureShardResult EMPTY_RESULT = new RankFeatureShardResult(new RankFeatureDoc[0]); - public RankFeatureShardPhase() {} + private RankFeatureShardPhase() {} - public void prepareForFetch(SearchContext searchContext, RankFeatureShardRequest request) { + public static void prepareForFetch(SearchContext searchContext, RankFeatureShardRequest request) { if (logger.isTraceEnabled()) { logger.trace("{}", new SearchContextSourcePrinter(searchContext)); } @@ -58,7 +58,7 @@ public void prepareForFetch(SearchContext searchContext, RankFeatureShardRequest } } - public void processFetch(SearchContext searchContext) { + public static void processFetch(SearchContext searchContext) { if (logger.isTraceEnabled()) { logger.trace("{}", new SearchContextSourcePrinter(searchContext)); } @@ -92,7 +92,7 @@ public void processFetch(SearchContext searchContext) { } } - private RankFeaturePhaseRankShardContext shardContext(SearchContext searchContext) { + private static RankFeaturePhaseRankShardContext shardContext(SearchContext searchContext) { return searchContext.request().source() != null && searchContext.request().source().rankBuilder() != null ? searchContext.request().source().rankBuilder().buildRankFeaturePhaseShardContext() : null; diff --git a/server/src/test/java/org/elasticsearch/search/rank/RankFeatureShardPhaseTests.java b/server/src/test/java/org/elasticsearch/search/rank/RankFeatureShardPhaseTests.java index 6250d1679fda3..41febe77d54aa 100644 --- a/server/src/test/java/org/elasticsearch/search/rank/RankFeatureShardPhaseTests.java +++ b/server/src/test/java/org/elasticsearch/search/rank/RankFeatureShardPhaseTests.java @@ -219,8 +219,7 @@ public void testPrepareForFetch() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); - rankFeatureShardPhase.prepareForFetch(searchContext, request); + RankFeatureShardPhase.prepareForFetch(searchContext, request); assertNotNull(searchContext.fetchFieldsContext()); assertEquals(searchContext.fetchFieldsContext().fields().size(), 1); @@ -248,8 +247,7 @@ public void testPrepareForFetchNoRankFeatureContext() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); - rankFeatureShardPhase.prepareForFetch(searchContext, request); + RankFeatureShardPhase.prepareForFetch(searchContext, request); assertNull(searchContext.fetchFieldsContext()); assertNull(searchContext.fetchResult()); @@ -274,8 +272,7 @@ public void testPrepareForFetchWhileTaskIsCancelled() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); - expectThrows(TaskCancelledException.class, () -> rankFeatureShardPhase.prepareForFetch(searchContext, request)); + expectThrows(TaskCancelledException.class, () -> RankFeatureShardPhase.prepareForFetch(searchContext, request)); } } @@ -318,11 +315,10 @@ public void testProcessFetch() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); // this is called as part of the search context initialization // with the ResultsType.RANK_FEATURE type searchContext.addRankFeatureResult(); - rankFeatureShardPhase.processFetch(searchContext); + RankFeatureShardPhase.processFetch(searchContext); assertNotNull(searchContext.rankFeatureResult()); assertNotNull(searchContext.rankFeatureResult().rankFeatureResult()); @@ -365,11 +361,10 @@ public void testProcessFetchEmptyHits() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); // this is called as part of the search context initialization // with the ResultsType.RANK_FEATURE type searchContext.addRankFeatureResult(); - rankFeatureShardPhase.processFetch(searchContext); + RankFeatureShardPhase.processFetch(searchContext); assertNotNull(searchContext.rankFeatureResult()); assertNotNull(searchContext.rankFeatureResult().rankFeatureResult()); @@ -410,11 +405,10 @@ public void testProcessFetchWhileTaskIsCancelled() { RankFeatureShardRequest request = mock(RankFeatureShardRequest.class); when(request.getDocIds()).thenReturn(new int[] { 4, 9, numDocs - 1 }); - RankFeatureShardPhase rankFeatureShardPhase = new RankFeatureShardPhase(); // this is called as part of the search context initialization // with the ResultsType.RANK_FEATURE type searchContext.addRankFeatureResult(); - expectThrows(TaskCancelledException.class, () -> rankFeatureShardPhase.processFetch(searchContext)); + expectThrows(TaskCancelledException.class, () -> RankFeatureShardPhase.processFetch(searchContext)); } finally { if (searchHits != null) { searchHits.decRef(); diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java index cf240550e809d..ceaf7979ed60e 100644 --- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java +++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java @@ -180,7 +180,6 @@ import org.elasticsearch.search.SearchService; import org.elasticsearch.search.builder.SearchSourceBuilder; import org.elasticsearch.search.fetch.FetchPhase; -import org.elasticsearch.search.rank.feature.RankFeatureShardPhase; import org.elasticsearch.telemetry.TelemetryProvider; import org.elasticsearch.telemetry.tracing.Tracer; import org.elasticsearch.test.ClusterServiceUtils; @@ -2314,7 +2313,6 @@ public RecyclerBytesStreamOutput newNetworkBytesStream() { threadPool, scriptService, bigArrays, - new RankFeatureShardPhase(), new FetchPhase(Collections.emptyList()), responseCollectorService, new NoneCircuitBreakerService(), diff --git a/test/framework/src/main/java/org/elasticsearch/node/MockNode.java b/test/framework/src/main/java/org/elasticsearch/node/MockNode.java index 38c7b1eb04772..7fddeb8491c7f 100644 --- a/test/framework/src/main/java/org/elasticsearch/node/MockNode.java +++ b/test/framework/src/main/java/org/elasticsearch/node/MockNode.java @@ -42,7 +42,6 @@ import org.elasticsearch.search.MockSearchService; import org.elasticsearch.search.SearchService; import org.elasticsearch.search.fetch.FetchPhase; -import org.elasticsearch.search.rank.feature.RankFeatureShardPhase; import org.elasticsearch.tasks.TaskManager; import org.elasticsearch.telemetry.tracing.Tracer; import org.elasticsearch.test.ESTestCase; @@ -100,7 +99,6 @@ SearchService newSearchService( ThreadPool threadPool, ScriptService scriptService, BigArrays bigArrays, - RankFeatureShardPhase rankFeatureShardPhase, FetchPhase fetchPhase, ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, @@ -115,7 +113,6 @@ SearchService newSearchService( threadPool, scriptService, bigArrays, - rankFeatureShardPhase, fetchPhase, responseCollectorService, circuitBreakerService, @@ -129,7 +126,6 @@ SearchService newSearchService( threadPool, scriptService, bigArrays, - rankFeatureShardPhase, fetchPhase, responseCollectorService, circuitBreakerService, diff --git a/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java b/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java index 778a6e3106f49..179e1cd80cd4b 100644 --- a/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java +++ b/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java @@ -24,7 +24,6 @@ import org.elasticsearch.search.internal.ReaderContext; import org.elasticsearch.search.internal.SearchContext; import org.elasticsearch.search.internal.ShardSearchRequest; -import org.elasticsearch.search.rank.feature.RankFeatureShardPhase; import org.elasticsearch.telemetry.tracing.Tracer; import org.elasticsearch.threadpool.ThreadPool; @@ -83,7 +82,6 @@ public MockSearchService( ThreadPool threadPool, ScriptService scriptService, BigArrays bigArrays, - RankFeatureShardPhase rankFeatureShardPhase, FetchPhase fetchPhase, ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, @@ -96,7 +94,6 @@ public MockSearchService( threadPool, scriptService, bigArrays, - rankFeatureShardPhase, fetchPhase, responseCollectorService, circuitBreakerService, From 2ed318f21fc015609fa9b09d94115e3465c17615 Mon Sep 17 00:00:00 2001 From: Armin Braun Date: Wed, 27 Nov 2024 12:02:36 +0100 Subject: [PATCH 073/129] Remove unnecessary ResponseCollectorService dependency from SearchService (#117573) Small cleanup from a code-review earlier. SearchService isn't using this thing, it's only used by the transport action so that's where it should reside. Adjusted constructors accordingly and removed getter. --- .../action/search/TransportSearchAction.java | 6 +++++- .../java/org/elasticsearch/node/NodeConstruction.java | 5 +++-- .../java/org/elasticsearch/node/NodeServiceProvider.java | 2 -- .../java/org/elasticsearch/search/SearchService.java | 9 --------- .../action/search/TransportSearchActionTests.java | 1 + .../elasticsearch/snapshots/SnapshotResiliencyTests.java | 2 +- .../src/main/java/org/elasticsearch/node/MockNode.java | 3 --- .../java/org/elasticsearch/search/MockSearchService.java | 3 --- 8 files changed, 10 insertions(+), 21 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java index 4bca7a562fc38..5d1fb46a53cef 100644 --- a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java +++ b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java @@ -69,6 +69,7 @@ import org.elasticsearch.indices.ExecutorSelector; import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.injection.guice.Inject; +import org.elasticsearch.node.ResponseCollectorService; import org.elasticsearch.rest.action.search.SearchResponseMetrics; import org.elasticsearch.search.SearchPhaseResult; import org.elasticsearch.search.SearchService; @@ -151,6 +152,7 @@ public class TransportSearchAction extends HandledTransportAction getLocalShardsIterator( concreteIndices, routingMap, searchRequest.preference(), - searchService.getResponseCollectorService(), + responseCollectorService, searchTransportService.getPendingSearchRequests() ); final Map originalIndices = buildPerIndexOriginalIndices( diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java index 795fe9e2771f0..aec8eb0c3ca67 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java +++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java @@ -921,6 +921,9 @@ private void construct( final IndexingPressure indexingLimits = new IndexingPressure(settings); final IncrementalBulkService incrementalBulkService = new IncrementalBulkService(client, indexingLimits); + final ResponseCollectorService responseCollectorService = new ResponseCollectorService(clusterService); + modules.bindToInstance(ResponseCollectorService.class, responseCollectorService); + ActionModule actionModule = new ActionModule( settings, clusterModule.getIndexNameExpressionResolver(), @@ -1003,7 +1006,6 @@ private void construct( taskManager, telemetryProvider.getTracer() ); - final ResponseCollectorService responseCollectorService = new ResponseCollectorService(clusterService); final SearchResponseMetrics searchResponseMetrics = new SearchResponseMetrics(telemetryProvider.getMeterRegistry()); final SearchTransportService searchTransportService = new SearchTransportService( transportService, @@ -1100,7 +1102,6 @@ private void construct( scriptService, bigArrays, searchModule.getFetchPhase(), - responseCollectorService, circuitBreakerService, systemIndices.getExecutorSelector(), telemetryProvider.getTracer() diff --git a/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java b/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java index a49958c476416..4b7524a7ac011 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java +++ b/server/src/main/java/org/elasticsearch/node/NodeServiceProvider.java @@ -119,7 +119,6 @@ SearchService newSearchService( ScriptService scriptService, BigArrays bigArrays, FetchPhase fetchPhase, - ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, ExecutorSelector executorSelector, Tracer tracer @@ -131,7 +130,6 @@ SearchService newSearchService( scriptService, bigArrays, fetchPhase, - responseCollectorService, circuitBreakerService, executorSelector, tracer diff --git a/server/src/main/java/org/elasticsearch/search/SearchService.java b/server/src/main/java/org/elasticsearch/search/SearchService.java index 84bdc017ce970..e17709ed78318 100644 --- a/server/src/main/java/org/elasticsearch/search/SearchService.java +++ b/server/src/main/java/org/elasticsearch/search/SearchService.java @@ -73,7 +73,6 @@ import org.elasticsearch.indices.IndicesService; import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.indices.cluster.IndicesClusterStateService.AllocatedIndices.IndexRemovalReason; -import org.elasticsearch.node.ResponseCollectorService; import org.elasticsearch.script.FieldScript; import org.elasticsearch.script.ScriptService; import org.elasticsearch.search.aggregations.AggregationInitializationException; @@ -279,8 +278,6 @@ public class SearchService extends AbstractLifecycleComponent implements IndexEv private final ScriptService scriptService; - private final ResponseCollectorService responseCollectorService; - private final ExecutorSelector executorSelector; private final BigArrays bigArrays; @@ -325,7 +322,6 @@ public SearchService( ScriptService scriptService, BigArrays bigArrays, FetchPhase fetchPhase, - ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, ExecutorSelector executorSelector, Tracer tracer @@ -335,7 +331,6 @@ public SearchService( this.clusterService = clusterService; this.indicesService = indicesService; this.scriptService = scriptService; - this.responseCollectorService = responseCollectorService; this.bigArrays = bigArrays; this.fetchPhase = fetchPhase; this.multiBucketConsumerService = new MultiBucketConsumerService( @@ -1535,10 +1530,6 @@ public int getOpenScrollContexts() { return openScrollContexts.get(); } - public ResponseCollectorService getResponseCollectorService() { - return this.responseCollectorService; - } - public long getDefaultKeepAliveInMillis() { return defaultKeepAlive; } diff --git a/server/src/test/java/org/elasticsearch/action/search/TransportSearchActionTests.java b/server/src/test/java/org/elasticsearch/action/search/TransportSearchActionTests.java index a9de118c6b859..367508283bb93 100644 --- a/server/src/test/java/org/elasticsearch/action/search/TransportSearchActionTests.java +++ b/server/src/test/java/org/elasticsearch/action/search/TransportSearchActionTests.java @@ -1758,6 +1758,7 @@ protected void doWriteTo(StreamOutput out) throws IOException { new NoneCircuitBreakerService(), transportService, searchService, + null, new SearchTransportService(transportService, client, null), null, clusterService, diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java index ceaf7979ed60e..b7f33151961ea 100644 --- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java +++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java @@ -2314,7 +2314,6 @@ public RecyclerBytesStreamOutput newNetworkBytesStream() { scriptService, bigArrays, new FetchPhase(Collections.emptyList()), - responseCollectorService, new NoneCircuitBreakerService(), EmptySystemIndices.INSTANCE.getExecutorSelector(), Tracer.NOOP @@ -2481,6 +2480,7 @@ public RecyclerBytesStreamOutput newNetworkBytesStream() { new NoneCircuitBreakerService(), transportService, searchService, + responseCollectorService, searchTransportService, searchPhaseController, clusterService, diff --git a/test/framework/src/main/java/org/elasticsearch/node/MockNode.java b/test/framework/src/main/java/org/elasticsearch/node/MockNode.java index 7fddeb8491c7f..d3bfacdf7691a 100644 --- a/test/framework/src/main/java/org/elasticsearch/node/MockNode.java +++ b/test/framework/src/main/java/org/elasticsearch/node/MockNode.java @@ -100,7 +100,6 @@ SearchService newSearchService( ScriptService scriptService, BigArrays bigArrays, FetchPhase fetchPhase, - ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, ExecutorSelector executorSelector, Tracer tracer @@ -114,7 +113,6 @@ SearchService newSearchService( scriptService, bigArrays, fetchPhase, - responseCollectorService, circuitBreakerService, executorSelector, tracer @@ -127,7 +125,6 @@ SearchService newSearchService( scriptService, bigArrays, fetchPhase, - responseCollectorService, circuitBreakerService, executorSelector, tracer diff --git a/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java b/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java index 179e1cd80cd4b..79c61cacb58eb 100644 --- a/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java +++ b/test/framework/src/main/java/org/elasticsearch/search/MockSearchService.java @@ -17,7 +17,6 @@ import org.elasticsearch.indices.IndicesService; import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.node.MockNode; -import org.elasticsearch.node.ResponseCollectorService; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.script.ScriptService; import org.elasticsearch.search.fetch.FetchPhase; @@ -83,7 +82,6 @@ public MockSearchService( ScriptService scriptService, BigArrays bigArrays, FetchPhase fetchPhase, - ResponseCollectorService responseCollectorService, CircuitBreakerService circuitBreakerService, ExecutorSelector executorSelector, Tracer tracer @@ -95,7 +93,6 @@ public MockSearchService( scriptService, bigArrays, fetchPhase, - responseCollectorService, circuitBreakerService, executorSelector, tracer From 560e0c5d0441a165f4588f8af869053b5202999f Mon Sep 17 00:00:00 2001 From: Bogdan Pintea Date: Wed, 27 Nov 2024 14:59:42 +0100 Subject: [PATCH 074/129] ESQL: fix COUNT filter pushdown (#117503) If `COUNT` agg has a filter applied, this must also be push down to source. This currently does not happen, but this issue is masked currently by two factors: * a logical optimisation, `ExtractAggregateCommonFilter` that extracts the filter out of the STATS entirely (and pushes it to source then from a `WHERE`); * the phisical plan optimisation implementing the push down, `PushStatsToSource`, currently only applies if there's just one agg function to push down. However, this fix needs to be applied since: * it's still present in versions prior to `ExtractAggregateCommonFilter` introduction; * the defect might resurface when the restriction in `PushStatsToSource` is lifted. Fixes #115522. --- docs/changelog/117503.yaml | 6 ++ .../src/main/resources/stats.csv-spec | 31 +++++++++ .../physical/local/PushStatsToSource.java | 11 ++++ .../LocalPhysicalPlanOptimizerTests.java | 66 +++++++++++++++++++ .../esql/optimizer/TestPlannerOptimizer.java | 10 +-- 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/117503.yaml diff --git a/docs/changelog/117503.yaml b/docs/changelog/117503.yaml new file mode 100644 index 0000000000000..d48741262b581 --- /dev/null +++ b/docs/changelog/117503.yaml @@ -0,0 +1,6 @@ +pr: 117503 +summary: Fix COUNT filter pushdown +area: ES|QL +type: bug +issues: + - 115522 diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec index f95506ff1982f..d76f4c05d955f 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec @@ -2688,6 +2688,16 @@ c1:long 41 ; +simpleCountOnFieldWithFilteringOnDifferentFieldAndNoGrouping +required_capability: per_agg_filtering +from employees +| stats c1 = count(hire_date) where emp_no < 10042 +; + +c1:long +41 +; + simpleCountOnStarWithFilteringAndNoGrouping required_capability: per_agg_filtering from employees @@ -2698,6 +2708,27 @@ c1:long 41 ; +simpleCountWithFilteringAndNoGroupingOnFieldWithNulls +required_capability: per_agg_filtering +from employees +| stats c1 = count(birth_date) where emp_no <= 10050 +; + +c1:long +40 +; + + +simpleCountWithFilteringAndNoGroupingOnFieldWithMultivalues +required_capability: per_agg_filtering +from employees +| stats c1 = count(job_positions) where emp_no <= 10003 +; + +c1:long +3 +; + commonFilterExtractionWithAliasing required_capability: per_agg_filtering from employees diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java index b0b86b43cd162..21bc360404628 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java @@ -16,6 +16,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; import org.elasticsearch.xpack.esql.core.expression.NamedExpression; +import org.elasticsearch.xpack.esql.core.util.Queries; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.expression.function.aggregate.Count; import org.elasticsearch.xpack.esql.optimizer.LocalPhysicalOptimizerContext; @@ -25,12 +26,15 @@ import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec; import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan; import org.elasticsearch.xpack.esql.planner.AbstractPhysicalOperationProviders; +import org.elasticsearch.xpack.esql.planner.PlannerUtils; import java.util.ArrayList; import java.util.List; +import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; +import static org.elasticsearch.xpack.esql.optimizer.rules.physical.local.PushFiltersToSource.canPushToSource; import static org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec.StatsType.COUNT; /** @@ -98,6 +102,13 @@ private Tuple, List> pushableStats( } } if (fieldName != null) { + if (count.hasFilter()) { + if (canPushToSource(count.filter()) == false) { + return null; // can't push down + } + var countFilter = PlannerUtils.TRANSLATOR_HANDLER.asQuery(count.filter()); + query = Queries.combine(Queries.Clause.MUST, asList(countFilter.asBuilder(), query)); + } return new EsStatsQueryExec.Stat(fieldName, COUNT, query); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java index 4612ccb425ba2..86f5c812737b1 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java @@ -42,7 +42,9 @@ import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry; import org.elasticsearch.xpack.esql.index.EsIndex; import org.elasticsearch.xpack.esql.index.IndexResolution; +import org.elasticsearch.xpack.esql.optimizer.rules.logical.ExtractAggregateCommonFilter; import org.elasticsearch.xpack.esql.plan.logical.Enrich; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; import org.elasticsearch.xpack.esql.plan.physical.AggregateExec; import org.elasticsearch.xpack.esql.plan.physical.EsQueryExec; import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec; @@ -59,6 +61,7 @@ import org.elasticsearch.xpack.esql.planner.FilterTests; import org.elasticsearch.xpack.esql.plugin.QueryPragmas; import org.elasticsearch.xpack.esql.querydsl.query.SingleValueQuery; +import org.elasticsearch.xpack.esql.rule.Rule; import org.elasticsearch.xpack.esql.session.Configuration; import org.elasticsearch.xpack.esql.stats.Metrics; import org.elasticsearch.xpack.esql.stats.SearchContextStats; @@ -67,9 +70,11 @@ import org.junit.Before; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Function; import static java.util.Arrays.asList; import static org.elasticsearch.compute.aggregation.AggregatorMode.FINAL; @@ -380,6 +385,67 @@ public void testMultiCountAllWithFilter() { assertThat(plan.anyMatch(EsQueryExec.class::isInstance), is(true)); } + @SuppressWarnings("unchecked") + public void testSingleCountWithStatsFilter() { + // an optimizer that filters out the ExtractAggregateCommonFilter rule + var logicalOptimizer = new LogicalPlanOptimizer(new LogicalOptimizerContext(config)) { + @Override + protected List> batches() { + var oldBatches = super.batches(); + List> newBatches = new ArrayList<>(oldBatches.size()); + for (var batch : oldBatches) { + List> rules = new ArrayList<>(List.of(batch.rules())); + rules.removeIf(r -> r instanceof ExtractAggregateCommonFilter); + newBatches.add(batch.with(rules.toArray(Rule[]::new))); + } + return newBatches; + } + }; + var analyzer = makeAnalyzer("mapping-default.json"); + var plannerOptimizer = new TestPlannerOptimizer(config, analyzer, logicalOptimizer); + var plan = plannerOptimizer.plan(""" + from test + | stats c = count(hire_date) where emp_no < 10042 + """, IS_SV_STATS); + + var limit = as(plan, LimitExec.class); + var agg = as(limit.child(), AggregateExec.class); + assertThat(agg.getMode(), is(FINAL)); + var exchange = as(agg.child(), ExchangeExec.class); + var esStatsQuery = as(exchange.child(), EsStatsQueryExec.class); + + Function compact = s -> s.replaceAll("\\s+", ""); + assertThat(compact.apply(esStatsQuery.query().toString()), is(compact.apply(""" + { + "bool": { + "must": [ + { + "exists": { + "field": "hire_date", + "boost": 1.0 + } + }, + { + "esql_single_value": { + "field": "emp_no", + "next": { + "range": { + "emp_no": { + "lt": 10042, + "boost": 1.0 + } + } + }, + "source": "emp_no < 10042@2:36" + } + } + ], + "boost": 1.0 + } + } + """))); + } + /** * Expecting * LimitExec[1000[INTEGER]] diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/TestPlannerOptimizer.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/TestPlannerOptimizer.java index 595f0aaa91f0d..9fe479dbb8625 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/TestPlannerOptimizer.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/TestPlannerOptimizer.java @@ -9,7 +9,6 @@ import org.elasticsearch.xpack.esql.EsqlTestUtils; import org.elasticsearch.xpack.esql.analysis.Analyzer; -import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry; import org.elasticsearch.xpack.esql.parser.EsqlParser; import org.elasticsearch.xpack.esql.plan.physical.EstimatesRowSize; import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan; @@ -23,19 +22,22 @@ public class TestPlannerOptimizer { private final Analyzer analyzer; private final LogicalPlanOptimizer logicalOptimizer; private final PhysicalPlanOptimizer physicalPlanOptimizer; - private final EsqlFunctionRegistry functionRegistry; private final Mapper mapper; private final Configuration config; public TestPlannerOptimizer(Configuration config, Analyzer analyzer) { + this(config, analyzer, new LogicalPlanOptimizer(new LogicalOptimizerContext(config))); + } + + public TestPlannerOptimizer(Configuration config, Analyzer analyzer, LogicalPlanOptimizer logicalOptimizer) { this.analyzer = analyzer; this.config = config; + this.logicalOptimizer = logicalOptimizer; parser = new EsqlParser(); - logicalOptimizer = new LogicalPlanOptimizer(new LogicalOptimizerContext(config)); physicalPlanOptimizer = new PhysicalPlanOptimizer(new PhysicalOptimizerContext(config)); - functionRegistry = new EsqlFunctionRegistry(); mapper = new Mapper(); + } public PhysicalPlan plan(String query) { From 66108ebeb9c3d526a8d61df73af2191a5282dc8d Mon Sep 17 00:00:00 2001 From: Dimitris Rempapis Date: Wed, 27 Nov 2024 16:42:41 +0200 Subject: [PATCH 075/129] Search Queries in parallel - part 2 (#117141) Assert optimization applied to search IT tests --- .../search/fields/SearchFieldsIT.java | 65 +-- .../functionscore/DecayFunctionScoreIT.java | 412 ++++++------------ .../search/functionscore/FunctionScoreIT.java | 89 ++-- .../search/nested/SimpleNestedIT.java | 135 ++---- .../search/query/QueryStringIT.java | 75 ++-- .../search/query/SearchQueryIT.java | 119 ++--- .../search/query/SimpleQueryStringIT.java | 86 ++-- .../routing/SearchReplicaSelectionIT.java | 17 +- 8 files changed, 340 insertions(+), 658 deletions(-) diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/fields/SearchFieldsIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/fields/SearchFieldsIT.java index 16e5e42e00c9f..0310af3685e3e 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/fields/SearchFieldsIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/fields/SearchFieldsIT.java @@ -65,6 +65,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; @@ -203,18 +204,16 @@ public void testStoredFields() throws Exception { assertThat(response.getHits().getAt(0).getFields().size(), equalTo(0)); assertThat(response.getHits().getAt(0).getFields().get("field2"), nullValue()); }); - assertResponse(prepareSearch().setQuery(matchAllQuery()).addStoredField("field3"), response -> { + assertResponses(response -> { assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); assertThat(response.getHits().getHits().length, equalTo(1)); assertThat(response.getHits().getAt(0).getFields().size(), equalTo(1)); assertThat(response.getHits().getAt(0).getFields().get("field3").getValue().toString(), equalTo("value3")); - }); - assertResponse(prepareSearch().setQuery(matchAllQuery()).addStoredField("*3"), response -> { - assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); - assertThat(response.getHits().getHits().length, equalTo(1)); - assertThat(response.getHits().getAt(0).getFields().size(), equalTo(1)); - assertThat(response.getHits().getAt(0).getFields().get("field3").getValue().toString(), equalTo("value3")); - }); + }, + prepareSearch().setQuery(matchAllQuery()).addStoredField("field3"), + prepareSearch().setQuery(matchAllQuery()).addStoredField("*3"), + prepareSearch().setQuery(matchAllQuery()).addStoredField("f*3") + ); assertResponse( prepareSearch().setQuery(matchAllQuery()).addStoredField("*3").addStoredField("field1").addStoredField("field2"), response -> { @@ -232,12 +231,6 @@ public void testStoredFields() throws Exception { assertThat(response.getHits().getAt(0).getFields().get("field3").getValue().toString(), equalTo("value3")); assertThat(response.getHits().getAt(0).getFields().get("field1").getValue().toString(), equalTo("value1")); }); - assertResponse(prepareSearch().setQuery(matchAllQuery()).addStoredField("f*3"), response -> { - assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); - assertThat(response.getHits().getHits().length, equalTo(1)); - assertThat(response.getHits().getAt(0).getFields().size(), equalTo(1)); - assertThat(response.getHits().getAt(0).getFields().get("field3").getValue().toString(), equalTo("value3")); - }); assertResponse(prepareSearch().setQuery(matchAllQuery()).addStoredField("*"), response -> { assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); assertThat(response.getHits().getHits().length, equalTo(1)); @@ -865,47 +858,7 @@ public void testDocValueFields() throws Exception { if (randomBoolean()) { builder.addDocValueField("*_field"); } - assertResponse(builder, response -> { - assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); - assertThat(response.getHits().getHits().length, equalTo(1)); - Set fields = new HashSet<>(response.getHits().getAt(0).getFields().keySet()); - assertThat( - fields, - equalTo( - newHashSet( - "byte_field", - "short_field", - "integer_field", - "long_field", - "float_field", - "double_field", - "date_field", - "boolean_field", - "text_field", - "keyword_field", - "binary_field", - "ip_field" - ) - ) - ); - - assertThat(response.getHits().getAt(0).getFields().get("byte_field").getValues(), equalTo(List.of(1L))); - assertThat(response.getHits().getAt(0).getFields().get("short_field").getValues(), equalTo(List.of(2L))); - assertThat(response.getHits().getAt(0).getFields().get("integer_field").getValues(), equalTo(List.of(3L))); - assertThat(response.getHits().getAt(0).getFields().get("long_field").getValues(), equalTo(List.of(4L))); - assertThat(response.getHits().getAt(0).getFields().get("float_field").getValues(), equalTo(List.of(5.0))); - assertThat(response.getHits().getAt(0).getFields().get("double_field").getValues(), equalTo(List.of(6.0d))); - assertThat( - response.getHits().getAt(0).getFields().get("date_field").getValue(), - equalTo(DateFormatter.forPattern("date_optional_time").format(date)) - ); - assertThat(response.getHits().getAt(0).getFields().get("boolean_field").getValues(), equalTo(List.of(true))); - assertThat(response.getHits().getAt(0).getFields().get("text_field").getValues(), equalTo(List.of("foo"))); - assertThat(response.getHits().getAt(0).getFields().get("keyword_field").getValues(), equalTo(List.of("foo"))); - assertThat(response.getHits().getAt(0).getFields().get("binary_field").getValues(), equalTo(List.of("KmQ="))); - assertThat(response.getHits().getAt(0).getFields().get("ip_field").getValues(), equalTo(List.of("::1"))); - }); - assertResponse(prepareSearch().setQuery(matchAllQuery()).addDocValueField("*field"), response -> { + assertResponses(response -> { assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); assertThat(response.getHits().getHits().length, equalTo(1)); Set fields = new HashSet<>(response.getHits().getAt(0).getFields().keySet()); @@ -944,7 +897,7 @@ public void testDocValueFields() throws Exception { assertThat(response.getHits().getAt(0).getFields().get("keyword_field").getValues(), equalTo(List.of("foo"))); assertThat(response.getHits().getAt(0).getFields().get("binary_field").getValues(), equalTo(List.of("KmQ="))); assertThat(response.getHits().getAt(0).getFields().get("ip_field").getValues(), equalTo(List.of("::1"))); - }); + }, builder, prepareSearch().setQuery(matchAllQuery()).addDocValueField("*field")); assertResponse( prepareSearch().setQuery(matchAllQuery()) .addDocValueField("byte_field", "#.0") diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/DecayFunctionScoreIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/DecayFunctionScoreIT.java index 76384253282de..9988624f6a677 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/DecayFunctionScoreIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/DecayFunctionScoreIT.java @@ -51,6 +51,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertOrderedSearchHits; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSearchHits; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; import static org.hamcrest.Matchers.anyOf; @@ -135,64 +136,21 @@ public void testDistanceScoreGeoLinGaussExp() throws Exception { lonlat.add(20f); lonlat.add(11f); - assertHitCount( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH).source(searchSource().query(baseQuery)) - ), - (numDummyDocs + 2) - ); - - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source(searchSource().query(functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km")))) - ), - response -> { - assertHitCount(response, (numDummyDocs + 2)); - assertThat(response.getHits().getAt(0).getId(), equalTo("1")); - assertThat(response.getHits().getAt(1).getId(), equalTo("2")); - } - ); - // Test Exp - - assertHitCount( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH).source(searchSource().query(baseQuery)) - ), - (numDummyDocs + 2) - ); - - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source(searchSource().query(functionScoreQuery(baseQuery, linearDecayFunction("loc", lonlat, "1000km")))) - ), - response -> { - assertHitCount(response, (numDummyDocs + 2)); - assertThat(response.getHits().getAt(0).getId(), equalTo("1")); - assertThat(response.getHits().getAt(1).getId(), equalTo("2")); - } - ); - - // Test Lin - - assertHitCount( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH).source(searchSource().query(baseQuery)) - ), - (numDummyDocs + 2) - ); - - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source(searchSource().query(functionScoreQuery(baseQuery, exponentialDecayFunction("loc", lonlat, "1000km")))) - ), - response -> { - assertHitCount(response, (numDummyDocs + 2)); - assertThat(response.getHits().getAt(0).getId(), equalTo("1")); - assertThat(response.getHits().getAt(1).getId(), equalTo("2")); - } + assertResponses(response -> { + assertHitCount(response, (numDummyDocs + 2)); + assertThat(response.getHits().getAt(0).getId(), equalTo("1")); + assertThat(response.getHits().getAt(1).getId(), equalTo("2")); + assertHitCount( + (numDummyDocs + 2), + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH).setSource(searchSource().query(baseQuery)) + ); + }, + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource(searchSource().query(functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km")))), + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource(searchSource().query(functionScoreQuery(baseQuery, linearDecayFunction("loc", lonlat, "1000km")))), + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource(searchSource().query(functionScoreQuery(baseQuery, exponentialDecayFunction("loc", lonlat, "1000km")))) ); } @@ -234,77 +192,46 @@ public void testDistanceScoreGeoLinGaussExpWithOffset() throws Exception { indexRandom(true, indexBuilders); - // Test Gauss - - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().size(numDummyDocs + 2) - .query( - functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("num", 1.0, 5.0, 1.0)).boostMode( - CombineFunction.REPLACE - ) - ) - ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (numDummyDocs + 2))); - assertThat(sh.getAt(0).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getScore(), equalTo(sh.getAt(0).getScore())); - for (int i = 0; i < numDummyDocs; i++) { - assertThat(sh.getAt(i + 2).getId(), equalTo(Integer.toString(i + 3))); - } + assertResponses(response -> { + SearchHits sh = response.getHits(); + assertThat(sh.getTotalHits().value(), equalTo((long) (numDummyDocs + 2))); + assertThat(sh.getAt(0).getId(), anyOf(equalTo("1"), equalTo("2"))); + assertThat(sh.getAt(1).getId(), anyOf(equalTo("1"), equalTo("2"))); + assertThat(sh.getAt(1).getScore(), equalTo(sh.getAt(0).getScore())); + for (int i = 0; i < numDummyDocs; i++) { + assertThat(sh.getAt(i + 2).getId(), equalTo(Integer.toString(i + 3))); } - ); - - // Test Exp - - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().size(numDummyDocs + 2) - .query( - functionScoreQuery(termQuery("test", "value"), exponentialDecayFunction("num", 1.0, 5.0, 1.0)).boostMode( - CombineFunction.REPLACE - ) + }, + // Test Gauss + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().size(numDummyDocs + 2) + .query( + functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("num", 1.0, 5.0, 1.0)).boostMode( + CombineFunction.REPLACE ) - ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (numDummyDocs + 2))); - assertThat(sh.getAt(0).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getScore(), equalTo(sh.getAt(0).getScore())); - for (int i = 0; i < numDummyDocs; i++) { - assertThat(sh.getAt(i + 2).getId(), equalTo(Integer.toString(i + 3))); - } - } - ); - // Test Lin - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().size(numDummyDocs + 2) - .query( - functionScoreQuery(termQuery("test", "value"), linearDecayFunction("num", 1.0, 20.0, 1.0)).boostMode( - CombineFunction.REPLACE - ) + ) + ), + // Test Exp + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().size(numDummyDocs + 2) + .query( + functionScoreQuery(termQuery("test", "value"), exponentialDecayFunction("num", 1.0, 5.0, 1.0)).boostMode( + CombineFunction.REPLACE ) - ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (numDummyDocs + 2))); - assertThat(sh.getAt(0).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getId(), anyOf(equalTo("1"), equalTo("2"))); - assertThat(sh.getAt(1).getScore(), equalTo(sh.getAt(0).getScore())); - } + ) + ), + // Test Lin + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().size(numDummyDocs + 2) + .query( + functionScoreQuery(termQuery("test", "value"), linearDecayFunction("num", 1.0, 20.0, 1.0)).boostMode( + CombineFunction.REPLACE + ) + ) + ) ); } @@ -355,54 +282,38 @@ public void testBoostModeSettingWorks() throws Exception { ); indexRandom(true, false, indexBuilders); // force no dummy docs - // Test Gauss List lonlat = new ArrayList<>(); lonlat.add(20f); lonlat.add(11f); - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("loc", lonlat, "1000km")).boostMode( - CombineFunction.MULTIPLY - ) + assertResponses(response -> { + SearchHits sh = response.getHits(); + assertThat(sh.getTotalHits().value(), equalTo((long) (2))); + assertThat(sh.getAt(0).getId(), equalTo("1")); + assertThat(sh.getAt(1).getId(), equalTo("2")); + }, + // Test Gauss + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("loc", lonlat, "1000km")).boostMode( + CombineFunction.MULTIPLY ) ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (2))); - assertThat(sh.getAt(0).getId(), equalTo("1")); - assertThat(sh.getAt(1).getId(), equalTo("2")); - } - ); - // Test Exp - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source(searchSource().query(termQuery("test", "value"))) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (2))); - assertThat(sh.getAt(0).getId(), equalTo("1")); - assertThat(sh.getAt(1).getId(), equalTo("2")); - } + ), + // Test Exp + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH).setSource(searchSource().query(termQuery("test", "value"))) ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("loc", lonlat, "1000km")).boostMode( - CombineFunction.REPLACE - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(termQuery("test", "value"), gaussDecayFunction("loc", lonlat, "1000km")).boostMode( + CombineFunction.REPLACE ) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (2))); @@ -410,7 +321,6 @@ public void testBoostModeSettingWorks() throws Exception { assertThat(sh.getAt(1).getId(), equalTo("1")); } ); - } public void testParseGeoPoint() throws Exception { @@ -447,44 +357,30 @@ public void testParseGeoPoint() throws Exception { constantScoreQuery(termQuery("test", "value")), ScoreFunctionBuilders.weightFactorFunction(randomIntBetween(1, 10)) ); - GeoPoint point = new GeoPoint(20, 11); - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("loc", point, "1000km")).boostMode( - CombineFunction.REPLACE - ) + + assertResponses(response -> { + SearchHits sh = response.getHits(); + assertThat(sh.getTotalHits().value(), equalTo((long) (1))); + assertThat(sh.getAt(0).getId(), equalTo("1")); + assertThat((double) sh.getAt(0).getScore(), closeTo(1.0f, 1.e-5)); + }, + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("loc", new GeoPoint(20, 11), "1000km")).boostMode( + CombineFunction.REPLACE ) ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (1))); - assertThat(sh.getAt(0).getId(), equalTo("1")); - assertThat((double) sh.getAt(0).getScore(), closeTo(1.0, 1.e-5)); - } - ); - // this is equivalent to new GeoPoint(20, 11); just flipped so scores must be same - float[] coords = { 11, 20 }; - assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("loc", coords, "1000km")).boostMode( - CombineFunction.REPLACE - ) + ), + // new float[] {11,20} is equivalent to new GeoPoint(20, 11); just flipped so scores must be same + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("loc", new float[] { 11, 20 }, "1000km")).boostMode( + CombineFunction.REPLACE ) ) - ), - response -> { - SearchHits sh = response.getHits(); - assertThat(sh.getTotalHits().value(), equalTo((long) (1))); - assertThat(sh.getAt(0).getId(), equalTo("1")); - assertThat((double) sh.getAt(0).getScore(), closeTo(1.0f, 1.e-5)); - } + ) ); } @@ -516,16 +412,14 @@ public void testCombineModes() throws Exception { ); // decay score should return 0.5 for this function and baseQuery should return 2.0f as it's score assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.MULTIPLY - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( + CombineFunction.MULTIPLY ) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -534,16 +428,14 @@ public void testCombineModes() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.REPLACE - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( + CombineFunction.REPLACE ) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -552,16 +444,12 @@ public void testCombineModes() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.SUM - ) - ) - ) - ), + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + (searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode(CombineFunction.SUM) + )) + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -576,16 +464,12 @@ public void testCombineModes() throws Exception { ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.AVG - ) - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode(CombineFunction.AVG) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -594,16 +478,12 @@ public void testCombineModes() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.MIN - ) - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode(CombineFunction.MIN) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -612,16 +492,12 @@ public void testCombineModes() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).searchType(SearchType.QUERY_THEN_FETCH) - .source( - searchSource().query( - functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode( - CombineFunction.MAX - ) - ) + prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH) + .setSource( + searchSource().query( + functionScoreQuery(baseQueryBuilder, gaussDecayFunction("num", 0.0, 1.0, null, 0.5)).boostMode(CombineFunction.MAX) ) - ), + ), response -> { SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (1))); @@ -1128,7 +1004,7 @@ public void testMultiFieldOptions() throws Exception { indexRandom(true, doc1, doc2); - assertResponse(client().search(new SearchRequest(new String[] {}).source(searchSource().query(baseQuery))), response -> { + assertResponse(prepareSearch().setSource(searchSource().query(baseQuery)), response -> { assertSearchHits(response, "1", "2"); SearchHits sh = response.getHits(); assertThat(sh.getTotalHits().value(), equalTo((long) (2))); @@ -1138,11 +1014,9 @@ public void testMultiFieldOptions() throws Exception { lonlat.add(20f); lonlat.add(10f); assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km").setMultiValueMode(MultiValueMode.MIN)) - ) + prepareSearch().setSource( + searchSource().query( + functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km").setMultiValueMode(MultiValueMode.MIN)) ) ), response -> { @@ -1154,11 +1028,9 @@ public void testMultiFieldOptions() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km").setMultiValueMode(MultiValueMode.MAX)) - ) + prepareSearch().setSource( + searchSource().query( + functionScoreQuery(baseQuery, gaussDecayFunction("loc", lonlat, "1000km").setMultiValueMode(MultiValueMode.MAX)) ) ), response -> { @@ -1180,11 +1052,9 @@ public void testMultiFieldOptions() throws Exception { indexRandom(true, doc1, doc2); assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery(baseQuery, linearDecayFunction("num", "0", "10").setMultiValueMode(MultiValueMode.SUM)) - ) + prepareSearch().setSource( + searchSource().query( + functionScoreQuery(baseQuery, linearDecayFunction("num", "0", "10").setMultiValueMode(MultiValueMode.SUM)) ) ), response -> { @@ -1197,11 +1067,9 @@ public void testMultiFieldOptions() throws Exception { } ); assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery(baseQuery, linearDecayFunction("num", "0", "10").setMultiValueMode(MultiValueMode.AVG)) - ) + prepareSearch().setSource( + searchSource().query( + functionScoreQuery(baseQuery, linearDecayFunction("num", "0", "10").setMultiValueMode(MultiValueMode.AVG)) ) ), response -> { diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/FunctionScoreIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/FunctionScoreIT.java index a38c9dc916056..e90740c042de3 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/FunctionScoreIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/FunctionScoreIT.java @@ -43,7 +43,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailures; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; -import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -137,41 +137,25 @@ public void testMinScoreFunctionScoreBasic() throws Exception { ensureYellow(); Script script = new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "doc['random_score']", Collections.emptyMap()); - assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query(functionScoreQuery(scriptFunction(script)).setMinScore(minScore)) - ) - ), - response -> { - if (score < minScore) { - assertThat(response.getHits().getTotalHits().value(), is(0L)); - } else { - assertThat(response.getHits().getTotalHits().value(), is(1L)); - } - } - ); - assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery( - new MatchAllQueryBuilder(), - new FilterFunctionBuilder[] { - new FilterFunctionBuilder(scriptFunction(script)), - new FilterFunctionBuilder(scriptFunction(script)) } - ).scoreMode(FunctionScoreQuery.ScoreMode.AVG).setMinScore(minScore) - ) - ) - ), - response -> { - if (score < minScore) { - assertThat(response.getHits().getTotalHits().value(), is(0L)); - } else { - assertThat(response.getHits().getTotalHits().value(), is(1L)); - } + assertResponses(response -> { + if (score < minScore) { + assertThat(response.getHits().getTotalHits().value(), is(0L)); + } else { + assertThat(response.getHits().getTotalHits().value(), is(1L)); } + }, + prepareSearch().setSource(searchSource().query(functionScoreQuery(scriptFunction(script)).setMinScore(minScore))), + prepareSearch().setSource( + searchSource().query( + functionScoreQuery( + new MatchAllQueryBuilder(), + new FilterFunctionBuilder[] { + new FilterFunctionBuilder(scriptFunction(script)), + new FilterFunctionBuilder(scriptFunction(script)) } + ).scoreMode(FunctionScoreQuery.ScoreMode.AVG).setMinScore(minScore) + ) + ) ); } @@ -195,31 +179,20 @@ public void testMinScoreFunctionScoreManyDocsAndRandomMinScore() throws IOExcept final int finalNumMatchingDocs = numMatchingDocs; - assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query(functionScoreQuery(scriptFunction(script)).setMinScore(minScore)).size(numDocs) - ) - ), - response -> assertMinScoreSearchResponses(numDocs, response, finalNumMatchingDocs) - ); - - assertResponse( - client().search( - new SearchRequest(new String[] {}).source( - searchSource().query( - functionScoreQuery( - new MatchAllQueryBuilder(), - new FilterFunctionBuilder[] { - new FilterFunctionBuilder(scriptFunction(script)), - new FilterFunctionBuilder(scriptFunction(script)) } - ).scoreMode(FunctionScoreQuery.ScoreMode.AVG).setMinScore(minScore) - ).size(numDocs) - ) - ), - response -> assertMinScoreSearchResponses(numDocs, response, finalNumMatchingDocs) + assertResponses( + response -> assertMinScoreSearchResponses(numDocs, response, finalNumMatchingDocs), + prepareSearch().setSource(searchSource().query(functionScoreQuery(scriptFunction(script)).setMinScore(minScore)).size(numDocs)), + prepareSearch().setSource( + searchSource().query( + functionScoreQuery( + new MatchAllQueryBuilder(), + new FilterFunctionBuilder[] { + new FilterFunctionBuilder(scriptFunction(script)), + new FilterFunctionBuilder(scriptFunction(script)) } + ).scoreMode(FunctionScoreQuery.ScoreMode.AVG).setMinScore(minScore) + ).size(numDocs) + ) ); - } protected void assertMinScoreSearchResponses(int numDocs, SearchResponse searchResponse, int numMatchingDocs) { diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/nested/SimpleNestedIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/nested/SimpleNestedIT.java index 4688201c66201..8225386ed02d2 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/nested/SimpleNestedIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/nested/SimpleNestedIT.java @@ -44,6 +44,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailures; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; @@ -1149,39 +1150,54 @@ public void testSortNestedWithNestedFilter() throws Exception { // With nested filter NestedSortBuilder nestedSort = new NestedSortBuilder("parent.child"); nestedSort.setFilter(QueryBuilders.termQuery("parent.child.filter", true)); - assertResponse( + assertResponses(response -> { + assertHitCount(response, 3); + assertThat(response.getHits().getHits().length, equalTo(3)); + assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); + assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); + assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); + assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); + assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); + assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); + }, prepareSearch().setQuery(matchAllQuery()) .addSort(SortBuilders.fieldSort("parent.child.child_values").setNestedSort(nestedSort).order(SortOrder.ASC)), - response -> { - assertHitCount(response, 3); - assertThat(response.getHits().getHits().length, equalTo(3)); - assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); - assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); - assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); - assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); - assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); - assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); - } - ); - // Nested path should be automatically detected, expect same results as above search request - assertResponse( prepareSearch().setQuery(matchAllQuery()) - .addSort(SortBuilders.fieldSort("parent.child.child_values").setNestedSort(nestedSort).order(SortOrder.ASC)), - response -> { - assertHitCount(response, 3); - assertThat(response.getHits().getHits().length, equalTo(3)); - assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); - assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); - assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); - assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); - assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); - assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); - } + .addSort( + SortBuilders.fieldSort("parent.child.child_obj.value") + .setNestedSort( + new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) + ) + .order(SortOrder.ASC) + ), + // Sort mode: sum with filter + prepareSearch().setQuery(matchAllQuery()) + .addSort( + SortBuilders.fieldSort("parent.child.child_values") + .setNestedSort( + new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) + ) + .sortMode(SortMode.SUM) + .order(SortOrder.ASC) + ), + // Sort mode: avg with filter + prepareSearch().setQuery(matchAllQuery()) + .addSort( + SortBuilders.fieldSort("parent.child.child_values") + .setNestedSort( + new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) + ) + .sortMode(SortMode.AVG) + .order(SortOrder.ASC) + ) ); - nestedSort.setFilter(QueryBuilders.termQuery("parent.filter", false)); assertResponse( prepareSearch().setQuery(matchAllQuery()) - .addSort(SortBuilders.fieldSort("parent.parent_values").setNestedSort(nestedSort).order(SortOrder.ASC)), + .addSort( + SortBuilders.fieldSort("parent.parent_values") + .setNestedSort(nestedSort.setFilter(QueryBuilders.termQuery("parent.filter", false))) + .order(SortOrder.ASC) + ), response -> { assertHitCount(response, 3); assertThat(response.getHits().getHits().length, equalTo(3)); @@ -1215,27 +1231,6 @@ public void testSortNestedWithNestedFilter() throws Exception { assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("6")); } ); - // Check if closest nested type is resolved - assertResponse( - prepareSearch().setQuery(matchAllQuery()) - .addSort( - SortBuilders.fieldSort("parent.child.child_obj.value") - .setNestedSort( - new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) - ) - .order(SortOrder.ASC) - ), - response -> { - assertHitCount(response, 3); - assertThat(response.getHits().getHits().length, equalTo(3)); - assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); - assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); - assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); - assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); - assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); - assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); - } - ); // Sort mode: sum assertResponse( prepareSearch().setQuery(matchAllQuery()) @@ -1275,28 +1270,6 @@ public void testSortNestedWithNestedFilter() throws Exception { assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("2")); } ); - // Sort mode: sum with filter - assertResponse( - prepareSearch().setQuery(matchAllQuery()) - .addSort( - SortBuilders.fieldSort("parent.child.child_values") - .setNestedSort( - new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) - ) - .sortMode(SortMode.SUM) - .order(SortOrder.ASC) - ), - response -> { - assertHitCount(response, 3); - assertThat(response.getHits().getHits().length, equalTo(3)); - assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); - assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); - assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); - assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); - assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); - assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); - } - ); // Sort mode: avg assertResponse( prepareSearch().setQuery(matchAllQuery()) @@ -1336,28 +1309,6 @@ public void testSortNestedWithNestedFilter() throws Exception { assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("1")); } ); - // Sort mode: avg with filter - assertResponse( - prepareSearch().setQuery(matchAllQuery()) - .addSort( - SortBuilders.fieldSort("parent.child.child_values") - .setNestedSort( - new NestedSortBuilder("parent.child").setFilter(QueryBuilders.termQuery("parent.child.filter", true)) - ) - .sortMode(SortMode.AVG) - .order(SortOrder.ASC) - ), - response -> { - assertHitCount(response, 3); - assertThat(response.getHits().getHits().length, equalTo(3)); - assertThat(response.getHits().getHits()[0].getId(), equalTo("1")); - assertThat(response.getHits().getHits()[0].getSortValues()[0].toString(), equalTo("1")); - assertThat(response.getHits().getHits()[1].getId(), equalTo("2")); - assertThat(response.getHits().getHits()[1].getSortValues()[0].toString(), equalTo("2")); - assertThat(response.getHits().getHits()[2].getId(), equalTo("3")); - assertThat(response.getHits().getHits()[2].getSortValues()[0].toString(), equalTo("3")); - } - ); } // Issue #9305 diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/query/QueryStringIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/query/QueryStringIT.java index c8fe9498b156f..28d72518f516e 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/query/QueryStringIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/query/QueryStringIT.java @@ -30,6 +30,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -50,14 +51,10 @@ public void testBasicAllQuery() throws Exception { reqs.add(prepareIndex("test").setId("3").setSource("f3", "foo bar baz")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("foo")), response -> { - assertHitCount(response, 2L); - assertHits(response.getHits(), "1", "3"); - }); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("bar")), response -> { + assertResponses(response -> { assertHitCount(response, 2L); assertHits(response.getHits(), "1", "3"); - }); + }, prepareSearch("test").setQuery(queryStringQuery("foo")), prepareSearch("test").setQuery(queryStringQuery("bar"))); assertResponse(prepareSearch("test").setQuery(queryStringQuery("Bar")), response -> { assertHitCount(response, 3L); assertHits(response.getHits(), "1", "2", "3"); @@ -70,22 +67,18 @@ public void testWithDate() throws Exception { reqs.add(prepareIndex("test").setId("2").setSource("f1", "bar", "f_date", "2015/09/01")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("foo bar")), response -> { + assertResponses(response -> { assertHits(response.getHits(), "1", "2"); assertHitCount(response, 2L); - }); + }, + prepareSearch("test").setQuery(queryStringQuery("foo bar")), + prepareSearch("test").setQuery(queryStringQuery("bar \"2015/09/02\"")), + prepareSearch("test").setQuery(queryStringQuery("\"2015/09/02\" \"2015/09/01\"")) + ); assertResponse(prepareSearch("test").setQuery(queryStringQuery("\"2015/09/02\"")), response -> { assertHits(response.getHits(), "1"); assertHitCount(response, 1L); }); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("bar \"2015/09/02\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("\"2015/09/02\" \"2015/09/01\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); } public void testWithLotsOfTypes() throws Exception { @@ -94,22 +87,18 @@ public void testWithLotsOfTypes() throws Exception { reqs.add(prepareIndex("test").setId("2").setSource("f1", "bar", "f_date", "2015/09/01", "f_float", "1.8", "f_ip", "127.0.0.2")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("foo bar")), response -> { + assertResponses(response -> { assertHits(response.getHits(), "1", "2"); assertHitCount(response, 2L); - }); + }, + prepareSearch("test").setQuery(queryStringQuery("foo bar")), + prepareSearch("test").setQuery(queryStringQuery("127.0.0.2 \"2015/09/02\"")), + prepareSearch("test").setQuery(queryStringQuery("127.0.0.1 OR 1.8")) + ); assertResponse(prepareSearch("test").setQuery(queryStringQuery("\"2015/09/02\"")), response -> { assertHits(response.getHits(), "1"); assertHitCount(response, 1L); }); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("127.0.0.2 \"2015/09/02\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("127.0.0.1 OR 1.8")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); } public void testDocWithAllTypes() throws Exception { @@ -118,23 +107,23 @@ public void testDocWithAllTypes() throws Exception { reqs.add(prepareIndex("test").setId("1").setSource(docBody, XContentType.JSON)); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("foo")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("Bar")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("Baz")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("19")), response -> assertHits(response.getHits(), "1")); - // nested doesn't match because it's hidden - assertResponse(prepareSearch("test").setQuery(queryStringQuery("1476383971")), response -> assertHits(response.getHits(), "1")); - // bool doesn't match - assertResponse(prepareSearch("test").setQuery(queryStringQuery("7")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("23")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("1293")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("42")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("1.7")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("1.5")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(queryStringQuery("127.0.0.1")), response -> assertHits(response.getHits(), "1")); - // binary doesn't match - // suggest doesn't match - // geo_point doesn't match + assertResponses( + response -> assertHits(response.getHits(), "1"), + prepareSearch("test").setQuery(queryStringQuery("foo")), + prepareSearch("test").setQuery(queryStringQuery("Bar")), + prepareSearch("test").setQuery(queryStringQuery("Baz")), + prepareSearch("test").setQuery(queryStringQuery("19")), + // nested doesn't match because it's hidden + prepareSearch("test").setQuery(queryStringQuery("1476383971")), + // bool doesn't match + prepareSearch("test").setQuery(queryStringQuery("7")), + prepareSearch("test").setQuery(queryStringQuery("23")), + prepareSearch("test").setQuery(queryStringQuery("1293")), + prepareSearch("test").setQuery(queryStringQuery("42")), + prepareSearch("test").setQuery(queryStringQuery("1.7")), + prepareSearch("test").setQuery(queryStringQuery("1.5")), + prepareSearch("test").setQuery(queryStringQuery("127.0.0.1")) + ); } public void testKeywordWithWhitespace() throws Exception { diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/query/SearchQueryIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/query/SearchQueryIT.java index 118aa00fc1b4f..f790cf30e1c0e 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/query/SearchQueryIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/query/SearchQueryIT.java @@ -589,19 +589,19 @@ public void testMultiMatchQuery() throws Exception { indicesAdmin().prepareRefresh("test").get(); builder = multiMatchQuery("value1", "field1", "field2").operator(Operator.AND); // Operator only applies on terms inside a field! - // Fields are always OR-ed together. + // Fields are always OR-ed together. assertSearchHitsWithoutFailures(prepareSearch().setQuery(builder), "1"); refresh(); builder = multiMatchQuery("value1", "field1").field("field3", 1.5f).operator(Operator.AND); // Operator only applies on terms inside - // a field! Fields are always OR-ed - // together. + // a field! Fields are always OR-ed + // together. assertSearchHitsWithoutFailures(prepareSearch().setQuery(builder), "3", "1"); indicesAdmin().prepareRefresh("test").get(); builder = multiMatchQuery("value1").field("field1").field("field3", 1.5f).operator(Operator.AND); // Operator only applies on terms - // inside a field! Fields are - // always OR-ed together. + // inside a field! Fields are + // always OR-ed together. assertResponse(prepareSearch().setQuery(builder), response -> { assertHitCount(response, 2L); assertSearchHits(response, "3", "1"); @@ -726,25 +726,27 @@ public void testBoolQueryMinShouldMatchBiggerThanNumberOfShouldClauses() throws prepareIndex("test").setId("2").setSource("field2", "value1").get(); refresh(); - BoolQueryBuilder boolQuery = boolQuery().must(termQuery("field1", "value1")) - .should(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(3)); - assertResponse(prepareSearch().setQuery(boolQuery), response -> { + assertResponses(response -> { assertHitCount(response, 1L); assertFirstHit(response, hasId("1")); - }); - boolQuery = boolQuery().must(termQuery("field1", "value1")) + }, + prepareSearch().setQuery( + boolQuery().must(termQuery("field1", "value1")) + .should(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(3)) + ), + prepareSearch().setQuery( + boolQuery().should(termQuery("field1", "value1")) + .should(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(3)) + .minimumShouldMatch(1) + ) + ); + + BoolQueryBuilder boolQuery = boolQuery().must(termQuery("field1", "value1")) .should(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(1)) // Only one should clause is defined, returns no docs. .minimumShouldMatch(2); assertHitCount(prepareSearch().setQuery(boolQuery), 0L); - boolQuery = boolQuery().should(termQuery("field1", "value1")) - .should(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(3)) - .minimumShouldMatch(1); - assertResponse(prepareSearch().setQuery(boolQuery), response -> { - assertHitCount(response, 1L); - assertFirstHit(response, hasId("1")); - }); boolQuery = boolQuery().must(termQuery("field1", "value1")) .must(boolQuery().should(termQuery("field1", "value1")).should(termQuery("field1", "value2")).minimumShouldMatch(3)); assertHitCount(prepareSearch().setQuery(boolQuery), 0L); @@ -1449,73 +1451,40 @@ public void testRangeQueryWithTimeZone() throws Exception { .setSource("date", Instant.now().atZone(ZoneOffset.ofHours(1)).toInstant().toEpochMilli(), "num", 4) ); - assertResponse( + assertResponses(response -> { + assertHitCount(response, 1L); + assertThat(response.getHits().getAt(0).getId(), is("1")); + }, prepareSearch("test").setQuery(QueryBuilders.rangeQuery("date").from("2014-01-01T00:00:00").to("2014-01-01T00:59:00")), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("1")); - } - ); - assertResponse( - prepareSearch("test").setQuery(QueryBuilders.rangeQuery("date").from("2013-12-31T23:00:00").to("2013-12-31T23:59:00")), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("2")); - } - ); - assertResponse( - prepareSearch("test").setQuery(QueryBuilders.rangeQuery("date").from("2014-01-01T01:00:00").to("2014-01-01T01:59:00")), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("3")); - } - ); - // We explicitly define a time zone in the from/to dates so whatever the time zone is, it won't be used - assertResponse( + // We explicitly define a time zone in the from/to dates so whatever the time zone is, it won't be used prepareSearch("test").setQuery( QueryBuilders.rangeQuery("date").from("2014-01-01T00:00:00Z").to("2014-01-01T00:59:00Z").timeZone("+10:00") ), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("1")); - } - ); - assertResponse( + // We define a time zone to be applied to the filter and from/to have no time zone prepareSearch("test").setQuery( - QueryBuilders.rangeQuery("date").from("2013-12-31T23:00:00Z").to("2013-12-31T23:59:00Z").timeZone("+10:00") - ), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("2")); - } + QueryBuilders.rangeQuery("date").from("2014-01-01T03:00:00").to("2014-01-01T03:59:00").timeZone("+03:00") + ) ); - assertResponse( + assertResponses(response -> { + assertHitCount(response, 1L); + assertThat(response.getHits().getAt(0).getId(), is("2")); + }, + prepareSearch("test").setQuery(QueryBuilders.rangeQuery("date").from("2013-12-31T23:00:00").to("2013-12-31T23:59:00")), prepareSearch("test").setQuery( - QueryBuilders.rangeQuery("date").from("2014-01-01T01:00:00Z").to("2014-01-01T01:59:00Z").timeZone("+10:00") + QueryBuilders.rangeQuery("date").from("2013-12-31T23:00:00Z").to("2013-12-31T23:59:00Z").timeZone("+10:00") ), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("3")); - } - ); - // We define a time zone to be applied to the filter and from/to have no time zone - assertResponse( prepareSearch("test").setQuery( - QueryBuilders.rangeQuery("date").from("2014-01-01T03:00:00").to("2014-01-01T03:59:00").timeZone("+03:00") - ), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("1")); - } + QueryBuilders.rangeQuery("date").from("2014-01-01T02:00:00").to("2014-01-01T02:59:00").timeZone("+03:00") + ) ); - assertResponse( + assertResponses(response -> { + assertHitCount(response, 1L); + assertThat(response.getHits().getAt(0).getId(), is("3")); + }, + prepareSearch("test").setQuery(QueryBuilders.rangeQuery("date").from("2014-01-01T01:00:00").to("2014-01-01T01:59:00")), prepareSearch("test").setQuery( - QueryBuilders.rangeQuery("date").from("2014-01-01T02:00:00").to("2014-01-01T02:59:00").timeZone("+03:00") - ), - response -> { - assertHitCount(response, 1L); - assertThat(response.getHits().getAt(0).getId(), is("2")); - } + QueryBuilders.rangeQuery("date").from("2014-01-01T01:00:00Z").to("2014-01-01T01:59:00Z").timeZone("+10:00") + ) ); assertResponses(response -> { assertHitCount(response, 1L); @@ -1713,8 +1682,8 @@ public void testFieldAliasesForMetaFields() throws Exception { } /** - * Test that wildcard queries on keyword fields get normalized - */ + * Test that wildcard queries on keyword fields get normalized + */ public void testWildcardQueryNormalizationOnKeywordField() { assertAcked( prepareCreate("test").setSettings( diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/query/SimpleQueryStringIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/query/SimpleQueryStringIT.java index 522c20b687caa..f9ae30720b33f 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/query/SimpleQueryStringIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/query/SimpleQueryStringIT.java @@ -51,6 +51,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailuresAndResponse; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSearchHits; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSearchHitsWithoutFailures; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.hasId; @@ -383,14 +384,10 @@ public void testBasicAllQuery() throws Exception { reqs.add(prepareIndex("test").setId("3").setSource("f3", "foo bar baz")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("foo")), response -> { + assertResponses(response -> { assertHitCount(response, 2L); assertHits(response.getHits(), "1", "3"); - }); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("bar")), response -> { - assertHitCount(response, 2L); - assertHits(response.getHits(), "1", "3"); - }); + }, prepareSearch("test").setQuery(simpleQueryStringQuery("foo")), prepareSearch("test").setQuery(simpleQueryStringQuery("bar"))); assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("Bar")), response -> { assertHitCount(response, 3L); assertHits(response.getHits(), "1", "2", "3"); @@ -407,22 +404,18 @@ public void testWithDate() throws Exception { reqs.add(prepareIndex("test").setId("2").setSource("f1", "bar", "f_date", "2015/09/01")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("foo bar")), response -> { + assertResponses(response -> { assertHits(response.getHits(), "1", "2"); assertHitCount(response, 2L); - }); + }, + prepareSearch("test").setQuery(simpleQueryStringQuery("foo bar")), + prepareSearch("test").setQuery(simpleQueryStringQuery("bar \"2015/09/02\"")), + prepareSearch("test").setQuery(simpleQueryStringQuery("\"2015/09/02\" \"2015/09/01\"")) + ); assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("\"2015/09/02\"")), response -> { assertHits(response.getHits(), "1"); assertHitCount(response, 1L); }); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("bar \"2015/09/02\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("\"2015/09/02\" \"2015/09/01\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); } public void testWithLotsOfTypes() throws Exception { @@ -435,22 +428,18 @@ public void testWithLotsOfTypes() throws Exception { reqs.add(prepareIndex("test").setId("2").setSource("f1", "bar", "f_date", "2015/09/01", "f_float", "1.8", "f_ip", "127.0.0.2")); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("foo bar")), response -> { + assertResponses(response -> { assertHits(response.getHits(), "1", "2"); assertHitCount(response, 2L); - }); + }, + prepareSearch("test").setQuery(simpleQueryStringQuery("foo bar")), + prepareSearch("test").setQuery(simpleQueryStringQuery("127.0.0.2 \"2015/09/02\"")), + prepareSearch("test").setQuery(simpleQueryStringQuery("127.0.0.1 1.8")) + ); assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("\"2015/09/02\"")), response -> { assertHits(response.getHits(), "1"); assertHitCount(response, 1L); }); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("127.0.0.2 \"2015/09/02\"")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("127.0.0.1 1.8")), response -> { - assertHits(response.getHits(), "1", "2"); - assertHitCount(response, 2L); - }); } public void testDocWithAllTypes() throws Exception { @@ -463,34 +452,27 @@ public void testDocWithAllTypes() throws Exception { reqs.add(prepareIndex("test").setId("1").setSource(docBody, XContentType.JSON)); indexRandom(true, false, reqs); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("foo")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("Bar")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("Baz")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("19")), response -> assertHits(response.getHits(), "1")); - // nested doesn't match because it's hidden - assertResponse( + assertResponses( + response -> assertHits(response.getHits(), "1"), + prepareSearch("test").setQuery(simpleQueryStringQuery("foo")), + prepareSearch("test").setQuery(simpleQueryStringQuery("Bar")), + prepareSearch("test").setQuery(simpleQueryStringQuery("Baz")), + prepareSearch("test").setQuery(simpleQueryStringQuery("19")), + // nested doesn't match because it's hidden prepareSearch("test").setQuery(simpleQueryStringQuery("1476383971")), - response -> assertHits(response.getHits(), "1") - ); - // bool doesn't match - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("7")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("23")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("1293")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("42")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("1.7")), response -> assertHits(response.getHits(), "1")); - assertResponse(prepareSearch("test").setQuery(simpleQueryStringQuery("1.5")), response -> assertHits(response.getHits(), "1")); - assertResponse( + // bool doesn't match + prepareSearch("test").setQuery(simpleQueryStringQuery("7")), + prepareSearch("test").setQuery(simpleQueryStringQuery("23")), + prepareSearch("test").setQuery(simpleQueryStringQuery("1293")), + prepareSearch("test").setQuery(simpleQueryStringQuery("42")), + prepareSearch("test").setQuery(simpleQueryStringQuery("1.7")), + prepareSearch("test").setQuery(simpleQueryStringQuery("1.5")), prepareSearch("test").setQuery(simpleQueryStringQuery("127.0.0.1")), - response -> assertHits(response.getHits(), "1") - ); - // binary doesn't match - // suggest doesn't match - // geo_point doesn't match - // geo_shape doesn't match - - assertResponse( - prepareSearch("test").setQuery(simpleQueryStringQuery("foo Bar 19 127.0.0.1").defaultOperator(Operator.AND)), - response -> assertHits(response.getHits(), "1") + // binary doesn't match + // suggest doesn't match + // geo_point doesn't match + // geo_shape doesn't match + prepareSearch("test").setQuery(simpleQueryStringQuery("foo Bar 19 127.0.0.1").defaultOperator(Operator.AND)) ); } diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/routing/SearchReplicaSelectionIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/routing/SearchReplicaSelectionIT.java index 06ce330213af8..789da5aac7568 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/routing/SearchReplicaSelectionIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/routing/SearchReplicaSelectionIT.java @@ -24,6 +24,7 @@ import static org.elasticsearch.index.query.QueryBuilders.matchAllQuery; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponse; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertResponses; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -50,18 +51,14 @@ public void testNodeSelection() { // Before we've gathered stats for all nodes, we should try each node once. Set nodeIds = new HashSet<>(); - assertResponse(client.prepareSearch().setQuery(matchAllQuery()), response -> { - assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); - nodeIds.add(response.getHits().getAt(0).getShard().getNodeId()); - }); - assertResponse(client.prepareSearch().setQuery(matchAllQuery()), response -> { + assertResponses(response -> { assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); nodeIds.add(response.getHits().getAt(0).getShard().getNodeId()); - }); - assertResponse(client.prepareSearch().setQuery(matchAllQuery()), response -> { - assertThat(response.getHits().getTotalHits().value(), equalTo(1L)); - nodeIds.add(response.getHits().getAt(0).getShard().getNodeId()); - }); + }, + client.prepareSearch().setQuery(matchAllQuery()), + client.prepareSearch().setQuery(matchAllQuery()), + client.prepareSearch().setQuery(matchAllQuery()) + ); assertEquals(3, nodeIds.size()); // Now after more searches, we should select a node with the lowest ARS rank. From 5c928a431671fd2789c9d58fd26a0e48cb7d6f92 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Wed, 27 Nov 2024 07:27:21 -0800 Subject: [PATCH 076/129] 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 077/129] 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 078/129] 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 combineUpperGroupingsAndLowerProjections( - List upperGroupings, + List upperGroupings, List 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 aggregates, List 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 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 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 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 079/129] 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 080/129] 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 081/129] [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 082/129] 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 083/129] 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 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 084/129] 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 085/129] 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 086/129] 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 087/129] 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 088/129] [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 089/129] 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 090/129] 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 091/129] 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 092/129] 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 093/129] [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 094/129] 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 095/129] 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 096/129] 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.getBlock(initialBlockIndex + 1).asVector(); for (int i = 0; i < page.getPositionCount(); i++) { BytesRef termAtPosition = terms.getBytesRef(i, new BytesRef()); assertThat(matches.getBoolean(i), equalTo(termAtPosition.utf8ToString().equals(term))); @@ -155,8 +158,9 @@ private void testTermsQuery(boolean shuffleDocs) throws IOException { List results = runQuery(values, new TermInSetQuery(MultiTermQuery.CONSTANT_SCORE_REWRITE, FIELD, matchingBytes), shuffleDocs); 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.getBlock(initialBlockIndex + 1).asVector(); for (int i = 0; i < page.getPositionCount(); i++) { BytesRef termAtPosition = terms.getBytesRef(i, new BytesRef()); assertThat(matches.getBoolean(i), equalTo(matching.contains(termAtPosition.utf8ToString()))); @@ -207,7 +211,7 @@ private List runQuery(Set values, Query query, boolean shuffleDocs List results = new ArrayList<>(); Driver driver = new Driver( driverContext, - luceneOperatorFactory(reader, new MatchAllDocsQuery(), LuceneOperator.NO_LIMIT).get(driverContext), + luceneOperatorFactory(reader, new MatchAllDocsQuery(), LuceneOperator.NO_LIMIT, scoring).get(driverContext), operators, new TestResultPageSinkOperator(results::add), () -> {} @@ -248,7 +252,21 @@ private DriverContext driverContext() { return new DriverContext(blockFactory.bigArrays(), blockFactory); } - static LuceneOperator.Factory luceneOperatorFactory(IndexReader reader, Query query, int limit) { + // Scores are not interesting to this test, but enabled conditionally and effectively ignored just for coverage. + private final boolean scoring = randomBoolean(); + + // Returns the initial block index, ignoring the score block if scoring is enabled + private int initialBlockIndex(Page page) { + assert page.getBlock(0) instanceof DocBlock : "expected doc block at index 0"; + if (scoring) { + assert page.getBlock(1) instanceof DoubleBlock : "expected double block at index 1"; + return 2; + } else { + return 1; + } + } + + static LuceneOperator.Factory luceneOperatorFactory(IndexReader reader, Query query, int limit, boolean scoring) { final ShardContext searchContext = new LuceneSourceOperatorTests.MockShardContext(reader, 0); return new LuceneSourceOperator.Factory( List.of(searchContext), @@ -256,7 +274,8 @@ static LuceneOperator.Factory luceneOperatorFactory(IndexReader reader, Query qu randomFrom(DataPartitioning.values()), randomIntBetween(1, 10), randomPageSize(), - limit + limit, + scoring ); } } diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneSourceOperatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneSourceOperatorTests.java index 626190c04c501..2dcc5e20d3f98 100644 --- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneSourceOperatorTests.java +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneSourceOperatorTests.java @@ -17,6 +17,8 @@ import org.apache.lucene.store.Directory; import org.apache.lucene.tests.index.RandomIndexWriter; import org.elasticsearch.common.breaker.CircuitBreakingException; +import org.elasticsearch.compute.data.DocBlock; +import org.elasticsearch.compute.data.DoubleBlock; import org.elasticsearch.compute.data.ElementType; import org.elasticsearch.compute.data.LongBlock; import org.elasticsearch.compute.data.Page; @@ -63,10 +65,10 @@ public void closeIndex() throws IOException { @Override protected LuceneSourceOperator.Factory simple() { - return simple(randomFrom(DataPartitioning.values()), between(1, 10_000), 100); + return simple(randomFrom(DataPartitioning.values()), between(1, 10_000), 100, scoring); } - private LuceneSourceOperator.Factory simple(DataPartitioning dataPartitioning, int numDocs, int limit) { + private LuceneSourceOperator.Factory simple(DataPartitioning dataPartitioning, int numDocs, int limit, boolean scoring) { int commitEvery = Math.max(1, numDocs / 10); try ( RandomIndexWriter writer = new RandomIndexWriter( @@ -91,7 +93,7 @@ private LuceneSourceOperator.Factory simple(DataPartitioning dataPartitioning, i ShardContext ctx = new MockShardContext(reader, 0); Function queryFunction = c -> new MatchAllDocsQuery(); int maxPageSize = between(10, Math.max(10, numDocs)); - return new LuceneSourceOperator.Factory(List.of(ctx), queryFunction, dataPartitioning, 1, maxPageSize, limit); + return new LuceneSourceOperator.Factory(List.of(ctx), queryFunction, dataPartitioning, 1, maxPageSize, limit, scoring); } @Override @@ -101,7 +103,10 @@ protected Matcher expectedToStringOfSimple() { @Override protected Matcher expectedDescriptionOfSimple() { - return matchesRegex("LuceneSourceOperator\\[dataPartitioning = (DOC|SHARD|SEGMENT), maxPageSize = \\d+, limit = 100]"); + return matchesRegex( + "LuceneSourceOperator" + + "\\[dataPartitioning = (DOC|SHARD|SEGMENT), maxPageSize = \\d+, limit = 100, scoreMode = (COMPLETE|COMPLETE_NO_SCORES)]" + ); } // TODO tests for the other data partitioning configurations @@ -149,7 +154,7 @@ public void testShardDataPartitioningWithCranky() { } private void testSimple(DriverContext ctx, int size, int limit) { - LuceneSourceOperator.Factory factory = simple(DataPartitioning.SHARD, size, limit); + LuceneSourceOperator.Factory factory = simple(DataPartitioning.SHARD, size, limit, scoring); Operator.OperatorFactory readS = ValuesSourceReaderOperatorTests.factory(reader, S_FIELD, ElementType.LONG); List results = new ArrayList<>(); @@ -164,7 +169,7 @@ private void testSimple(DriverContext ctx, int size, int limit) { } for (Page page : results) { - LongBlock sBlock = page.getBlock(1); + LongBlock sBlock = page.getBlock(initialBlockIndex(page)); for (int p = 0; p < page.getPositionCount(); p++) { assertThat(sBlock.getLong(sBlock.getFirstValueIndex(p)), both(greaterThanOrEqualTo(0L)).and(lessThan((long) size))); } @@ -174,6 +179,20 @@ private void testSimple(DriverContext ctx, int size, int limit) { assertThat(results, hasSize(both(greaterThanOrEqualTo(minPages)).and(lessThanOrEqualTo(maxPages)))); } + // Scores are not interesting to this test, but enabled conditionally and effectively ignored just for coverage. + private final boolean scoring = randomBoolean(); + + // Returns the initial block index, ignoring the score block if scoring is enabled + private int initialBlockIndex(Page page) { + assert page.getBlock(0) instanceof DocBlock : "expected doc block at index 0"; + if (scoring) { + assert page.getBlock(1) instanceof DoubleBlock : "expected double block at index 1"; + return 2; + } else { + return 1; + } + } + /** * Creates a mock search context with the given index reader. * The returned mock search context can be used to test with {@link LuceneOperator}. diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorScoringTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorScoringTests.java new file mode 100644 index 0000000000000..a0fa1c2c01c0a --- /dev/null +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorScoringTests.java @@ -0,0 +1,151 @@ +/* + * 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.lucene; + +import org.apache.lucene.document.SortedNumericDocValuesField; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.Sort; +import org.apache.lucene.search.SortField; +import org.apache.lucene.search.SortedNumericSelector; +import org.apache.lucene.search.SortedNumericSortField; +import org.apache.lucene.store.Directory; +import org.apache.lucene.tests.index.RandomIndexWriter; +import org.elasticsearch.compute.data.DoubleBlock; +import org.elasticsearch.compute.data.ElementType; +import org.elasticsearch.compute.data.Page; +import org.elasticsearch.compute.operator.Driver; +import org.elasticsearch.compute.operator.DriverContext; +import org.elasticsearch.compute.operator.Operator; +import org.elasticsearch.compute.operator.OperatorTestCase; +import org.elasticsearch.compute.operator.TestResultPageSinkOperator; +import org.elasticsearch.core.IOUtils; +import org.elasticsearch.index.mapper.MappedFieldType; +import org.elasticsearch.index.mapper.NumberFieldMapper; +import org.elasticsearch.search.DocValueFormat; +import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.SortAndFormats; +import org.elasticsearch.search.sort.SortBuilder; +import org.hamcrest.Matcher; +import org.junit.After; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.matchesRegex; + +public class LuceneTopNSourceOperatorScoringTests extends LuceneTopNSourceOperatorTests { + private static final MappedFieldType S_FIELD = new NumberFieldMapper.NumberFieldType("s", NumberFieldMapper.NumberType.LONG); + private Directory directory = newDirectory(); + private IndexReader reader; + + @After + private void closeIndex() throws IOException { + IOUtils.close(reader, directory); + } + + @Override + protected LuceneTopNSourceOperator.Factory simple() { + return simple(DataPartitioning.SHARD, 10_000, 100); + } + + private LuceneTopNSourceOperator.Factory simple(DataPartitioning dataPartitioning, int size, int limit) { + int commitEvery = Math.max(1, size / 10); + try ( + RandomIndexWriter writer = new RandomIndexWriter( + random(), + directory, + newIndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE) + ) + ) { + for (int d = 0; d < size; d++) { + List doc = new ArrayList<>(); + doc.add(new SortedNumericDocValuesField("s", d)); + writer.addDocument(doc); + if (d % commitEvery == 0) { + writer.commit(); + } + } + reader = writer.getReader(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + ShardContext ctx = new LuceneSourceOperatorTests.MockShardContext(reader, 0) { + @Override + public Optional buildSort(List> sorts) { + SortField field = new SortedNumericSortField("s", SortField.Type.LONG, false, SortedNumericSelector.Type.MIN); + return Optional.of(new SortAndFormats(new Sort(field), new DocValueFormat[] { null })); + } + }; + Function queryFunction = c -> new MatchAllDocsQuery(); + int taskConcurrency = 0; + int maxPageSize = between(10, Math.max(10, size)); + List> sorts = List.of(new FieldSortBuilder("s")); + return new LuceneTopNSourceOperator.Factory( + List.of(ctx), + queryFunction, + dataPartitioning, + taskConcurrency, + maxPageSize, + limit, + sorts, + true // scoring + ); + } + + @Override + protected Matcher expectedToStringOfSimple() { + return matchesRegex("LuceneTopNSourceOperator\\[maxPageSize = \\d+, limit = 100, scoreMode = COMPLETE, sorts = \\[\\{.+}]]"); + } + + @Override + protected Matcher expectedDescriptionOfSimple() { + return matchesRegex( + "LuceneTopNSourceOperator" + + "\\[dataPartitioning = (DOC|SHARD|SEGMENT), maxPageSize = \\d+, limit = 100, scoreMode = COMPLETE, sorts = \\[\\{.+}]]" + ); + } + + @Override + protected void testSimple(DriverContext ctx, int size, int limit) { + LuceneTopNSourceOperator.Factory factory = simple(DataPartitioning.SHARD, size, limit); + Operator.OperatorFactory readS = ValuesSourceReaderOperatorTests.factory(reader, S_FIELD, ElementType.LONG); + + List results = new ArrayList<>(); + OperatorTestCase.runDriver( + new Driver(ctx, factory.get(ctx), List.of(readS.get(ctx)), new TestResultPageSinkOperator(results::add), () -> {}) + ); + OperatorTestCase.assertDriverContext(ctx); + + long expectedS = 0; + int maxPageSize = factory.maxPageSize(); + for (Page page : results) { + if (limit - expectedS < maxPageSize) { + assertThat(page.getPositionCount(), equalTo((int) (limit - expectedS))); + } else { + assertThat(page.getPositionCount(), equalTo(maxPageSize)); + } + DoubleBlock sBlock = page.getBlock(1); + for (int p = 0; p < page.getPositionCount(); p++) { + assertThat(sBlock.getDouble(sBlock.getFirstValueIndex(p)), equalTo(1.0d)); + expectedS++; + } + } + int pages = (int) Math.ceil((float) Math.min(size, limit) / maxPageSize); + assertThat(results, hasSize(pages)); + } +} diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorTests.java index 938c4ce5c9f7d..d9a0b70b7931e 100644 --- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorTests.java +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneTopNSourceOperatorTests.java @@ -20,6 +20,8 @@ import org.apache.lucene.store.Directory; import org.apache.lucene.tests.index.RandomIndexWriter; import org.elasticsearch.common.breaker.CircuitBreakingException; +import org.elasticsearch.compute.data.DocBlock; +import org.elasticsearch.compute.data.DoubleBlock; import org.elasticsearch.compute.data.ElementType; import org.elasticsearch.compute.data.LongBlock; import org.elasticsearch.compute.data.Page; @@ -56,7 +58,7 @@ public class LuceneTopNSourceOperatorTests extends AnyOperatorTestCase { private IndexReader reader; @After - public void closeIndex() throws IOException { + private void closeIndex() throws IOException { IOUtils.close(reader, directory); } @@ -105,19 +107,25 @@ public Optional buildSort(List> sorts) { taskConcurrency, maxPageSize, limit, - sorts + sorts, + scoring ); } @Override protected Matcher expectedToStringOfSimple() { - return matchesRegex("LuceneTopNSourceOperator\\[maxPageSize = \\d+, limit = 100, sorts = \\[\\{.+}]]"); + var s = scoring ? "COMPLETE" : "TOP_DOCS"; + return matchesRegex("LuceneTopNSourceOperator\\[maxPageSize = \\d+, limit = 100, scoreMode = " + s + ", sorts = \\[\\{.+}]]"); } @Override protected Matcher expectedDescriptionOfSimple() { + var s = scoring ? "COMPLETE" : "TOP_DOCS"; return matchesRegex( - "LuceneTopNSourceOperator\\[dataPartitioning = (DOC|SHARD|SEGMENT), maxPageSize = \\d+, limit = 100, sorts = \\[\\{.+}]]" + "LuceneTopNSourceOperator" + + "\\[dataPartitioning = (DOC|SHARD|SEGMENT), maxPageSize = \\d+, limit = 100, scoreMode = " + + s + + ", sorts = \\[\\{.+}]]" ); } @@ -137,12 +145,24 @@ public void testShardDataPartitioningWithCranky() { } } - private void testShardDataPartitioning(DriverContext context) { + void testShardDataPartitioning(DriverContext context) { int size = between(1_000, 20_000); int limit = between(10, size); testSimple(context, size, limit); } + public void testWithCranky() { + try { + int size = between(1_000, 20_000); + int limit = between(10, size); + testSimple(crankyDriverContext(), size, limit); + logger.info("cranky didn't break"); + } catch (CircuitBreakingException e) { + logger.info("broken", e); + assertThat(e.getMessage(), equalTo(CrankyCircuitBreakerService.ERROR_MESSAGE)); + } + } + public void testEmpty() { testEmpty(driverContext()); } @@ -157,11 +177,11 @@ public void testEmptyWithCranky() { } } - private void testEmpty(DriverContext context) { + void testEmpty(DriverContext context) { testSimple(context, 0, between(10, 10_000)); } - private void testSimple(DriverContext ctx, int size, int limit) { + protected void testSimple(DriverContext ctx, int size, int limit) { LuceneTopNSourceOperator.Factory factory = simple(DataPartitioning.SHARD, size, limit); Operator.OperatorFactory readS = ValuesSourceReaderOperatorTests.factory(reader, S_FIELD, ElementType.LONG); @@ -178,7 +198,7 @@ private void testSimple(DriverContext ctx, int size, int limit) { } else { assertThat(page.getPositionCount(), equalTo(factory.maxPageSize())); } - LongBlock sBlock = page.getBlock(1); + LongBlock sBlock = page.getBlock(initialBlockIndex(page)); for (int p = 0; p < page.getPositionCount(); p++) { assertThat(sBlock.getLong(sBlock.getFirstValueIndex(p)), equalTo(expectedS++)); } @@ -186,4 +206,18 @@ private void testSimple(DriverContext ctx, int size, int limit) { int pages = (int) Math.ceil((float) Math.min(size, limit) / factory.maxPageSize()); assertThat(results, hasSize(pages)); } + + // Scores are not interesting to this test, but enabled conditionally and effectively ignored just for coverage. + private final boolean scoring = randomBoolean(); + + // Returns the initial block index, ignoring the score block if scoring is enabled + private int initialBlockIndex(Page page) { + assert page.getBlock(0) instanceof DocBlock : "expected doc block at index 0"; + if (scoring) { + assert page.getBlock(1) instanceof DoubleBlock : "expected double block at index 1"; + return 2; + } else { + return 1; + } + } } diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValueSourceReaderTypeConversionTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValueSourceReaderTypeConversionTests.java index f6d81af7c14e5..f31573f121a71 100644 --- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValueSourceReaderTypeConversionTests.java +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValueSourceReaderTypeConversionTests.java @@ -265,7 +265,8 @@ private SourceOperator simpleInput(DriverContext context, int size, int commitEv DataPartitioning.SHARD, 1,// randomIntBetween(1, 10), pageSize, - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); return luceneFactory.get(context); } @@ -1292,7 +1293,8 @@ public void testWithNulls() throws IOException { randomFrom(DataPartitioning.values()), randomIntBetween(1, 10), randomPageSize(), - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); var vsShardContext = new ValuesSourceReaderOperator.ShardContext(reader(indexKey), () -> SourceLoader.FROM_STORED_SOURCE); try ( @@ -1450,7 +1452,8 @@ public void testManyShards() throws IOException { DataPartitioning.SHARD, randomIntBetween(1, 10), 1000, - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); // TODO add index2 MappedFieldType ft = mapperService(indexKey).fieldType("key"); diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValuesSourceReaderOperatorTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValuesSourceReaderOperatorTests.java index c8dd6f87be5fc..95b313b0b5412 100644 --- a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValuesSourceReaderOperatorTests.java +++ b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/ValuesSourceReaderOperatorTests.java @@ -170,7 +170,8 @@ private SourceOperator simpleInput(DriverContext context, int size, int commitEv DataPartitioning.SHARD, randomIntBetween(1, 10), pageSize, - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); return luceneFactory.get(context); } @@ -1301,7 +1302,8 @@ public void testWithNulls() throws IOException { randomFrom(DataPartitioning.values()), randomIntBetween(1, 10), randomPageSize(), - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); try ( Driver driver = new Driver( @@ -1524,7 +1526,8 @@ public void testManyShards() throws IOException { DataPartitioning.SHARD, randomIntBetween(1, 10), 1000, - LuceneOperator.NO_LIMIT + LuceneOperator.NO_LIMIT, + false // no scoring ); MappedFieldType ft = mapperService.fieldType("key"); var readerFactory = new ValuesSourceReaderOperator.Factory( diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec index 6039dc05b6c44..2c84bdae6b32e 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec @@ -100,7 +100,6 @@ book_no:keyword | title:text 7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1) ; - qstrWithMultivaluedTextField required_capability: qstr_function diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/scoring.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/scoring.csv-spec new file mode 100644 index 0000000000000..d4c7b8c59fdbc --- /dev/null +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/scoring.csv-spec @@ -0,0 +1,285 @@ +############################################### +# Tests for scoring support +# + +singleQstrBoostScoringSorted +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:Lord Rings^2") +| eval c_score = ceil(_score) +| keep book_no, title, c_score +| sort c_score desc, book_no asc +| LIMIT 2; + +book_no:keyword | title:text | c_score:double +2675 | The Lord of the Rings - Boxed Set | 6.0 +4023 | A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings | 6.0 +; + +singleMatchWithKeywordFieldScoring +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where author.keyword:"William Faulkner" +| keep book_no, author, _score +| sort book_no; + +book_no:keyword | author:text | _score:double +2713 | William Faulkner | 2.3142893314361572 +2883 | William Faulkner | 2.3142893314361572 +4724 | William Faulkner | 2.3142893314361572 +4977 | William Faulkner | 2.3142893314361572 +5119 | William Faulkner | 2.3142893314361572 +5404 | William Faulkner | 2.3142893314361572 +5578 | William Faulkner | 2.3142893314361572 +8077 | William Faulkner | 2.3142893314361572 +9896 | William Faulkner | 2.3142893314361572 +; + +qstrWithFieldAndScoringSortedEval +required_capability: qstr_function +required_capability: metadata_score + +from books metadata _score +| where qstr("title:rings") +| sort _score desc +| eval _score::long +| keep book_no, title, _score +| limit 3; + +book_no:keyword | title:text | _score:double +2675 | The Lord of the Rings - Boxed Set | 2.7583377361297607 +7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1) | 1.9239964485168457 +2714 | Return of the King Being the Third Part of The Lord of the Rings | 1.9239964485168457 +; + +qstrWithFieldAndScoringSorted +required_capability: qstr_function +required_capability: metadata_score + +from books metadata _score +| where qstr("title:rings") +| sort _score desc, book_no desc +| keep book_no, title, _score +| limit 3; + +book_no:keyword | title:text | _score:double +2675 | The Lord of the Rings - Boxed Set | 2.7583377361297607 +7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1) | 1.9239964485168457 +2714 | Return of the King Being the Third Part of The Lord of the Rings | 1.9239964485168457 +; + +singleQstrScoringManipulated +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:William Faulkner") +| eval add_score = ceil(_score) + 1 +| keep book_no, author, add_score +| sort book_no +| LIMIT 2; + +book_no:keyword | author:text | add_score:double +2378 | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott] | 2.0 +2713 | William Faulkner | 7.0 +; + +testMultiValuedFieldWithConjunctionWithScore +required_capability: match_function +required_capability: metadata_score + +from employees metadata _score +| where match(job_positions, "Data Scientist") and match(job_positions, "Support Engineer") +| keep emp_no, first_name, last_name, job_positions, _score; + +emp_no:integer | first_name:keyword | last_name:keyword | job_positions:keyword | _score:double +10043 | Yishay | Tzvieli | [Data Scientist, Python Developer, Support Engineer] | 5.233309745788574 +; + +testMatchAndQueryStringFunctionsWithScore +required_capability: match_function +required_capability: metadata_score + +from employees metadata _score +| where match(job_positions, "Data Scientist") and qstr("job_positions: (Support Engineer) and gender: F") +| keep emp_no, first_name, last_name, job_positions, _score; +ignoreOrder:true + +emp_no:integer | first_name:keyword | last_name:keyword | job_positions:keyword | _score:double +10041 | Uri | Lenart | [Data Scientist, Head Human Resources, Internship, Senior Team Lead] | 3.509873867034912 +10043 | Yishay | Tzvieli | [Data Scientist, Python Developer, Support Engineer] | 5.233309745788574 +; + +multipleWhereWithMatchScoringNoSort +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"short stories" +| where author:"Ursula K. Le Guin" +| keep book_no, title, author, _score; + +ignoreOrder:true +book_no:keyword | title:text | author:text | _score:double +8480 | The wind's twelve quarters: Short stories | Ursula K. Le Guin | 14.489097595214844 +; + +multipleWhereWithMatchScoring +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"short stories" +| where author:"Ursula K. Le Guin" +| keep book_no, title, author, _score +| sort book_no; + +book_no:keyword | title:text | author:text | _score:double +8480 | The wind's twelve quarters: Short stories | Ursula K. Le Guin | 14.489097595214844 +; + +combinedMatchWithFunctionsScoring +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"Tolkien" AND author:"Tolkien" AND year > 2000 +| where mv_count(author) == 1 +| keep book_no, title, author, year, _score +| sort book_no; + +book_no:keyword | title:text | author:text | year:integer | _score:double +5335 | Letters of J R R Tolkien | J.R.R. Tolkien | 2014 | 5.448054313659668 +; + +singleQstrScoring +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:William Faulkner") +| keep book_no, author, _score +| sort book_no +| LIMIT 2; + +book_no:keyword | author:text | _score:double +2378 | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott] | 0.9976131916046143 +2713 | William Faulkner | 5.9556169509887695 +; + +singleQstrScoringGrok +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:Lord Rings") +| GROK title "%{WORD:title} %{WORD}" +| sort _score desc +| keep book_no, title, _score +| LIMIT 3; + +book_no:keyword | title:keyword | _score:double +8875 | The | 2.9505908489227295 +4023 | A | 2.8327860832214355 +2675 | The | 2.7583377361297607 +; + +combinedMatchWithScoringEvalNoSort +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"Tolkien" AND author:"Tolkien" AND year > 2000 +| where mv_count(author) == 1 +| eval c_score = ceil(_score) +| keep book_no, title, author, year, c_score; + +ignoreOrder:true +book_no:keyword | title:text | author:text | year:integer | c_score:double +5335 | Letters of J R R Tolkien | J.R.R. Tolkien | 2014 | 6 +; + +singleQstrScoringRename +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:Lord Rings") +| rename _score as rank +| sort rank desc +| keep book_no, rank +| LIMIT 3; + +book_no:keyword | rank:double +8875 | 2.9505908489227295 +4023 | 2.8327860832214355 +2675 | 2.7583377361297607 +; + +singleMatchWithTextFieldScoring +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where author:"William Faulkner" +| sort book_no +| keep book_no, author, _score +| limit 5; + +book_no:keyword | author:text | _score:double +2378 | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott] | 0.9976131916046143 +2713 | William Faulkner | 4.272439002990723 +2847 | Colleen Faulkner | 1.7401835918426514 +2883 | William Faulkner | 4.272439002990723 +3293 | Danny Faulkner | 1.7401835918426514 +; + +combinedMatchWithFunctionsScoringNoSort +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"Tolkien" AND author:"Tolkien" AND year > 2000 +| where mv_count(author) == 1 +| keep book_no, title, author, year, _score; + +ignoreOrder:true +book_no:keyword | title:text | author:text | year:integer | _score:double +5335 | Letters of J R R Tolkien | J.R.R. Tolkien | 2014 | 5.448054313659668 +; + +combinedMatchWithScoringEval +required_capability: metadata_score +required_capability: match_operator_colon + +from books metadata _score +| where title:"Tolkien" AND author:"Tolkien" AND year > 2000 +| where mv_count(author) == 1 +| eval c_score = ceil(_score) +| keep book_no, title, author, year, c_score +| sort book_no; + +book_no:keyword | title:text | author:text | year:integer | c_score:double +5335 | Letters of J R R Tolkien | J.R.R. Tolkien | 2014 | 6 +; + +singleQstrScoringEval +required_capability: metadata_score +required_capability: qstr_function + +from books metadata _score +| where qstr("author:Lord Rings") +| eval c_score = ceil(_score) +| keep book_no, c_score +| sort book_no desc +| LIMIT 3; + +book_no:keyword | c_score:double +8875 | 3.0 +7350 | 2.0 +7140 | 3.0 +; diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java index 56453a291ea81..1939f81353c0e 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/EsqlActionTaskIT.java @@ -89,7 +89,7 @@ public void setup() { assumeTrue("requires query pragmas", canUseQueryPragmas()); nodeLevelReduction = randomBoolean(); READ_DESCRIPTION = """ - \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = 2147483647] + \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = 2147483647, scoreMode = COMPLETE_NO_SCORES] \\_ValuesSourceReaderOperator[fields = [pause_me]] \\_AggregationOperator[mode = INITIAL, aggs = sum of longs] \\_ExchangeSinkOperator""".replace("pageSize()", Integer.toString(pageSize())); @@ -448,6 +448,7 @@ protected void doRun() throws Exception { public void testTaskContentsForTopNQuery() throws Exception { READ_DESCRIPTION = ("\\_LuceneTopNSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = 1000, " + + "scoreMode = TOP_DOCS, " + "sorts = [{\"pause_me\":{\"order\":\"asc\",\"missing\":\"_last\",\"unmapped_type\":\"long\"}}]]\n" + "\\_ValuesSourceReaderOperator[fields = [pause_me]]\n" + "\\_ProjectOperator[projection = [1]]\n" @@ -482,7 +483,7 @@ public void testTaskContentsForTopNQuery() throws Exception { public void testTaskContentsForLimitQuery() throws Exception { String limit = Integer.toString(randomIntBetween(pageSize() + 1, 2 * numberOfDocs())); READ_DESCRIPTION = """ - \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = limit()] + \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = limit(), scoreMode = COMPLETE_NO_SCORES] \\_ValuesSourceReaderOperator[fields = [pause_me]] \\_ProjectOperator[projection = [1]] \\_ExchangeSinkOperator""".replace("pageSize()", Integer.toString(pageSize())).replace("limit()", limit); @@ -511,7 +512,7 @@ public void testTaskContentsForLimitQuery() throws Exception { public void testTaskContentsForGroupingStatsQuery() throws Exception { READ_DESCRIPTION = """ - \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = 2147483647] + \\_LuceneSourceOperator[dataPartitioning = SHARD, maxPageSize = pageSize(), limit = 2147483647, scoreMode = COMPLETE_NO_SCORES] \\_ValuesSourceReaderOperator[fields = [foo]] \\_OrdinalsGroupingOperator(aggs = max of longs) \\_ExchangeSinkOperator""".replace("pageSize()", Integer.toString(pageSize())); diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupFromIndexIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupFromIndexIT.java index 5c0c13b48df3b..3b9359fe66d40 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupFromIndexIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupFromIndexIT.java @@ -148,7 +148,8 @@ public void testLookupIndex() throws IOException { DataPartitioning.SEGMENT, 1, 10000, - DocIdSetIterator.NO_MORE_DOCS + DocIdSetIterator.NO_MORE_DOCS, + false // no scoring ); ValuesSourceReaderOperator.Factory reader = new ValuesSourceReaderOperator.Factory( List.of( diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java new file mode 100644 index 0000000000000..99f7d48a0d636 --- /dev/null +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java @@ -0,0 +1,299 @@ +/* + * 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.plugin; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.xpack.esql.VerificationException; +import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; +import org.elasticsearch.xpack.esql.action.EsqlQueryRequest; +import org.elasticsearch.xpack.esql.action.EsqlQueryResponse; +import org.junit.Before; + +import java.util.List; + +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; +import static org.hamcrest.CoreMatchers.containsString; + +//@TestLogging(value = "org.elasticsearch.xpack.esql:TRACE,org.elasticsearch.compute:TRACE", reason = "debug") +public class MatchFunctionIT extends AbstractEsqlIntegTestCase { + + @Before + public void setupIndex() { + createAndPopulateIndex(); + } + + @Override + protected EsqlQueryResponse run(EsqlQueryRequest request) { + assumeTrue("match function capability not available", EsqlCapabilities.Cap.MATCH_FUNCTION.isEnabled()); + return super.run(request); + } + + public void testSimpleWhereMatch() { + var query = """ + FROM test + | WHERE match(content, "fox") + | KEEP id + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValues(resp.values(), List.of(List.of(1), List.of(6))); + } + } + + public void testCombinedWhereMatch() { + var query = """ + FROM test + | WHERE match(content, "fox") AND id > 5 + | KEEP id + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValues(resp.values(), List.of(List.of(6))); + } + } + + public void testMultipleMatch() { + var query = """ + FROM test + | WHERE match(content, "fox") AND match(content, "brown") + | KEEP id + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValues(resp.values(), List.of(List.of(1), List.of(6))); + } + } + + public void testMultipleWhereMatch() { + var query = """ + FROM test + | WHERE match(content, "fox") AND match(content, "brown") + | EVAL summary = CONCAT("document with id: ", to_str(id), "and content: ", content) + | SORT summary + | LIMIT 4 + | WHERE match(content, "brown fox") + | KEEP id + """; + + var error = expectThrows(ElasticsearchException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MATCH] function cannot be used after LIMIT")); + } + + public void testNotWhereMatch() { + var query = """ + FROM test + | WHERE NOT match(content, "brown fox") + | KEEP id + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id")); + assertColumnTypes(resp.columns(), List.of("integer")); + assertValues(resp.values(), List.of(List.of(5))); + } + } + + public void testWhereMatchWithScoring() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE match(content, "fox") + | KEEP id, _score + | SORT id ASC + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + + public void testWhereMatchWithScoringDifferentSort() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE match(content, "fox") + | KEEP id, _score + | SORT id DESC + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues(resp.values(), List.of(List.of(6, 0.9114001989364624), List.of(1, 1.156558871269226))); + } + } + + public void testWhereMatchWithScoringSortScore() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE match(content, "fox") + | KEEP id, _score + | SORT _score DESC + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + + public void testWhereMatchWithScoringNoSort() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE content:"fox" + | KEEP id, _score + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValuesInAnyOrder(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + + public void testNonExistingColumn() { + var query = """ + FROM test + | WHERE something:"fox" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("Unknown column [something]")); + } + + public void testWhereMatchEvalColumn() { + var query = """ + FROM test + | EVAL upper_content = to_upper(content) + | WHERE upper_content:"FOX" + | KEEP id + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat( + error.getMessage(), + containsString("[:] operator cannot operate on [upper_content], which is not a field from an index mapping") + ); + } + + public void testWhereMatchOverWrittenColumn() { + var query = """ + FROM test + | DROP content + | EVAL content = CONCAT("document with ID ", to_str(id)) + | WHERE content:"document" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat( + error.getMessage(), + containsString("[:] operator cannot operate on [content], which is not a field from an index mapping") + ); + } + + public void testWhereMatchAfterStats() { + var query = """ + FROM test + | STATS count(*) + | WHERE content:"fox" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("Unknown column [content]")); + } + + public void testWhereMatchWithFunctions() { + var query = """ + FROM test + | WHERE content:"fox" OR to_upper(content) == "FOX" + """; + var error = expectThrows(ElasticsearchException.class, () -> run(query)); + assertThat( + error.getMessage(), + containsString( + "Invalid condition [content:\"fox\" OR to_upper(content) == \"FOX\"]. " + + "[:] operator can't be used as part of an or condition" + ) + ); + } + + public void testWhereMatchWithRow() { + var query = """ + ROW content = "a brown fox" + | WHERE content:"fox" + """; + + var error = expectThrows(ElasticsearchException.class, () -> run(query)); + assertThat( + error.getMessage(), + containsString("[:] operator cannot operate on [\"a brown fox\"], which is not a field from an index mapping") + ); + } + + public void testMatchWithinEval() { + var query = """ + FROM test + | EVAL matches_query = content:"fox" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[:] operator is only supported in WHERE commands")); + } + + public void testMatchWithNonTextField() { + var query = """ + FROM test + | WHERE id:"fox" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("first argument of [id:\"fox\"] must be [string], found value [id] type [integer]")); + } + + private void createAndPopulateIndex() { + var indexName = "test"; + var client = client().admin().indices(); + var CreateRequest = client.prepareCreate(indexName) + .setSettings(Settings.builder().put("index.number_of_shards", 1)) + .setMapping("id", "type=integer", "content", "type=text"); + assertAcked(CreateRequest); + client().prepareBulk() + .add(new IndexRequest(indexName).id("1").source("id", 1, "content", "This is a brown fox")) + .add(new IndexRequest(indexName).id("2").source("id", 2, "content", "This is a brown dog")) + .add(new IndexRequest(indexName).id("3").source("id", 3, "content", "This dog is really brown")) + .add(new IndexRequest(indexName).id("4").source("id", 4, "content", "The dog is brown but this document is very very long")) + .add(new IndexRequest(indexName).id("5").source("id", 5, "content", "There is also a white cat")) + .add(new IndexRequest(indexName).id("6").source("id", 6, "content", "The quick brown fox jumps over the lazy dog")) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .get(); + ensureYellow(indexName); + } +} diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java index 3b647583f1129..6a360eb319abb 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java @@ -14,6 +14,7 @@ import org.elasticsearch.test.junit.annotations.TestLogging; import org.elasticsearch.xpack.esql.VerificationException; import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; import org.junit.Before; import java.util.List; @@ -105,6 +106,56 @@ public void testNotWhereMatch() { } } + public void testWhereMatchWithScoring() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE content:"fox" + | KEEP id, _score + | SORT id ASC + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + + public void testWhereMatchWithScoringDifferentSort() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE content:"fox" + | KEEP id, _score + | SORT id + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + + public void testWhereMatchWithScoringNoSort() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE content:"fox" + | KEEP id, _score + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValuesInAnyOrder(resp.values(), List.of(List.of(1, 1.156558871269226), List.of(6, 0.9114001989364624))); + } + } + public void testNonExistingColumn() { var query = """ FROM test diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java index 03af16d29e9b4..a3d1ac931528c 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java @@ -13,6 +13,7 @@ import org.elasticsearch.index.query.QueryShardException; import org.elasticsearch.xpack.esql.VerificationException; import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; import org.junit.Before; import java.util.List; @@ -137,4 +138,99 @@ private void createAndPopulateIndex() { .get(); ensureYellow(indexName); } + + public void testWhereQstrWithScoring() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE qstr("content: fox") + | KEEP id, _score + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValuesInAnyOrder( + resp.values(), + List.of( + List.of(2, 0.3028995096683502), + List.of(3, 0.3028995096683502), + List.of(4, 0.2547692656517029), + List.of(5, 0.28161853551864624) + ) + ); + + } + } + + public void testWhereQstrWithScoringSorted() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE qstr("content:fox fox") + | KEEP id, _score + | SORT _score DESC + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValues( + resp.values(), + List.of( + List.of(3, 1.5605685710906982), + List.of(2, 0.6057990193367004), + List.of(5, 0.5632370710372925), + List.of(4, 0.5095385313034058) + ) + ); + + } + } + + public void testWhereQstrWithScoringNoSort() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE qstr("content: fox") + | KEEP id, _score + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValuesInAnyOrder( + resp.values(), + List.of( + List.of(2, 0.3028995096683502), + List.of(3, 0.3028995096683502), + List.of(4, 0.2547692656517029), + List.of(5, 0.28161853551864624) + ) + ); + } + } + + public void testWhereQstrWithNonPushableAndScoring() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var query = """ + FROM test + METADATA _score + | WHERE qstr("content: fox") + AND abs(id) > 0 + | EVAL c_score = ceil(_score) + | KEEP id, c_score + | SORT id DESC + | LIMIT 2 + """; + + try (var resp = run(query)) { + assertColumnNames(resp.columns(), List.of("id", "c_score")); + assertColumnTypes(resp.columns(), List.of("integer", "double")); + assertValuesInAnyOrder(resp.values(), List.of(List.of(5, 1.0), List.of(4, 1.0))); + } + } } 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 d8004f73f613f..9bd4211855699 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 @@ -539,7 +539,12 @@ public enum Cap { /** * Fix for https://github.com/elastic/elasticsearch/issues/114714, again */ - FIX_STATS_BY_FOLDABLE_EXPRESSION_2,; + FIX_STATS_BY_FOLDABLE_EXPRESSION_2, + + /** + * Support the "METADATA _score" directive to enable _score column. + */ + METADATA_SCORE(Build.current().isSnapshot()); private final boolean enabled; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java index 2be13398dab2f..5f8c011cff53a 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java @@ -19,6 +19,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.Expressions; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.expression.NameId; import org.elasticsearch.xpack.esql.core.expression.NamedExpression; import org.elasticsearch.xpack.esql.core.expression.TypeResolutions; @@ -221,6 +222,7 @@ else if (p instanceof Lookup lookup) { checkFullTextQueryFunctions(p, failures); }); checkRemoteEnrich(plan, failures); + checkMetadataScoreNameReserved(plan, failures); if (failures.isEmpty()) { checkLicense(plan, licenseState, failures); @@ -234,6 +236,13 @@ else if (p instanceof Lookup lookup) { return failures; } + private static void checkMetadataScoreNameReserved(LogicalPlan p, Set failures) { + // _score can only be set as metadata attribute + if (p.inputSet().stream().anyMatch(a -> MetadataAttribute.SCORE.equals(a.name()) && (a instanceof MetadataAttribute) == false)) { + failures.add(fail(p, "`" + MetadataAttribute.SCORE + "` is a reserved METADATA attribute")); + } + } + private void checkSort(LogicalPlan p, Set failures) { if (p instanceof OrderBy ob) { ob.order().forEach(o -> { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/LucenePushdownPredicates.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/LucenePushdownPredicates.java index feb8717f007b7..8046d6bc56607 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/LucenePushdownPredicates.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/LucenePushdownPredicates.java @@ -9,6 +9,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.stats.SearchStats; @@ -59,6 +60,10 @@ default boolean isPushableFieldAttribute(Expression exp) { return false; } + default boolean isPushableMetadataAttribute(Expression exp) { + return exp instanceof MetadataAttribute ma && ma.name().equals(MetadataAttribute.SCORE); + } + /** * The default implementation of this has no access to SearchStats, so it can only make decisions based on the FieldAttribute itself. * In particular, it assumes TEXT fields have no exact subfields (underlying keyword field), diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSource.java index 925e144b69fcc..2b531257e594a 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSource.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSource.java @@ -14,6 +14,7 @@ import org.elasticsearch.xpack.esql.core.expression.AttributeMap; import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.expression.NameId; import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute; import org.elasticsearch.xpack.esql.expression.Order; @@ -57,6 +58,7 @@ * */ public class PushTopNToSource extends PhysicalOptimizerRules.ParameterizedOptimizerRule { + @Override protected PhysicalPlan rule(TopNExec topNExec, LocalPhysicalOptimizerContext ctx) { Pushable pushable = evaluatePushable(topNExec, LucenePushdownPredicates.from(ctx.searchStats())); @@ -155,6 +157,8 @@ && canPushDownOrders(topNExec.order(), lucenePushdownPredicates)) { order.nullsPosition() ) ); + } else if (lucenePushdownPredicates.isPushableMetadataAttribute(order.child())) { + pushableSorts.add(new EsQueryExec.ScoreSort(order.direction())); } else if (order.child() instanceof ReferenceAttribute referenceAttribute) { Attribute resolvedAttribute = aliasReplacedBy.resolve(referenceAttribute, referenceAttribute); if (distances.containsKey(resolvedAttribute.id())) { @@ -192,13 +196,23 @@ && canPushDownOrders(topNExec.order(), lucenePushdownPredicates)) { private static boolean canPushDownOrders(List orders, LucenePushdownPredicates lucenePushdownPredicates) { // allow only exact FieldAttributes (no expressions) for sorting - return orders.stream().allMatch(o -> lucenePushdownPredicates.isPushableFieldAttribute(o.child())); + return orders.stream() + .allMatch( + o -> lucenePushdownPredicates.isPushableFieldAttribute(o.child()) + || lucenePushdownPredicates.isPushableMetadataAttribute(o.child()) + ); } private static List buildFieldSorts(List orders) { List sorts = new ArrayList<>(orders.size()); for (Order o : orders) { - sorts.add(new EsQueryExec.FieldSort(((FieldAttribute) o.child()).exactAttribute(), o.direction(), o.nullsPosition())); + if (o.child() instanceof FieldAttribute fa) { + sorts.add(new EsQueryExec.FieldSort(fa.exactAttribute(), o.direction(), o.nullsPosition())); + } else if (o.child() instanceof MetadataAttribute ma && MetadataAttribute.SCORE.equals(ma.name())) { + sorts.add(new EsQueryExec.ScoreSort(o.direction())); + } else { + assert false : "unexpected ordering on expression type " + o.child().getClass(); + } } return sorts; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/ReplaceSourceAttributes.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/ReplaceSourceAttributes.java index 74ea6f99e5e59..11e386ddd046c 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/ReplaceSourceAttributes.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/ReplaceSourceAttributes.java @@ -16,6 +16,7 @@ import org.elasticsearch.xpack.esql.plan.physical.EsSourceExec; import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan; +import java.util.ArrayList; import java.util.List; import static org.elasticsearch.xpack.esql.optimizer.rules.logical.OptimizerRules.TransformDirection.UP; @@ -29,6 +30,8 @@ public ReplaceSourceAttributes() { @Override protected PhysicalPlan rule(EsSourceExec plan) { var docId = new FieldAttribute(plan.source(), EsQueryExec.DOC_ID_FIELD.getName(), EsQueryExec.DOC_ID_FIELD); + final List attributes = new ArrayList<>(); + attributes.add(docId); if (plan.indexMode() == IndexMode.TIME_SERIES) { Attribute tsid = null, timestamp = null; for (Attribute attr : plan.output()) { @@ -42,9 +45,14 @@ protected PhysicalPlan rule(EsSourceExec plan) { if (tsid == null || timestamp == null) { throw new IllegalStateException("_tsid or @timestamp are missing from the time-series source"); } - return new EsQueryExec(plan.source(), plan.index(), plan.indexMode(), List.of(docId, tsid, timestamp), plan.query()); - } else { - return new EsQueryExec(plan.source(), plan.index(), plan.indexMode(), List.of(docId), plan.query()); + attributes.add(tsid); + attributes.add(timestamp); } + plan.output().forEach(attr -> { + if (attr instanceof MetadataAttribute ma && ma.name().equals(MetadataAttribute.SCORE)) { + attributes.add(ma); + } + }); + return new EsQueryExec(plan.source(), plan.index(), plan.indexMode(), attributes, plan.query()); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java index 99e03b3653f79..24398afa18010 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java @@ -16,6 +16,7 @@ import org.elasticsearch.dissect.DissectParser; import org.elasticsearch.index.IndexMode; import org.elasticsearch.xpack.esql.VerificationException; +import org.elasticsearch.xpack.esql.action.EsqlCapabilities; import org.elasticsearch.xpack.esql.common.Failure; import org.elasticsearch.xpack.esql.core.expression.Alias; import org.elasticsearch.xpack.esql.core.expression.Attribute; @@ -276,7 +277,8 @@ public LogicalPlan visitFromCommand(EsqlBaseParser.FromCommandContext ctx) { for (var c : metadataOptionContext.UNQUOTED_SOURCE()) { String id = c.getText(); Source src = source(c); - if (MetadataAttribute.isSupported(id) == false) { + if (MetadataAttribute.isSupported(id) == false // TODO: drop check below once METADATA_SCORE is no longer snapshot-only + || (EsqlCapabilities.Cap.METADATA_SCORE.isEnabled() == false && MetadataAttribute.SCORE.equals(id))) { throw new ParsingException(src, "unsupported metadata field [" + id + "]"); } Attribute a = metadataMap.put(id, MetadataAttribute.create(src, id)); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/EsQueryExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/EsQueryExec.java index 82848fb2f1062..267b9e613abef 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/EsQueryExec.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/EsQueryExec.java @@ -15,6 +15,7 @@ import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.search.sort.FieldSortBuilder; import org.elasticsearch.search.sort.GeoDistanceSortBuilder; +import org.elasticsearch.search.sort.ScoreSortBuilder; import org.elasticsearch.search.sort.SortBuilder; import org.elasticsearch.search.sort.SortOrder; import org.elasticsearch.xpack.esql.core.expression.Attribute; @@ -94,6 +95,19 @@ public SortBuilder sortBuilder() { } } + public record ScoreSort(Order.OrderDirection direction) implements Sort { + @Override + public SortBuilder sortBuilder() { + return new ScoreSortBuilder(); + } + + @Override + public FieldAttribute field() { + // TODO: refactor this: not all Sorts are backed by FieldAttributes + return null; + } + } + public EsQueryExec(Source source, EsIndex index, IndexMode indexMode, List attributes, QueryBuilder query) { this(source, index, indexMode, attributes, query, null, null, null); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java index ab0d68b152262..15f5b6579098d 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java @@ -51,6 +51,7 @@ import org.elasticsearch.xpack.esql.core.expression.Attribute; import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.MultiTypeEsField; import org.elasticsearch.xpack.esql.expression.function.scalar.convert.AbstractConvertFunction; @@ -165,7 +166,10 @@ public final PhysicalOperation sourcePhysicalOperation(EsQueryExec esQueryExec, assert esQueryExec.estimatedRowSize() != null : "estimated row size not initialized"; int rowEstimatedSize = esQueryExec.estimatedRowSize(); int limit = esQueryExec.limit() != null ? (Integer) esQueryExec.limit().fold() : NO_LIMIT; - if (sorts != null && sorts.isEmpty() == false) { + boolean scoring = esQueryExec.attrs() + .stream() + .anyMatch(a -> a instanceof MetadataAttribute && a.name().equals(MetadataAttribute.SCORE)); + if ((sorts != null && sorts.isEmpty() == false)) { List> sortBuilders = new ArrayList<>(sorts.size()); for (Sort sort : sorts) { sortBuilders.add(sort.sortBuilder()); @@ -177,7 +181,8 @@ public final PhysicalOperation sourcePhysicalOperation(EsQueryExec esQueryExec, context.queryPragmas().taskConcurrency(), context.pageSize(rowEstimatedSize), limit, - sortBuilders + sortBuilders, + scoring ); } else { if (esQueryExec.indexMode() == IndexMode.TIME_SERIES) { @@ -195,7 +200,8 @@ public final PhysicalOperation sourcePhysicalOperation(EsQueryExec esQueryExec, context.queryPragmas().dataPartitioning(), context.queryPragmas().taskConcurrency(), context.pageSize(rowEstimatedSize), - limit + limit, + scoring ); } } @@ -273,7 +279,7 @@ public IndexSearcher searcher() { @Override public Optional buildSort(List> sorts) throws IOException { - return SortBuilder.buildSort(sorts, ctx); + return SortBuilder.buildSort(sorts, ctx, false); } @Override 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 355073fcc873f..6074601535477 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 @@ -12,6 +12,7 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.esql.VerificationException; import org.elasticsearch.xpack.esql.action.EsqlCapabilities; +import org.elasticsearch.xpack.esql.core.expression.Attribute; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.EsField; import org.elasticsearch.xpack.esql.core.type.InvalidMappedField; @@ -21,6 +22,7 @@ import org.elasticsearch.xpack.esql.parser.EsqlParser; import org.elasticsearch.xpack.esql.parser.QueryParam; import org.elasticsearch.xpack.esql.parser.QueryParams; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -1754,6 +1756,29 @@ public void testToDatePeriodToTimeDurationWithInvalidType() { ); } + public void testNonMetadataScore() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + assertEquals("1:12: `_score` is a reserved METADATA attribute", error("from foo | eval _score = 10")); + + assertEquals( + "1:48: `_score` is a reserved METADATA attribute", + error("from foo metadata _score | where qstr(\"bar\") | eval _score = _score + 1") + ); + } + + public void testScoreRenaming() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + assertEquals("1:33: `_score` is a reserved METADATA attribute", error("from foo METADATA _id, _score | rename _id as _score")); + + assertTrue(passes("from foo metadata _score | rename _score as foo").stream().anyMatch(a -> a.name().equals("foo"))); + } + + private List passes(String query) { + LogicalPlan logicalPlan = defaultAnalyzer.analyze(parser.createStatement(query)); + assertTrue(logicalPlan.resolved()); + return logicalPlan.output(); + } + public void testIntervalAsString() { // DateTrunc for (String interval : List.of("1 minu", "1 dy", "1.5 minutes", "0.5 days", "minutes 1", "day 5")) { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java index f3ba11457a715..1f131f79c3d0e 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java @@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.EsqlTestUtils.TestConfigurableSearchStats; import org.elasticsearch.xpack.esql.EsqlTestUtils.TestConfigurableSearchStats.Config; 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.EnrichResolution; @@ -63,6 +64,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.SpatialAggregateFunction; 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.fulltext.Match; import org.elasticsearch.xpack.esql.expression.function.scalar.math.Round; import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialContains; import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialDisjoint; @@ -6581,6 +6583,66 @@ public void testLookupThenTopN() { ); } + public void testScore() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var plan = physicalPlan(""" + from test metadata _score + | where match(first_name, "john") + | keep _score + """); + + ProjectExec outerProject = as(plan, ProjectExec.class); + LimitExec limitExec = as(outerProject.child(), LimitExec.class); + ExchangeExec exchange = as(limitExec.child(), ExchangeExec.class); + FragmentExec frag = as(exchange.child(), FragmentExec.class); + + LogicalPlan opt = logicalOptimizer.optimize(frag.fragment()); + Limit limit = as(opt, Limit.class); + Filter filter = as(limit.child(), Filter.class); + + Match match = as(filter.condition(), Match.class); + assertTrue(match.field() instanceof FieldAttribute); + assertEquals("first_name", ((FieldAttribute) match.field()).field().getName()); + + EsRelation esRelation = as(filter.child(), EsRelation.class); + assertTrue(esRelation.optimized()); + assertTrue(esRelation.resolved()); + assertTrue(esRelation.output().stream().anyMatch(a -> a.name().equals(MetadataAttribute.SCORE) && a instanceof MetadataAttribute)); + } + + public void testScoreTopN() { + assumeTrue("'METADATA _score' is disabled", EsqlCapabilities.Cap.METADATA_SCORE.isEnabled()); + var plan = physicalPlan(""" + from test metadata _score + | where match(first_name, "john") + | keep _score + | sort _score desc + """); + + ProjectExec projectExec = as(plan, ProjectExec.class); + TopNExec topNExec = as(projectExec.child(), TopNExec.class); + ExchangeExec exchange = as(topNExec.child(), ExchangeExec.class); + FragmentExec frag = as(exchange.child(), FragmentExec.class); + + LogicalPlan opt = logicalOptimizer.optimize(frag.fragment()); + TopN topN = as(opt, TopN.class); + List order = topN.order(); + Order scoreOrer = order.getFirst(); + assertEquals(Order.OrderDirection.DESC, scoreOrer.direction()); + Expression child = scoreOrer.child(); + assertTrue(child instanceof MetadataAttribute ma && ma.name().equals(MetadataAttribute.SCORE)); + Filter filter = as(topN.child(), Filter.class); + + Match match = as(filter.condition(), Match.class); + assertTrue(match.field() instanceof FieldAttribute); + assertEquals("first_name", ((FieldAttribute) match.field()).field().getName()); + + EsRelation esRelation = as(filter.child(), EsRelation.class); + assertTrue(esRelation.optimized()); + assertTrue(esRelation.resolved()); + assertTrue(esRelation.output().stream().anyMatch(a -> a.name().equals(MetadataAttribute.SCORE) && a instanceof MetadataAttribute)); + } + @SuppressWarnings("SameParameterValue") private static void assertFilterCondition( Filter filter, diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSourceTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSourceTests.java index 98f0af8e4b8e6..2429bcb1a1b04 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSourceTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushTopNToSourceTests.java @@ -20,6 +20,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; import org.elasticsearch.xpack.esql.core.expression.Nullability; import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute; import org.elasticsearch.xpack.esql.core.tree.Source; @@ -64,6 +65,13 @@ public void testSimpleSortField() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleScoreSortField() { + // FROM index METADATA _score | SORT _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false).scoreSort().limit(10); + assertPushdownSort(query); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortMultipleFields() { // FROM index | SORT field, integer, double | LIMIT 10 var query = from("index").sort("field").sort("integer").sort("double").limit(10); @@ -71,6 +79,13 @@ public void testSimpleSortMultipleFields() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortMultipleFieldsAndScore() { + // FROM index | SORT field, integer, double, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false).sort("field").sort("integer").sort("double").scoreSort().limit(10); + assertPushdownSort(query); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortFieldAndEvalLiteral() { // FROM index | EVAL x = 1 | SORT field | LIMIT 10 var query = from("index").eval("x", e -> e.i(1)).sort("field").limit(10); @@ -78,6 +93,13 @@ public void testSimpleSortFieldAndEvalLiteral() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortFieldScoreAndEvalLiteral() { + // FROM index METADATA _score | EVAL x = 1 | SORT field, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false).eval("x", e -> e.i(1)).sort("field").scoreSort().limit(10); + assertPushdownSort(query, List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortFieldWithAlias() { // FROM index | EVAL x = field | SORT field | LIMIT 10 var query = from("index").eval("x", b -> b.field("field")).sort("field").limit(10); @@ -98,6 +120,21 @@ public void testSimpleSortMultipleFieldsWithAliases() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortMultipleFieldsWithAliasesAndScore() { + // FROM index | EVAL x = field, y = integer, z = double | SORT field, integer, double, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("x", b -> b.field("field")) + .eval("y", b -> b.field("integer")) + .eval("z", b -> b.field("double")) + .sort("field") + .sort("integer") + .sort("double") + .scoreSort() + .limit(10); + assertPushdownSort(query, List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortFieldAsAlias() { // FROM index | EVAL x = field | SORT x | LIMIT 10 var query = from("index").eval("x", b -> b.field("field")).sort("x").limit(10); @@ -105,6 +142,13 @@ public void testSimpleSortFieldAsAlias() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortFieldAsAliasAndScore() { + // FROM index METADATA _score | EVAL x = field | SORT x, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false).eval("x", b -> b.field("field")).sort("x").scoreSort().limit(10); + assertPushdownSort(query, Map.of("x", "field"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortFieldAndEvalSumLiterals() { // FROM index | EVAL sum = 1 + 2 | SORT field | LIMIT 10 var query = from("index").eval("sum", b -> b.add(b.i(1), b.i(2))).sort("field").limit(10); @@ -112,6 +156,17 @@ public void testSimpleSortFieldAndEvalSumLiterals() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortFieldAndEvalSumLiteralsAndScore() { + // FROM index METADATA _score | EVAL sum = 1 + 2 | SORT field, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("sum", b -> b.add(b.i(1), b.i(2))) + .sort("field") + .scoreSort() + .limit(10); + assertPushdownSort(query, List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortFieldAndEvalSumLiteralAndField() { // FROM index | EVAL sum = 1 + integer | SORT integer | LIMIT 10 var query = from("index").eval("sum", b -> b.add(b.i(1), b.field("integer"))).sort("integer").limit(10); @@ -119,6 +174,17 @@ public void testSimpleSortFieldAndEvalSumLiteralAndField() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSimpleSortFieldAndEvalSumLiteralAndFieldAndScore() { + // FROM index METADATA _score | EVAL sum = 1 + integer | SORT integer, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("sum", b -> b.add(b.i(1), b.field("integer"))) + .sort("integer") + .scoreSort() + .limit(10); + assertPushdownSort(query, List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSimpleSortEvalSumLiteralAndField() { // FROM index | EVAL sum = 1 + integer | SORT sum | LIMIT 10 var query = from("index").eval("sum", b -> b.add(b.i(1), b.field("integer"))).sort("sum").limit(10); @@ -144,6 +210,14 @@ public void testSortGeoPointField() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoPointFieldAnsScore() { + // FROM index METADATA _score | SORT location, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false).sort("location", Order.OrderDirection.ASC).scoreSort().limit(10); + // NOTE: while geo_point is not sortable, this is checked during logical planning and the physical planner does not know or care + assertPushdownSort(query); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunction() { // FROM index | EVAL distance = ST_DISTANCE(location, POINT(1 2)) | SORT distance | LIMIT 10 var query = from("index").eval("distance", b -> b.distance("location", "POINT(1 2)")) @@ -154,6 +228,18 @@ public void testSortGeoDistanceFunction() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionAndScore() { + // FROM index METADATA _score | EVAL distance = ST_DISTANCE(location, POINT(1 2)) | SORT distance, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("distance", b -> b.distance("location", "POINT(1 2)")) + .sort("distance", Order.OrderDirection.ASC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertPushdownSort(query, Map.of("distance", "location"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunctionInverted() { // FROM index | EVAL distance = ST_DISTANCE(POINT(1 2), location) | SORT distance | LIMIT 10 var query = from("index").eval("distance", b -> b.distance("POINT(1 2)", "location")) @@ -164,6 +250,18 @@ public void testSortGeoDistanceFunctionInverted() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionInvertedAndScore() { + // FROM index METADATA _score | EVAL distance = ST_DISTANCE(POINT(1 2), location) | SORT distance, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("distance", b -> b.distance("POINT(1 2)", "location")) + .sort("distance", Order.OrderDirection.ASC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertPushdownSort(query, Map.of("distance", "location"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunctionLiterals() { // FROM index | EVAL distance = ST_DISTANCE(POINT(2 1), POINT(1 2)) | SORT distance | LIMIT 10 var query = from("index").eval("distance", b -> b.distance("POINT(2 1)", "POINT(1 2)")) @@ -174,6 +272,18 @@ public void testSortGeoDistanceFunctionLiterals() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionLiteralsAndScore() { + // FROM index METADATA _score | EVAL distance = ST_DISTANCE(POINT(2 1), POINT(1 2)) | SORT distance, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("distance", b -> b.distance("POINT(2 1)", "POINT(1 2)")) + .sort("distance", Order.OrderDirection.ASC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertNoPushdownSort(query, "sort on foldable distance function"); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunctionAndFieldsWithAliases() { // FROM index | EVAL distance = ST_DISTANCE(location, POINT(1 2)), x = field | SORT distance, field, integer | LIMIT 10 var query = from("index").eval("distance", b -> b.distance("location", "POINT(1 2)")) @@ -187,6 +297,21 @@ public void testSortGeoDistanceFunctionAndFieldsWithAliases() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionAndFieldsWithAliasesAndScore() { + // FROM index | EVAL distance = ST_DISTANCE(location, POINT(1 2)), x = field | SORT distance, field, integer, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("distance", b -> b.distance("location", "POINT(1 2)")) + .eval("x", b -> b.field("field")) + .sort("distance", Order.OrderDirection.ASC) + .sort("field", Order.OrderDirection.DESC) + .sort("integer", Order.OrderDirection.DESC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertPushdownSort(query, query.orders, Map.of("distance", "location"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunctionAndFieldsAndAliases() { // FROM index | EVAL distance = ST_DISTANCE(location, POINT(1 2)), x = field | SORT distance, x, integer | LIMIT 10 var query = from("index").eval("distance", b -> b.distance("location", "POINT(1 2)")) @@ -200,6 +325,21 @@ public void testSortGeoDistanceFunctionAndFieldsAndAliases() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionAndFieldsAndAliasesAndScore() { + // FROM index | EVAL distance = ST_DISTANCE(location, POINT(1 2)), x = field | SORT distance, x, integer, _score | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("distance", b -> b.distance("location", "POINT(1 2)")) + .eval("x", b -> b.field("field")) + .sort("distance", Order.OrderDirection.ASC) + .sort("x", Order.OrderDirection.DESC) + .sort("integer", Order.OrderDirection.DESC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertPushdownSort(query, query.orders, Map.of("distance", "location", "x", "field"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + public void testSortGeoDistanceFunctionAndFieldsAndManyAliases() { // FROM index // | EVAL loc = location, loc2 = loc, loc3 = loc2, distance = ST_DISTANCE(loc3, POINT(1 2)), x = field @@ -219,6 +359,27 @@ public void testSortGeoDistanceFunctionAndFieldsAndManyAliases() { assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); } + public void testSortGeoDistanceFunctionAndFieldsAndManyAliasesAndScore() { + // FROM index METADATA _score + // | EVAL loc = location, loc2 = loc, loc3 = loc2, distance = ST_DISTANCE(loc3, POINT(1 2)), x = field + // | SORT distance, x, integer, _score + // | LIMIT 10 + var query = from("index").metadata("_score", DOUBLE, false) + .eval("loc", b -> b.field("location")) + .eval("loc2", b -> b.ref("loc")) + .eval("loc3", b -> b.ref("loc2")) + .eval("distance", b -> b.distance("loc3", "POINT(1 2)")) + .eval("x", b -> b.field("field")) + .sort("distance", Order.OrderDirection.ASC) + .sort("x", Order.OrderDirection.DESC) + .sort("integer", Order.OrderDirection.DESC) + .scoreSort() + .limit(10); + // The pushed-down sort will use the underlying field 'location', not the sorted reference field 'distance' + assertPushdownSort(query, Map.of("distance", "location", "x", "field"), List.of(EvalExec.class, EsQueryExec.class)); + assertNoPushdownSort(query.asTimeSeries(), "for time series index mode"); + } + private static void assertPushdownSort(TestPhysicalPlanBuilder builder) { assertPushdownSort(builder, null, List.of(EsQueryExec.class)); } @@ -289,9 +450,12 @@ private static void assertPushdownSort( assertThat("Expect sorts count to match", sorts.size(), is(expectedSorts.size())); for (int i = 0; i < expectedSorts.size(); i++) { String name = ((Attribute) expectedSorts.get(i).child()).name(); - String fieldName = sorts.get(i).field().fieldName(); - assertThat("Expect sort[" + i + "] name to match", fieldName, is(sortName(name, fieldMap))); - assertThat("Expect sort[" + i + "] direction to match", sorts.get(i).direction(), is(expectedSorts.get(i).direction())); + EsQueryExec.Sort sort = sorts.get(i); + if (sort.field() != null) { + String fieldName = sort.field().fieldName(); + assertThat("Expect sort[" + i + "] name to match", fieldName, is(sortName(name, fieldMap))); + } + assertThat("Expect sort[" + i + "] direction to match", sort.direction(), is(expectedSorts.get(i).direction())); } } @@ -317,6 +481,7 @@ static class TestPhysicalPlanBuilder { private final String index; private final LinkedHashMap fields; private final LinkedHashMap refs; + private final LinkedHashMap metadata; private IndexMode indexMode; private final List aliases = new ArrayList<>(); private final List orders = new ArrayList<>(); @@ -327,6 +492,7 @@ private TestPhysicalPlanBuilder(String index, IndexMode indexMode) { this.indexMode = indexMode; this.fields = new LinkedHashMap<>(); this.refs = new LinkedHashMap<>(); + this.metadata = new LinkedHashMap<>(); addSortableFieldAttributes(this.fields); } @@ -346,6 +512,11 @@ static TestPhysicalPlanBuilder from(String index) { return new TestPhysicalPlanBuilder(index, IndexMode.STANDARD); } + TestPhysicalPlanBuilder metadata(String metadataAttribute, DataType dataType, boolean searchable) { + metadata.put(metadataAttribute, new MetadataAttribute(Source.EMPTY, metadataAttribute, dataType, searchable)); + return this; + } + public TestPhysicalPlanBuilder eval(Alias... aliases) { if (orders.isEmpty() == false) { throw new IllegalArgumentException("Eval must be before sort"); @@ -376,6 +547,22 @@ public TestPhysicalPlanBuilder sort(String field) { return sort(field, Order.OrderDirection.ASC); } + public TestPhysicalPlanBuilder scoreSort(Order.OrderDirection direction) { + orders.add( + new Order( + Source.EMPTY, + MetadataAttribute.create(Source.EMPTY, MetadataAttribute.SCORE), + direction, + Order.NullsPosition.LAST + ) + ); + return this; + } + + public TestPhysicalPlanBuilder scoreSort() { + return scoreSort(Order.OrderDirection.DESC); + } + public TestPhysicalPlanBuilder sort(String field, Order.OrderDirection direction) { Attribute attr = refs.get(field); if (attr == null) { From 6b94a91633fc846fe02ac8cf3173d613af27bc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Thu, 28 Nov 2024 16:07:07 +0100 Subject: [PATCH 097/129] ESQL: Add nulls support to Categorize (#117655) Handle nulls and empty strings (Which resolve to null) on Categorize grouping function. Also, implement `seenGroupIds()`, which would fail some queries with nulls otherwise. --- docs/changelog/117655.yaml | 5 + .../AbstractCategorizeBlockHash.java | 37 +++++- .../blockhash/CategorizeRawBlockHash.java | 12 +- .../CategorizedIntermediateBlockHash.java | 19 ++- .../blockhash/CategorizeBlockHashTests.java | 72 +++++++---- .../src/main/resources/categorize.csv-spec | 122 ++++++++++-------- .../xpack/esql/action/EsqlCapabilities.java | 5 +- .../xpack/esql/analysis/VerifierTests.java | 6 +- .../optimizer/LogicalPlanOptimizerTests.java | 4 +- .../categorization/TokenListCategorizer.java | 2 + 10 files changed, 186 insertions(+), 98 deletions(-) create mode 100644 docs/changelog/117655.yaml diff --git a/docs/changelog/117655.yaml b/docs/changelog/117655.yaml new file mode 100644 index 0000000000000..f2afd3570f104 --- /dev/null +++ b/docs/changelog/117655.yaml @@ -0,0 +1,5 @@ +pr: 117655 +summary: Add nulls support to Categorize +area: ES|QL +type: enhancement +issues: [] 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 index 22d3a10facb06..0e89d77820883 100644 --- 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 @@ -13,8 +13,10 @@ import org.elasticsearch.common.util.BigArrays; import org.elasticsearch.common.util.BitArray; import org.elasticsearch.common.util.BytesRefHash; +import org.elasticsearch.compute.aggregation.SeenGroupIds; 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; @@ -31,11 +33,21 @@ * Base BlockHash implementation for {@code Categorize} grouping function. */ public abstract class AbstractCategorizeBlockHash extends BlockHash { + protected static final int NULL_ORD = 0; + // TODO: this should probably also take an emitBatchSize private final int channel; private final boolean outputPartial; protected final TokenListCategorizer.CloseableTokenListCategorizer categorizer; + /** + * Store whether we've seen any {@code null} values. + *

+ * Null gets the {@link #NULL_ORD} ord. + *

+ */ + protected boolean seenNull = false; + AbstractCategorizeBlockHash(BlockFactory blockFactory, int channel, boolean outputPartial) { super(blockFactory); this.channel = channel; @@ -58,12 +70,12 @@ public Block[] getKeys() { @Override public IntVector nonEmpty() { - return IntVector.range(0, categorizer.getCategoryCount(), blockFactory); + return IntVector.range(seenNull ? 0 : 1, categorizer.getCategoryCount() + 1, blockFactory); } @Override public BitArray seenGroupIds(BigArrays bigArrays) { - throw new UnsupportedOperationException(); + return new SeenGroupIds.Range(seenNull ? 0 : 1, Math.toIntExact(categorizer.getCategoryCount() + 1)).seenGroupIds(bigArrays); } @Override @@ -76,24 +88,39 @@ public final ReleasableIterator lookup(Page page, ByteSizeValue target */ private Block buildIntermediateBlock() { if (categorizer.getCategoryCount() == 0) { - return blockFactory.newConstantNullBlock(0); + return blockFactory.newConstantNullBlock(seenNull ? 1 : 0); } try (BytesStreamOutput out = new BytesStreamOutput()) { // TODO be more careful here. + out.writeBoolean(seenNull); 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()); + int positionCount = categorizer.getCategoryCount() + (seenNull ? 1 : 0); + return blockFactory.newConstantBytesRefBlockWith(out.bytes().toBytesRef(), positionCount); } catch (IOException e) { throw new RuntimeException(e); } } private Block buildFinalBlock() { + BytesRefBuilder scratch = new BytesRefBuilder(); + + if (seenNull) { + try (BytesRefBlock.Builder result = blockFactory.newBytesRefBlockBuilder(categorizer.getCategoryCount())) { + result.appendNull(); + for (SerializableTokenListCategory category : categorizer.toCategoriesById()) { + scratch.copyChars(category.getRegex()); + result.appendBytesRef(scratch.get()); + scratch.clear(); + } + return result.build(); + } + } + 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()); 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 index bf633e0454384..0d0a2fef2f82b 100644 --- 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 @@ -64,7 +64,7 @@ public void close() { /** * Similar implementation to an Evaluator. */ - public static final class CategorizeEvaluator implements Releasable { + public final class CategorizeEvaluator implements Releasable { private final CategorizationAnalyzer analyzer; private final TokenListCategorizer.CloseableTokenListCategorizer categorizer; @@ -95,7 +95,8 @@ public IntBlock eval(int positionCount, BytesRefBlock vBlock) { BytesRef vScratch = new BytesRef(); for (int p = 0; p < positionCount; p++) { if (vBlock.isNull(p)) { - result.appendNull(); + seenNull = true; + result.appendInt(NULL_ORD); continue; } int first = vBlock.getFirstValueIndex(p); @@ -126,7 +127,12 @@ public IntVector eval(int positionCount, BytesRefVector vVector) { } private int process(BytesRef v) { - return categorizer.computeCategory(v.utf8ToString(), analyzer).getId(); + var category = categorizer.computeCategory(v.utf8ToString(), analyzer); + if (category == null) { + seenNull = true; + return NULL_ORD; + } + return category.getId() + 1; } @Override 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 index 1bca34a70e5fa..c774d3b26049d 100644 --- 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 @@ -40,9 +40,19 @@ public void add(Page page, GroupingAggregatorFunction.AddInput addInput) { return; } BytesRefBlock categorizerState = page.getBlock(channel()); + if (categorizerState.areAllValuesNull()) { + seenNull = true; + try (var newIds = blockFactory.newConstantIntVector(NULL_ORD, 1)) { + addInput.add(0, newIds); + } + return; + } + Map idMap = readIntermediate(categorizerState.getBytesRef(0, new BytesRef())); try (IntBlock.Builder newIdsBuilder = blockFactory.newIntBlockBuilder(idMap.size())) { - for (int i = 0; i < idMap.size(); i++) { + int fromId = idMap.containsKey(0) ? 0 : 1; + int toId = fromId + idMap.size(); + for (int i = fromId; i < toId; i++) { newIdsBuilder.appendInt(idMap.get(i)); } try (IntBlock newIds = newIdsBuilder.build()) { @@ -59,10 +69,15 @@ public void add(Page page, GroupingAggregatorFunction.AddInput addInput) { private Map readIntermediate(BytesRef bytes) { Map idMap = new HashMap<>(); try (StreamInput in = new BytesArray(bytes).streamInput()) { + if (in.readBoolean()) { + seenNull = true; + idMap.put(NULL_ORD, NULL_ORD); + } int count = in.readVInt(); for (int oldCategoryId = 0; oldCategoryId < count; oldCategoryId++) { int newCategoryId = categorizer.mergeWireCategory(new SerializableTokenListCategory(in)).getId(); - idMap.put(oldCategoryId, newCategoryId); + // +1 because the 0 ordinal is reserved for null + idMap.put(oldCategoryId + 1, newCategoryId + 1); } return idMap; } catch (IOException e) { 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 index de8a2a44266fe..dd7a87dc4a574 100644 --- 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 @@ -52,7 +52,8 @@ public class CategorizeBlockHashTests extends BlockHashTestCase { public void testCategorizeRaw() { final Page page; - final int positions = 7; + boolean withNull = randomBoolean(); + final int positions = 7 + (withNull ? 1 : 0); try (BytesRefBlock.Builder builder = blockFactory.newBytesRefBlockBuilder(positions)) { builder.appendBytesRef(new BytesRef("Connected to 10.1.0.1")); builder.appendBytesRef(new BytesRef("Connection error")); @@ -61,6 +62,13 @@ public void testCategorizeRaw() { 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")); + if (withNull) { + if (randomBoolean()) { + builder.appendNull(); + } else { + builder.appendBytesRef(new BytesRef("")); + } + } page = new Page(builder.build()); } @@ -70,13 +78,16 @@ public void testCategorizeRaw() { 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)); + assertEquals(1, groupIds.getInt(0)); + assertEquals(2, groupIds.getInt(1)); + assertEquals(2, groupIds.getInt(2)); + assertEquals(2, groupIds.getInt(3)); + assertEquals(3, groupIds.getInt(4)); + assertEquals(1, groupIds.getInt(5)); + assertEquals(1, groupIds.getInt(6)); + if (withNull) { + assertEquals(0, groupIds.getInt(7)); + } } @Override @@ -100,7 +111,8 @@ public void close() { public void testCategorizeIntermediate() { Page page1; - int positions1 = 7; + boolean withNull = randomBoolean(); + int positions1 = 7 + (withNull ? 1 : 0); try (BytesRefBlock.Builder builder = blockFactory.newBytesRefBlockBuilder(positions1)) { builder.appendBytesRef(new BytesRef("Connected to 10.1.0.1")); builder.appendBytesRef(new BytesRef("Connection error")); @@ -109,6 +121,13 @@ public void testCategorizeIntermediate() { 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")); + if (withNull) { + if (randomBoolean()) { + builder.appendNull(); + } else { + builder.appendBytesRef(new BytesRef("")); + } + } page1 = new Page(builder.build()); } Page page2; @@ -133,13 +152,16 @@ public void testCategorizeIntermediate() { @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)); + assertEquals(1, groupIds.getInt(0)); + assertEquals(2, groupIds.getInt(1)); + assertEquals(2, groupIds.getInt(2)); + assertEquals(1, groupIds.getInt(3)); + assertEquals(2, groupIds.getInt(4)); + assertEquals(1, groupIds.getInt(5)); + assertEquals(1, groupIds.getInt(6)); + if (withNull) { + assertEquals(0, groupIds.getInt(7)); + } } @Override @@ -158,11 +180,11 @@ public void close() { @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)); + assertEquals(1, groupIds.getInt(0)); + assertEquals(2, groupIds.getInt(1)); + assertEquals(1, groupIds.getInt(2)); + assertEquals(2, groupIds.getInt(3)); + assertEquals(3, groupIds.getInt(4)); } @Override @@ -189,7 +211,11 @@ public void add(int positionOffset, IntBlock groupIds) { .map(groupIds::getInt) .boxed() .collect(Collectors.toSet()); - assertEquals(values, Set.of(0, 1)); + if (withNull) { + assertEquals(Set.of(0, 1, 2), values); + } else { + assertEquals(Set.of(1, 2), values); + } } @Override @@ -212,7 +238,7 @@ public void add(int positionOffset, IntBlock groupIds) { .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)); + assertEquals(Set.of(1, 3, 4), values); } @Override 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 89d9026423204..547c430ed7518 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,5 +1,5 @@ standard aggs -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS count=COUNT(), @@ -17,7 +17,7 @@ count:long | sum:long | avg:double | count_distinct:long | category:keyw ; values aggs -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS values=MV_SORT(VALUES(message)), @@ -33,7 +33,7 @@ values:keyword | top ; mv -required_capability: categorize_v2 +required_capability: categorize_v3 FROM mv_sample_data | STATS COUNT(), SUM(event_duration) BY category=CATEGORIZE(message) @@ -48,7 +48,7 @@ COUNT():long | SUM(event_duration):long | category:keyword ; row mv -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = ["connected to a", "connected to b", "disconnected"], str = ["a", "b", "c"] | STATS COUNT(), VALUES(str) BY category=CATEGORIZE(message) @@ -61,7 +61,7 @@ COUNT():long | VALUES(str):keyword | category:keyword ; with multiple indices -required_capability: categorize_v2 +required_capability: categorize_v3 required_capability: union_types FROM sample_data* @@ -76,7 +76,7 @@ COUNT():long | category:keyword ; mv with many values -required_capability: categorize_v2 +required_capability: categorize_v3 FROM employees | STATS COUNT() BY category=CATEGORIZE(job_positions) @@ -92,24 +92,37 @@ COUNT():long | category:keyword 10 | .*?Head.+?Human.+?Resources.*? ; -# Throws when calling AbstractCategorizeBlockHash.seenGroupIds() - Requires nulls support? -mv with many values-Ignore -required_capability: categorize_v2 +mv with many values and SUM +required_capability: categorize_v3 FROM employees | STATS SUM(languages) BY category=CATEGORIZE(job_positions) - | SORT category DESC + | SORT category | LIMIT 3 ; -SUM(languages):integer | category:keyword - 43 | .*?Accountant.*? - 46 | .*?Architect.*? - 35 | .*?Business.+?Analyst.*? +SUM(languages):long | category:keyword + 43 | .*?Accountant.*? + 46 | .*?Architect.*? + 35 | .*?Business.+?Analyst.*? +; + +mv with many values and nulls and SUM +required_capability: categorize_v3 + +FROM employees + | STATS SUM(languages) BY category=CATEGORIZE(job_positions) + | SORT category DESC + | LIMIT 2 +; + +SUM(languages):long | category:keyword + 27 | null + 46 | .*?Tech.+?Lead.*? ; mv via eval -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | EVAL message = MV_APPEND(message, "Banana") @@ -125,7 +138,7 @@ COUNT():long | category:keyword ; mv via eval const -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | EVAL message = ["Banana", "Bread"] @@ -139,7 +152,7 @@ COUNT():long | category:keyword ; mv via eval const without aliases -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | EVAL message = ["Banana", "Bread"] @@ -153,7 +166,7 @@ COUNT():long | CATEGORIZE(message):keyword ; mv const in parameter -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY c = CATEGORIZE(["Banana", "Bread"]) @@ -166,7 +179,7 @@ COUNT():long | c:keyword ; agg alias shadowing -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS c = COUNT() BY c = CATEGORIZE(["Banana", "Bread"]) @@ -181,7 +194,7 @@ c:keyword ; chained aggregations using categorize -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -196,7 +209,7 @@ COUNT():long | category:keyword ; stats without aggs -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS BY category=CATEGORIZE(message) @@ -210,7 +223,7 @@ category:keyword ; text field -required_capability: categorize_v2 +required_capability: categorize_v3 FROM hosts | STATS COUNT() BY category=CATEGORIZE(host_group) @@ -221,10 +234,11 @@ COUNT():long | category:keyword 2 | .*?DB.+?servers.*? 2 | .*?Gateway.+?instances.*? 5 | .*?Kubernetes.+?cluster.*? + 1 | null ; on TO_UPPER -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(TO_UPPER(message)) @@ -238,7 +252,7 @@ COUNT():long | category:keyword ; on CONCAT -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " banana")) @@ -252,7 +266,7 @@ COUNT():long | category:keyword ; on CONCAT with unicode -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " 👍🏽😊")) @@ -266,7 +280,7 @@ COUNT():long | category:keyword ; on REVERSE(CONCAT()) -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(REVERSE(CONCAT(message, " 👍🏽😊"))) @@ -280,7 +294,7 @@ COUNT():long | category:keyword ; and then TO_LOWER -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -294,9 +308,8 @@ COUNT():long | category:keyword 1 | .*?disconnected.*? ; -# Throws NPE - Requires nulls support -on const empty string-Ignore -required_capability: categorize_v2 +on const empty string +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE("") @@ -304,12 +317,11 @@ FROM sample_data ; COUNT():long | category:keyword - 7 | .*?.*? + 7 | null ; -# Throws NPE - Requires nulls support -on const empty string from eval-Ignore -required_capability: categorize_v2 +on const empty string from eval +required_capability: categorize_v3 FROM sample_data | EVAL x = "" @@ -318,26 +330,24 @@ FROM sample_data ; COUNT():long | category:keyword - 7 | .*?.*? + 7 | null ; -# Doesn't give the correct results - Requires nulls support -on null-Ignore -required_capability: categorize_v2 +on null +required_capability: categorize_v3 FROM sample_data | EVAL x = null - | STATS COUNT() BY category=CATEGORIZE(x) + | STATS COUNT(), SUM(event_duration) BY category=CATEGORIZE(x) | SORT category ; -COUNT():long | category:keyword - 7 | null +COUNT():long | SUM(event_duration):long | category:keyword + 7 | 23231327 | null ; -# Doesn't give the correct results - Requires nulls support -on null string-Ignore -required_capability: categorize_v2 +on null string +required_capability: categorize_v3 FROM sample_data | EVAL x = null::string @@ -350,7 +360,7 @@ COUNT():long | category:keyword ; filtering out all data -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | WHERE @timestamp < "2023-10-23T00:00:00Z" @@ -362,7 +372,7 @@ COUNT():long | category:keyword ; filtering out all data with constant -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -373,7 +383,7 @@ COUNT():long | category:keyword ; drop output columns -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS count=COUNT() BY category=CATEGORIZE(message) @@ -388,7 +398,7 @@ x:integer ; category value processing -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = ["connected to a", "connected to b", "disconnected"] | STATS COUNT() BY category=CATEGORIZE(message) @@ -402,7 +412,7 @@ COUNT():long | category:keyword ; row aliases -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = "connected to a" | EVAL x = message @@ -416,7 +426,7 @@ COUNT():long | category:keyword | y:keyword ; from aliases -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | EVAL x = message @@ -432,7 +442,7 @@ COUNT():long | category:keyword | y:keyword ; row aliases with keep -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = "connected to a" | EVAL x = message @@ -448,7 +458,7 @@ COUNT():long | y:keyword ; from aliases with keep -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | EVAL x = message @@ -466,7 +476,7 @@ COUNT():long | y:keyword ; row rename -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = "connected to a" | RENAME message as x @@ -480,7 +490,7 @@ COUNT():long | y:keyword ; from rename -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | RENAME message as x @@ -496,7 +506,7 @@ COUNT():long | y:keyword ; row drop -required_capability: categorize_v2 +required_capability: categorize_v3 ROW message = "connected to a" | STATS c = COUNT() BY category=CATEGORIZE(message) @@ -509,7 +519,7 @@ c:long ; from drop -required_capability: categorize_v2 +required_capability: categorize_v3 FROM sample_data | STATS c = COUNT() BY category=CATEGORIZE(message) 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 9bd4211855699..77a3e2840977f 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,11 +402,8 @@ 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_V2(Build.current().isSnapshot()), + CATEGORIZE_V3(Build.current().isSnapshot()), /** * QSTR function 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 6074601535477..dd14e8dd82123 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 @@ -1846,7 +1846,7 @@ public void testIntervalAsString() { } public void testCategorizeSingleGrouping() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); query("from test | STATS COUNT(*) BY CATEGORIZE(first_name)"); query("from test | STATS COUNT(*) BY cat = CATEGORIZE(first_name)"); @@ -1875,7 +1875,7 @@ public void testCategorizeSingleGrouping() { } public void testCategorizeNestedGrouping() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); query("from test | STATS COUNT(*) BY CATEGORIZE(LENGTH(first_name)::string)"); @@ -1890,7 +1890,7 @@ public void testCategorizeNestedGrouping() { } public void testCategorizeWithinAggregations() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V2.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.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/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java index 8373528531902..e98f2b88b33c9 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 @@ -1212,7 +1212,7 @@ 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()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); var plan = plan(""" from test @@ -3949,7 +3949,7 @@ 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()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); var plan = optimizedPlan(""" from test 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 e4257270ce641..7fef6cdafa372 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 @@ -115,6 +115,7 @@ public TokenListCategorizer( cacheRamUsage(0); } + @Nullable public TokenListCategory computeCategory(String s, CategorizationAnalyzer analyzer) { try (TokenStream ts = analyzer.tokenStream("text", s)) { return computeCategory(ts, s.length(), 1); @@ -123,6 +124,7 @@ public TokenListCategory computeCategory(String s, CategorizationAnalyzer analyz } } + @Nullable 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"; From 3c70cd081d40c36a5ac375b009932a0ce5eff1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20J=C3=B3zala?= <377355+jozala@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:20:05 +0100 Subject: [PATCH 098/129] Revert "[CI] Ignore error about missing UBI artifact (#117506)" (#117704) This reverts commit 219372efaaf46a3b496df2142d3091d3434e67ec. This ignore is no longer necessary since the change to release-manager has been applied. --- .buildkite/scripts/dra-workflow.sh | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.buildkite/scripts/dra-workflow.sh b/.buildkite/scripts/dra-workflow.sh index bbfa81f51b286..f2dc40ca1927f 100755 --- a/.buildkite/scripts/dra-workflow.sh +++ b/.buildkite/scripts/dra-workflow.sh @@ -75,7 +75,6 @@ find "$WORKSPACE" -type d -path "*/build/distributions" -exec chmod a+w {} \; echo --- Running release-manager -set +e # Artifacts should be generated docker run --rm \ --name release-manager \ @@ -92,16 +91,4 @@ docker run --rm \ --version "$ES_VERSION" \ --artifact-set main \ --dependency "beats:https://artifacts-${WORKFLOW}.elastic.co/beats/${BEATS_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" \ - --dependency "ml-cpp:https://artifacts-${WORKFLOW}.elastic.co/ml-cpp/${ML_CPP_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" \ -2>&1 | tee release-manager.log -EXIT_CODE=$? -set -e - -# This failure is just generating a ton of noise right now, so let's just ignore it -# This should be removed once this issue has been fixed -if grep "elasticsearch-ubi-9.0.0-SNAPSHOT-docker-image.tar.gz" release-manager.log; then - echo "Ignoring error about missing ubi artifact" - exit 0 -fi - -exit "$EXIT_CODE" + --dependency "ml-cpp:https://artifacts-${WORKFLOW}.elastic.co/ml-cpp/${ML_CPP_BUILD_ID}/manifest-${ES_VERSION}${VERSION_SUFFIX}.json" From 54db9470207df11f07475a6e8d4837b29515a4d7 Mon Sep 17 00:00:00 2001 From: Oleksandr Kolomiiets Date: Thu, 28 Nov 2024 07:33:35 -0800 Subject: [PATCH 099/129] Fix scaled_float test (#117662) --- .../index/mapper/extras/ScaledFloatFieldMapperTests.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/mapper-extras/src/test/java/org/elasticsearch/index/mapper/extras/ScaledFloatFieldMapperTests.java b/modules/mapper-extras/src/test/java/org/elasticsearch/index/mapper/extras/ScaledFloatFieldMapperTests.java index dc9bc96f107a0..83fe07170d6e7 100644 --- a/modules/mapper-extras/src/test/java/org/elasticsearch/index/mapper/extras/ScaledFloatFieldMapperTests.java +++ b/modules/mapper-extras/src/test/java/org/elasticsearch/index/mapper/extras/ScaledFloatFieldMapperTests.java @@ -527,7 +527,13 @@ protected Number randomNumber() { public void testEncodeDecodeExactScalingFactor() { double v = randomValue(); - assertThat(encodeDecode(1 / v, v), equalTo(1 / v)); + double expected = 1 / v; + // We don't produce infinities while decoding. See #testDecodeHandlingInfinity(). + if (Double.isInfinite(expected)) { + var sign = expected == Double.POSITIVE_INFINITY ? 1 : -1; + expected = sign * Double.MAX_VALUE; + } + assertThat(encodeDecode(1 / v, v), equalTo(expected)); } /** From ab604ada78d779a18b82465d51829006540ce546 Mon Sep 17 00:00:00 2001 From: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:34:57 +0100 Subject: [PATCH 100/129] [DOCS] Update tutorial example (#117538) --- .../full-text-filtering-tutorial.asciidoc | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/reference/quickstart/full-text-filtering-tutorial.asciidoc b/docs/reference/quickstart/full-text-filtering-tutorial.asciidoc index fee4b797da724..a024305588cae 100644 --- a/docs/reference/quickstart/full-text-filtering-tutorial.asciidoc +++ b/docs/reference/quickstart/full-text-filtering-tutorial.asciidoc @@ -511,8 +511,9 @@ In this tutorial scenario it's useful for when users have complex requirements f Let's create a query that addresses the following user needs: -* Must be a vegetarian main course +* Must be a vegetarian recipe * Should contain "curry" or "spicy" in the title or description +* Should be a main course * Must not be a dessert * Must have a rating of at least 4.5 * Should prefer recipes published in the last month @@ -524,16 +525,7 @@ GET /cooking_blog/_search "query": { "bool": { "must": [ - { - "term": { - "category.keyword": "Main Course" - } - }, - { - "term": { - "tags": "vegetarian" - } - }, + { "term": { "tags": "vegetarian" } }, { "range": { "rating": { @@ -543,10 +535,18 @@ GET /cooking_blog/_search } ], "should": [ + { + "term": { + "category": "Main Course" + } + }, { "multi_match": { "query": "curry spicy", - "fields": ["title^2", "description"] + "fields": [ + "title^2", + "description" + ] } }, { @@ -590,12 +590,12 @@ GET /cooking_blog/_search "value": 1, "relation": "eq" }, - "max_score": 7.9835095, + "max_score": 7.444513, "hits": [ { "_index": "cooking_blog", "_id": "2", - "_score": 7.9835095, + "_score": 7.444513, "_source": { "title": "Spicy Thai Green Curry: A Vegetarian Adventure", <1> "description": "Dive into the flavors of Thailand with this vibrant green curry. Packed with vegetables and aromatic herbs, this dish is both healthy and satisfying. Don't worry about the heat - you can easily adjust the spice level to your liking.", <2> @@ -619,8 +619,8 @@ GET /cooking_blog/_search <1> The title contains "Spicy" and "Curry", matching our should condition. With the default <> behavior, this field contributes most to the relevance score. <2> While the description also contains matching terms, only the best matching field's score is used by default. <3> The recipe was published within the last month, satisfying our recency preference. -<4> The "Main Course" category matches our `must` condition. -<5> The "vegetarian" tag satisfies another `must` condition, while "curry" and "spicy" tags align with our `should` preferences. +<4> The "Main Course" category satisfies another `should` condition. +<5> The "vegetarian" tag satisfies a `must` condition, while "curry" and "spicy" tags align with our `should` preferences. <6> The rating of 4.6 meets our minimum rating requirement of 4.5. ============== From f096c317c06052dc26c00b72448eda4743ab5965 Mon Sep 17 00:00:00 2001 From: Dimitris Rempapis Date: Thu, 28 Nov 2024 19:38:37 +0200 Subject: [PATCH 101/129] fix/SearchStatesIt_failures (#117618) Investigate and unmute automatically muted tests --- docs/changelog/117618.yaml | 5 +++++ muted-tests.yml | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 docs/changelog/117618.yaml diff --git a/docs/changelog/117618.yaml b/docs/changelog/117618.yaml new file mode 100644 index 0000000000000..5de29e2fe768c --- /dev/null +++ b/docs/changelog/117618.yaml @@ -0,0 +1,5 @@ +pr: 117618 +summary: SearchStatesIt failures reported by CI +area: Search +type: bug +issues: [116617, 116618] diff --git a/muted-tests.yml b/muted-tests.yml index fdadc747289bb..d703cfaa1b9aa 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -156,12 +156,6 @@ tests: - class: org.elasticsearch.xpack.searchablesnapshots.SearchableSnapshotsCanMatchOnCoordinatorIntegTests method: testSearchableSnapshotShardsAreSkippedBySearchRequestWithoutQueryingAnyNodeWhenTheyAreOutsideOfTheQueryRange issue: https://github.com/elastic/elasticsearch/issues/116523 -- class: org.elasticsearch.upgrades.SearchStatesIT - method: testBWCSearchStates - issue: https://github.com/elastic/elasticsearch/issues/116617 -- class: org.elasticsearch.upgrades.SearchStatesIT - method: testCanMatch - issue: https://github.com/elastic/elasticsearch/issues/116618 - class: org.elasticsearch.reservedstate.service.RepositoriesFileSettingsIT method: testSettingsApplied issue: https://github.com/elastic/elasticsearch/issues/116694 From 8350ff29ba18c7d03d652b107532415705426da9 Mon Sep 17 00:00:00 2001 From: John Verwolf Date: Thu, 28 Nov 2024 13:25:02 -0800 Subject: [PATCH 102/129] Extensible Completion Postings Formats (#111494) Allows the Completion Postings Format to be extensible by providing an implementation of the CompletionsPostingsFormatExtension SPIs. --- docs/changelog/111494.yaml | 5 ++++ server/src/main/java/module-info.java | 6 +++- .../index/codec/PerFieldFormatSupplier.java | 24 ++++++++++++++-- .../index/mapper/CompletionFieldMapper.java | 5 ---- .../index/mapper/MappingLookup.java | 17 ----------- .../CompletionsPostingsFormatExtension.java | 28 +++++++++++++++++++ 6 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 docs/changelog/111494.yaml create mode 100644 server/src/main/java/org/elasticsearch/internal/CompletionsPostingsFormatExtension.java diff --git a/docs/changelog/111494.yaml b/docs/changelog/111494.yaml new file mode 100644 index 0000000000000..6c7b84bb04798 --- /dev/null +++ b/docs/changelog/111494.yaml @@ -0,0 +1,5 @@ +pr: 111494 +summary: Extensible Completion Postings Formats +area: "Suggesters" +type: enhancement +issues: [] diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 63dbac3a72487..d572d3b90fec8 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import org.elasticsearch.internal.CompletionsPostingsFormatExtension; import org.elasticsearch.plugins.internal.RestExtension; /** The Elasticsearch Server Module. */ @@ -288,7 +289,8 @@ to org.elasticsearch.serverless.version, org.elasticsearch.serverless.buildinfo, - org.elasticsearch.serverless.constants; + org.elasticsearch.serverless.constants, + org.elasticsearch.serverless.codec; exports org.elasticsearch.lucene.analysis.miscellaneous; exports org.elasticsearch.lucene.grouping; exports org.elasticsearch.lucene.queries; @@ -395,6 +397,7 @@ org.elasticsearch.stateless, org.elasticsearch.settings.secure, org.elasticsearch.serverless.constants, + org.elasticsearch.serverless.codec, org.elasticsearch.serverless.apifiltering, org.elasticsearch.internal.security; @@ -414,6 +417,7 @@ uses org.elasticsearch.node.internal.TerminationHandlerProvider; uses org.elasticsearch.internal.VersionExtension; uses org.elasticsearch.internal.BuildExtension; + uses CompletionsPostingsFormatExtension; uses org.elasticsearch.features.FeatureSpecification; uses org.elasticsearch.plugins.internal.LoggingDataProvider; diff --git a/server/src/main/java/org/elasticsearch/index/codec/PerFieldFormatSupplier.java b/server/src/main/java/org/elasticsearch/index/codec/PerFieldFormatSupplier.java index 9c2a08a69002c..4d3d37ab4f3af 100644 --- a/server/src/main/java/org/elasticsearch/index/codec/PerFieldFormatSupplier.java +++ b/server/src/main/java/org/elasticsearch/index/codec/PerFieldFormatSupplier.java @@ -20,10 +20,15 @@ import org.elasticsearch.index.codec.bloomfilter.ES87BloomFilterPostingsFormat; import org.elasticsearch.index.codec.postings.ES812PostingsFormat; import org.elasticsearch.index.codec.tsdb.ES87TSDBDocValuesFormat; +import org.elasticsearch.index.mapper.CompletionFieldMapper; import org.elasticsearch.index.mapper.IdFieldMapper; import org.elasticsearch.index.mapper.Mapper; import org.elasticsearch.index.mapper.MapperService; import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper; +import org.elasticsearch.internal.CompletionsPostingsFormatExtension; +import org.elasticsearch.plugins.ExtensionLoader; + +import java.util.ServiceLoader; /** * Class that encapsulates the logic of figuring out the most appropriate file format for a given field, across postings, doc values and @@ -53,15 +58,28 @@ public PostingsFormat getPostingsFormatForField(String field) { private PostingsFormat internalGetPostingsFormatForField(String field) { if (mapperService != null) { - final PostingsFormat format = mapperService.mappingLookup().getPostingsFormat(field); - if (format != null) { - return format; + Mapper mapper = mapperService.mappingLookup().getMapper(field); + if (mapper instanceof CompletionFieldMapper) { + return PostingsFormatHolder.POSTINGS_FORMAT; } } // return our own posting format using PFOR return es812PostingsFormat; } + private static class PostingsFormatHolder { + private static final PostingsFormat POSTINGS_FORMAT = getPostingsFormat(); + + private static PostingsFormat getPostingsFormat() { + String defaultName = "Completion912"; // Caution: changing this name will result in exceptions if a field is created during a + // rolling upgrade and the new codec (specified by the name) is not available on all nodes in the cluster. + String codecName = ExtensionLoader.loadSingleton(ServiceLoader.load(CompletionsPostingsFormatExtension.class)) + .map(CompletionsPostingsFormatExtension::getFormatName) + .orElse(defaultName); + return PostingsFormat.forName(codecName); + } + } + boolean useBloomFilter(String field) { if (mapperService == null) { return false; diff --git a/server/src/main/java/org/elasticsearch/index/mapper/CompletionFieldMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/CompletionFieldMapper.java index 53ccccdbd4bab..bb229c795a83e 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/CompletionFieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/CompletionFieldMapper.java @@ -8,7 +8,6 @@ */ package org.elasticsearch.index.mapper; -import org.apache.lucene.codecs.PostingsFormat; import org.apache.lucene.document.FieldType; import org.apache.lucene.index.IndexOptions; import org.apache.lucene.index.Term; @@ -344,10 +343,6 @@ public CompletionFieldType fieldType() { return (CompletionFieldType) super.fieldType(); } - static PostingsFormat postingsFormat() { - return PostingsFormat.forName("Completion912"); - } - @Override public boolean parsesArrayValue() { return true; diff --git a/server/src/main/java/org/elasticsearch/index/mapper/MappingLookup.java b/server/src/main/java/org/elasticsearch/index/mapper/MappingLookup.java index 2f78e11761448..ce3f8cfb53184 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/MappingLookup.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/MappingLookup.java @@ -9,7 +9,6 @@ package org.elasticsearch.index.mapper; -import org.apache.lucene.codecs.PostingsFormat; import org.elasticsearch.cluster.metadata.DataStream; import org.elasticsearch.cluster.metadata.InferenceFieldMetadata; import org.elasticsearch.index.IndexSettings; @@ -21,7 +20,6 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -58,7 +56,6 @@ private CacheKey() {} private final Map indexAnalyzersMap; private final List indexTimeScriptMappers; private final Mapping mapping; - private final Set completionFields; private final int totalFieldsCount; /** @@ -161,7 +158,6 @@ private MappingLookup( this.nestedLookup = NestedLookup.build(nestedMappers); final Map indexAnalyzersMap = new HashMap<>(); - final Set completionFields = new HashSet<>(); final List indexTimeScriptMappers = new ArrayList<>(); for (FieldMapper mapper : mappers) { if (objects.containsKey(mapper.fullPath())) { @@ -174,9 +170,6 @@ private MappingLookup( if (mapper.hasScript()) { indexTimeScriptMappers.add(mapper); } - if (mapper instanceof CompletionFieldMapper) { - completionFields.add(mapper.fullPath()); - } } for (FieldAliasMapper aliasMapper : aliasMappers) { @@ -211,7 +204,6 @@ private MappingLookup( this.objectMappers = Map.copyOf(objects); this.runtimeFieldMappersCount = runtimeFields.size(); this.indexAnalyzersMap = Map.copyOf(indexAnalyzersMap); - this.completionFields = Set.copyOf(completionFields); this.indexTimeScriptMappers = List.copyOf(indexTimeScriptMappers); runtimeFields.stream().flatMap(RuntimeField::asMappedFieldTypes).map(MappedFieldType::name).forEach(this::validateDoesNotShadow); @@ -285,15 +277,6 @@ public Iterable fieldMappers() { return fieldMappers.values(); } - /** - * Gets the postings format for a particular field - * @param field the field to retrieve a postings format for - * @return the postings format for the field, or {@code null} if the default format should be used - */ - public PostingsFormat getPostingsFormat(String field) { - return completionFields.contains(field) ? CompletionFieldMapper.postingsFormat() : null; - } - void checkLimits(IndexSettings settings) { checkFieldLimit(settings.getMappingTotalFieldsLimit()); checkObjectDepthLimit(settings.getMappingDepthLimit()); diff --git a/server/src/main/java/org/elasticsearch/internal/CompletionsPostingsFormatExtension.java b/server/src/main/java/org/elasticsearch/internal/CompletionsPostingsFormatExtension.java new file mode 100644 index 0000000000000..bb28d4dd6c901 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/internal/CompletionsPostingsFormatExtension.java @@ -0,0 +1,28 @@ +/* + * 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.internal; + +import org.apache.lucene.search.suggest.document.CompletionPostingsFormat; + +/** + * Allows plugging-in the Completions Postings Format. + */ +public interface CompletionsPostingsFormatExtension { + + /** + * Returns the name of the {@link CompletionPostingsFormat} that Elasticsearch should use. Should return null if the extension + * is not enabled. + *

+ * Note that the name must match a codec that is available on all nodes in the cluster, otherwise IndexCorruptionExceptions will occur. + * A feature can be used to protect against this scenario, or alternatively, the codec code can be rolled out prior to its usage by this + * extension. + */ + String getFormatName(); +} From 2895f1e900b2f41704fd507845102a281cff437e Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 29 Nov 2024 11:37:45 +1300 Subject: [PATCH 103/129] [ML] Remove deprecated sort from reindex operation (#117606) Sort in reindex is deprecated. This PR removes its use from within the reindexing step of dataframe analytics. Testing indicates that having the destination index sorted is a "nice to have" and not necessary for the DFA functionality to succeed. --- docs/changelog/117606.yaml | 5 +++++ .../xpack/ml/dataframe/steps/ReindexingStep.java | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 docs/changelog/117606.yaml diff --git a/docs/changelog/117606.yaml b/docs/changelog/117606.yaml new file mode 100644 index 0000000000000..ea61099a1a6b4 --- /dev/null +++ b/docs/changelog/117606.yaml @@ -0,0 +1,5 @@ +pr: 117606 +summary: Remove deprecated sort from reindex operation within dataframe analytics procedure +area: Machine Learning +type: enhancement +issues: [] diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/dataframe/steps/ReindexingStep.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/dataframe/steps/ReindexingStep.java index 0ccdd1eb64601..2a6d6eb329503 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/dataframe/steps/ReindexingStep.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/dataframe/steps/ReindexingStep.java @@ -27,13 +27,11 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.index.IndexNotFoundException; -import org.elasticsearch.index.mapper.SeqNoFieldMapper; import org.elasticsearch.index.reindex.BulkByScrollResponse; import org.elasticsearch.index.reindex.BulkByScrollTask; import org.elasticsearch.index.reindex.ReindexAction; import org.elasticsearch.index.reindex.ReindexRequest; import org.elasticsearch.script.Script; -import org.elasticsearch.search.sort.SortOrder; import org.elasticsearch.tasks.Task; import org.elasticsearch.tasks.TaskCancelledException; import org.elasticsearch.tasks.TaskId; @@ -147,7 +145,6 @@ protected void doExecute(ActionListener listener) { reindexRequest.setSourceQuery(config.getSource().getParsedQuery()); reindexRequest.getSearchRequest().allowPartialSearchResults(false); reindexRequest.getSearchRequest().source().fetchSource(config.getSource().getSourceFiltering()); - reindexRequest.getSearchRequest().source().sort(SeqNoFieldMapper.NAME, SortOrder.ASC); reindexRequest.setDestIndex(config.getDest().getIndex()); // We explicitly set slices to 1 as we cannot parallelize in order to have the incremental id From c35777a175f10a49ae860d28aa16b40d6f66c49a Mon Sep 17 00:00:00 2001 From: Rene Groeschke Date: Fri, 29 Nov 2024 02:26:34 +0100 Subject: [PATCH 104/129] [Build] Declare mirror for eclipse p2 repository (#117732) The spotlight plugin directly resolves dependencies from p2 which causes `java.io.IOException: Failed to load eclipse jdt formatter` issues if that repo is not accessible. This is a workaround for the eclipse p2 default repository being down resulting in all our ci jobs to fail. The artifacts in question we wanna cache live in `~/.m2/repository` --- .../conventions/precommit/FormattingPrecommitPlugin.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/FormattingPrecommitPlugin.java b/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/FormattingPrecommitPlugin.java index ea9009172c7e2..41c0b4d67e1df 100644 --- a/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/FormattingPrecommitPlugin.java +++ b/build-conventions/src/main/java/org/elasticsearch/gradle/internal/conventions/precommit/FormattingPrecommitPlugin.java @@ -17,6 +17,8 @@ import org.gradle.api.Project; import java.io.File; +import java.util.Arrays; +import java.util.Map; /** * This plugin configures formatting for Java source using Spotless @@ -64,7 +66,8 @@ public void apply(Project project) { java.importOrderFile(new File(elasticsearchWorkspace, importOrderPath)); // Most formatting is done through the Eclipse formatter - java.eclipse().configFile(new File(elasticsearchWorkspace, formatterConfigPath)); + java.eclipse().withP2Mirrors(Map.of("https://download.eclipse.org/", "https://mirror.umd.edu/eclipse/")) + .configFile(new File(elasticsearchWorkspace, formatterConfigPath)); // Ensure blank lines are actually empty. Since formatters are applied in // order, apply this one last, otherwise non-empty blank lines can creep From e54c7cf5edd4ffd24725412015b5d3db1e7ce5a4 Mon Sep 17 00:00:00 2001 From: David Roberts Date: Fri, 29 Nov 2024 02:19:48 +0000 Subject: [PATCH 105/129] [ML] Disable machine learning on macOS x86_64 (#104125) As previously advised in #104087, machine learning functionality will no longer be available on macOS x86_64. Machine learning functionality is still available on macOS by using an arm64 machine (Apple silicon). It is also possible to run Elasticsearch with machine learning functionality within a Docker container on macOS x86_64. This PR should be merged to main after the branch is split for the last minor release scheduled for before December 2024. For example, suppose 8.17.0 is scheduled for release in November 2024 and 8.18.0 is scheduled for release in January 2025. Then this PR should be merged to main after the 8.17 branch is split. One this PR is merged a followup PR should be opened against the ml-cpp repo to remove the build system for darwin-x86_64. It has been confirmed that with this change in place the Elasticsearch build system works with an ml-cpp bundle that does not contain a platform/darwin-x86_64 directory. It still produces an Elasticsearch build that will run providing xpack.ml.enabled is not explicitly set to true. After the build system for darwin-x86_64 has been removed from the ml-cpp repo, we will be able to do another PyTorch upgrade without having to worry about tweaking the build system to work on Intel macOS. --------- Co-authored-by: Ed Savage Co-authored-by: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com> --- docs/changelog/104125.yaml | 18 +++++++++++++++ .../xpack/core/XPackSettings.java | 22 +++++++++++++++++-- .../xpack/ml/MachineLearning.java | 11 ---------- 3 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 docs/changelog/104125.yaml diff --git a/docs/changelog/104125.yaml b/docs/changelog/104125.yaml new file mode 100644 index 0000000000000..e5c5ea6a3f1cd --- /dev/null +++ b/docs/changelog/104125.yaml @@ -0,0 +1,18 @@ +pr: 104125 +summary: Disable machine learning on macOS x86_64 +area: Machine Learning +type: breaking +issues: [] +breaking: + title: Disable machine learning on macOS x86_64 + area: Packaging + details: The machine learning plugin is permanently disabled on macOS x86_64. + For the last three years Apple has been selling hardware based on the arm64 + architecture, and support will increasingly focus on this architecture in + the future. Changes to upstream dependencies of Elastic's machine learning + functionality have made it unviable for Elastic to continue to build machine + learning on macOS x86_64. + impact: To continue to use machine learning functionality on macOS please switch to + an arm64 machine (Apple silicon). Alternatively, it will still be possible to run + Elasticsearch with machine learning enabled in a Docker container on macOS x86_64. + notable: false diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java index 72e8805e96fc4..6aef618288fd2 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java @@ -7,12 +7,16 @@ package org.elasticsearch.xpack.core; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslClientAuthenticationMode; import org.elasticsearch.common.ssl.SslVerificationMode; import org.elasticsearch.core.Strings; +import org.elasticsearch.plugins.Platforms; import org.elasticsearch.transport.RemoteClusterPortSettings; import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.authc.support.Hasher; @@ -26,6 +30,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.function.Function; import javax.crypto.SecretKeyFactory; @@ -40,6 +45,8 @@ */ public class XPackSettings { + private static final Logger logger = LogManager.getLogger(XPackSettings.class); + private XPackSettings() { throw new IllegalStateException("Utility class should not be instantiated"); } @@ -76,10 +83,21 @@ public Iterator> settings() { /** Setting for enabling or disabling graph. Defaults to true. */ public static final Setting GRAPH_ENABLED = Setting.boolSetting("xpack.graph.enabled", true, Setting.Property.NodeScope); - /** Setting for enabling or disabling machine learning. Defaults to true. */ + public static final Set ML_NATIVE_CODE_PLATFORMS = Set.of("darwin-aarch64", "linux-aarch64", "linux-x86_64", "windows-x86_64"); + + /** Setting for enabling or disabling machine learning. Defaults to true on platforms that have the ML native code available. */ public static final Setting MACHINE_LEARNING_ENABLED = Setting.boolSetting( "xpack.ml.enabled", - true, + ML_NATIVE_CODE_PLATFORMS.contains(Platforms.PLATFORM_NAME), + enabled -> { + if (enabled && ML_NATIVE_CODE_PLATFORMS.contains(Platforms.PLATFORM_NAME) == false) { + SettingsException e = new SettingsException("xpack.ml.enabled cannot be set to [true] on [{}]", Platforms.PLATFORM_NAME); + // The exception doesn't get logged nicely on the console because it's thrown during initial plugin loading, + // so log separately here to make absolutely clear what happened + logger.fatal(e.getMessage()); + throw e; + } + }, Setting.Property.NodeScope ); diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java index 1feb95661f33a..8363e0f5c19a1 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java @@ -32,7 +32,6 @@ import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.breaker.CircuitBreaker; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; -import org.elasticsearch.common.logging.DeprecationCategory; import org.elasticsearch.common.logging.DeprecationLogger; import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.IndexScopedSettings; @@ -69,7 +68,6 @@ import org.elasticsearch.plugins.ExtensiblePlugin; import org.elasticsearch.plugins.IngestPlugin; import org.elasticsearch.plugins.PersistentTaskPlugin; -import org.elasticsearch.plugins.Platforms; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.plugins.SearchPlugin; import org.elasticsearch.plugins.ShutdownAwarePlugin; @@ -931,15 +929,6 @@ public Collection createComponents(PluginServices services) { return List.of(new JobManagerHolder(), new MachineLearningExtensionHolder()); } - if ("darwin-x86_64".equals(Platforms.PLATFORM_NAME)) { - String msg = "The machine learning plugin will be permanently disabled on macOS x86_64 in new minor versions released " - + "from December 2024 onwards. To continue to use machine learning functionality on macOS please switch to an arm64 " - + "machine (Apple silicon). Alternatively, it will still be possible to run Elasticsearch with machine learning " - + "enabled in a Docker container on macOS x86_64."; - logger.warn(msg); - deprecationLogger.warn(DeprecationCategory.PLUGINS, "ml-darwin-x86_64", msg); - } - machineLearningExtension.get().configure(environment.settings()); this.mlUpgradeModeActionFilter.set(new MlUpgradeModeActionFilter(clusterService)); From 56637285a8f2bacc88a12c7824b8b88d06752b07 Mon Sep 17 00:00:00 2001 From: Nick Tindall Date: Fri, 29 Nov 2024 13:47:40 +1100 Subject: [PATCH 106/129] Implement CAS support in Azure test fixture (#117104) Closes ES-5680 --- .../azure/AzureBlobStoreRepositoryTests.java | 8 +- .../AzureStorageCleanupThirdPartyTests.java | 4 +- .../azure/AzureBlobContainer.java | 2 +- .../repositories/azure/AzureBlobStore.java | 26 +- .../azure/AzureBlobContainerStatsTests.java | 3 +- .../RepositoryAzureClientYamlTestSuiteIT.java | 4 +- .../test/repository_azure/20_repository.yml | 14 + .../java/fixture/azure/AzureHttpFixture.java | 15 +- .../java/fixture/azure/AzureHttpHandler.java | 333 ++++++++---- .../fixture/azure/MockAzureBlobStore.java | 484 ++++++++++++++++++ .../azure/AzureRepositoriesMeteringIT.java | 4 +- .../AzureSearchableSnapshotsIT.java | 4 +- .../AzureSnapshotBasedRecoveryIT.java | 4 +- .../AzureRepositoryAnalysisRestIT.java | 12 +- 14 files changed, 800 insertions(+), 117 deletions(-) create mode 100644 test/fixtures/azure-fixture/src/main/java/fixture/azure/MockAzureBlobStore.java diff --git a/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureBlobStoreRepositoryTests.java b/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureBlobStoreRepositoryTests.java index bd21f208faac4..3fa4f7de7e717 100644 --- a/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureBlobStoreRepositoryTests.java +++ b/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureBlobStoreRepositoryTests.java @@ -9,6 +9,7 @@ package org.elasticsearch.repositories.azure; import fixture.azure.AzureHttpHandler; +import fixture.azure.MockAzureBlobStore; import com.azure.storage.common.policy.RequestRetryOptions; import com.azure.storage.common.policy.RetryPolicyType; @@ -184,7 +185,12 @@ long getUploadBlockSize() { @SuppressForbidden(reason = "this test uses a HttpHandler to emulate an Azure endpoint") private static class AzureBlobStoreHttpHandler extends AzureHttpHandler implements BlobStoreHttpHandler { AzureBlobStoreHttpHandler(final String account, final String container) { - super(account, container, null /* no auth header validation - sometimes it's omitted in these tests (TODO why?) */); + super( + account, + container, + null /* no auth header validation - sometimes it's omitted in these tests (TODO why?) */, + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE + ); } } diff --git a/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureStorageCleanupThirdPartyTests.java b/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureStorageCleanupThirdPartyTests.java index 6d5c17c392141..40be0f8ca78c4 100644 --- a/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureStorageCleanupThirdPartyTests.java +++ b/modules/repository-azure/src/internalClusterTest/java/org/elasticsearch/repositories/azure/AzureStorageCleanupThirdPartyTests.java @@ -10,6 +10,7 @@ package org.elasticsearch.repositories.azure; import fixture.azure.AzureHttpFixture; +import fixture.azure.MockAzureBlobStore; import com.azure.core.exception.HttpResponseException; import com.azure.storage.blob.BlobContainerClient; @@ -60,7 +61,8 @@ public class AzureStorageCleanupThirdPartyTests extends AbstractThirdPartyReposi System.getProperty("test.azure.container"), System.getProperty("test.azure.tenant_id"), System.getProperty("test.azure.client_id"), - AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_ACCOUNT) + AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_ACCOUNT), + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE ); @Override diff --git a/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobContainer.java b/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobContainer.java index 52bc1ee1399d4..73936d82fc204 100644 --- a/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobContainer.java +++ b/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobContainer.java @@ -180,7 +180,7 @@ protected String buildKey(String blobName) { } private boolean skipRegisterOperation(ActionListener listener) { - return skipCas(listener) || skipIfNotPrimaryOnlyLocationMode(listener); + return skipIfNotPrimaryOnlyLocationMode(listener); } private boolean skipIfNotPrimaryOnlyLocationMode(ActionListener listener) { diff --git a/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobStore.java b/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobStore.java index 3c64bb9f3b830..b4567a92184fc 100644 --- a/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobStore.java +++ b/modules/repository-azure/src/main/java/org/elasticsearch/repositories/azure/AzureBlobStore.java @@ -40,6 +40,7 @@ import com.azure.storage.blob.models.ListBlobsOptions; import com.azure.storage.blob.options.BlobParallelUploadOptions; import com.azure.storage.blob.options.BlockBlobSimpleUploadOptions; +import com.azure.storage.blob.specialized.BlobLeaseClient; import com.azure.storage.blob.specialized.BlobLeaseClientBuilder; import com.azure.storage.blob.specialized.BlockBlobAsyncClient; @@ -1010,7 +1011,7 @@ private static BytesReference innerCompareAndExchangeRegister( } return currentValue; } finally { - leaseClient.releaseLease(); + bestEffortRelease(leaseClient); } } else { if (expected.length() == 0) { @@ -1020,6 +1021,29 @@ private static BytesReference innerCompareAndExchangeRegister( } } + /** + * Release the lease, ignoring conflicts due to expiry + * + * @see Outcomes of lease operations by lease state + * @param leaseClient The client for the lease + */ + private static void bestEffortRelease(BlobLeaseClient leaseClient) { + try { + leaseClient.releaseLease(); + } catch (BlobStorageException blobStorageException) { + if (blobStorageException.getStatusCode() == RestStatus.CONFLICT.getStatus()) { + // This is OK, we tried to release a lease that was expired/re-acquired + logger.debug( + "Ignored conflict on release: errorCode={}, message={}", + blobStorageException.getErrorCode(), + blobStorageException.getMessage() + ); + } else { + throw blobStorageException; + } + } + } + private static BytesReference downloadRegisterBlob( String containerPath, String blobKey, diff --git a/modules/repository-azure/src/test/java/org/elasticsearch/repositories/azure/AzureBlobContainerStatsTests.java b/modules/repository-azure/src/test/java/org/elasticsearch/repositories/azure/AzureBlobContainerStatsTests.java index 6730e5c3c81bd..812d519e60260 100644 --- a/modules/repository-azure/src/test/java/org/elasticsearch/repositories/azure/AzureBlobContainerStatsTests.java +++ b/modules/repository-azure/src/test/java/org/elasticsearch/repositories/azure/AzureBlobContainerStatsTests.java @@ -10,6 +10,7 @@ package org.elasticsearch.repositories.azure; import fixture.azure.AzureHttpHandler; +import fixture.azure.MockAzureBlobStore; import org.elasticsearch.common.blobstore.OperationPurpose; import org.elasticsearch.common.bytes.BytesReference; @@ -26,7 +27,7 @@ public class AzureBlobContainerStatsTests extends AbstractAzureServerTestCase { @SuppressForbidden(reason = "use a http server") @Before public void configureAzureHandler() { - httpServer.createContext("/", new AzureHttpHandler(ACCOUNT, CONTAINER, null)); + httpServer.createContext("/", new AzureHttpHandler(ACCOUNT, CONTAINER, null, MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE)); } public void testOperationPurposeIsReflectedInBlobStoreStats() throws IOException { diff --git a/modules/repository-azure/src/yamlRestTest/java/org/elasticsearch/repositories/azure/RepositoryAzureClientYamlTestSuiteIT.java b/modules/repository-azure/src/yamlRestTest/java/org/elasticsearch/repositories/azure/RepositoryAzureClientYamlTestSuiteIT.java index 64dde0248ad2c..b24574da36825 100644 --- a/modules/repository-azure/src/yamlRestTest/java/org/elasticsearch/repositories/azure/RepositoryAzureClientYamlTestSuiteIT.java +++ b/modules/repository-azure/src/yamlRestTest/java/org/elasticsearch/repositories/azure/RepositoryAzureClientYamlTestSuiteIT.java @@ -10,6 +10,7 @@ package org.elasticsearch.repositories.azure; import fixture.azure.AzureHttpFixture; +import fixture.azure.MockAzureBlobStore; import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; @@ -47,7 +48,8 @@ public class RepositoryAzureClientYamlTestSuiteIT extends ESClientYamlSuiteTestC AZURE_TEST_CONTAINER, AZURE_TEST_TENANT_ID, AZURE_TEST_CLIENT_ID, - decideAuthHeaderPredicate() + decideAuthHeaderPredicate(), + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE ); private static Predicate decideAuthHeaderPredicate() { diff --git a/modules/repository-azure/src/yamlRestTest/resources/rest-api-spec/test/repository_azure/20_repository.yml b/modules/repository-azure/src/yamlRestTest/resources/rest-api-spec/test/repository_azure/20_repository.yml index a4a7d0b22a0ed..968e93cf9fc55 100644 --- a/modules/repository-azure/src/yamlRestTest/resources/rest-api-spec/test/repository_azure/20_repository.yml +++ b/modules/repository-azure/src/yamlRestTest/resources/rest-api-spec/test/repository_azure/20_repository.yml @@ -193,6 +193,20 @@ setup: container: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE client: integration_test +--- +"Register a read-only repository with a non existing container": + + - do: + catch: /repository_verification_exception/ + snapshot.create_repository: + repository: repository + body: + type: azure + settings: + container: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE + client: integration_test + readonly: true + --- "Register a repository with a non existing client": diff --git a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpFixture.java b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpFixture.java index 39105e0a27dc9..ab4d54f4fc451 100644 --- a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpFixture.java +++ b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpFixture.java @@ -45,6 +45,7 @@ public class AzureHttpFixture extends ExternalResource { private final String clientId; private final String tenantId; private final Predicate authHeaderPredicate; + private final MockAzureBlobStore.LeaseExpiryPredicate leaseExpiryPredicate; private HttpServer server; private HttpServer metadataServer; @@ -116,7 +117,8 @@ public AzureHttpFixture( String container, @Nullable String rawTenantId, @Nullable String rawClientId, - Predicate authHeaderPredicate + Predicate authHeaderPredicate, + MockAzureBlobStore.LeaseExpiryPredicate leaseExpiryPredicate ) { final var tenantId = Strings.hasText(rawTenantId) ? rawTenantId : null; final var clientId = Strings.hasText(rawClientId) ? rawClientId : null; @@ -135,6 +137,7 @@ public AzureHttpFixture( this.tenantId = tenantId; this.clientId = clientId; this.authHeaderPredicate = authHeaderPredicate; + this.leaseExpiryPredicate = leaseExpiryPredicate; } private String scheme() { @@ -193,7 +196,10 @@ protected void before() { } case HTTP -> { server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); - server.createContext("/" + account, new AzureHttpHandler(account, container, actualAuthHeaderPredicate)); + server.createContext( + "/" + account, + new AzureHttpHandler(account, container, actualAuthHeaderPredicate, leaseExpiryPredicate) + ); server.start(); oauthTokenServiceServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); @@ -222,7 +228,10 @@ protected void before() { final var httpsServer = HttpsServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); this.server = httpsServer; httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext)); - httpsServer.createContext("/" + account, new AzureHttpHandler(account, container, actualAuthHeaderPredicate)); + httpsServer.createContext( + "/" + account, + new AzureHttpHandler(account, container, actualAuthHeaderPredicate, leaseExpiryPredicate) + ); httpsServer.start(); } { diff --git a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java index bbcfe1f75dc06..904f4581ad2c9 100644 --- a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java +++ b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java @@ -15,7 +15,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.elasticsearch.common.Strings; -import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.io.Streams; import org.elasticsearch.common.regex.Regex; @@ -27,7 +26,6 @@ import org.elasticsearch.xcontent.XContentType; import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; @@ -43,11 +41,11 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static fixture.azure.MockAzureBlobStore.failTestWithAssertionError; import static org.elasticsearch.repositories.azure.AzureFixtureHelper.assertValidBlockId; /** @@ -56,17 +54,29 @@ @SuppressForbidden(reason = "Uses a HttpServer to emulate an Azure endpoint") public class AzureHttpHandler implements HttpHandler { private static final Logger logger = LogManager.getLogger(AzureHttpHandler.class); + private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$"); + static final String X_MS_LEASE_ID = "x-ms-lease-id"; + static final String X_MS_PROPOSED_LEASE_ID = "x-ms-proposed-lease-id"; + static final String X_MS_LEASE_DURATION = "x-ms-lease-duration"; + static final String X_MS_LEASE_BREAK_PERIOD = "x-ms-lease-break-period"; + static final String X_MS_BLOB_TYPE = "x-ms-blob-type"; + static final String X_MS_BLOB_CONTENT_LENGTH = "x-ms-blob-content-length"; - private final Map blobs; private final String account; private final String container; private final Predicate authHeaderPredicate; - - public AzureHttpHandler(final String account, final String container, @Nullable Predicate authHeaderPredicate) { + private final MockAzureBlobStore mockAzureBlobStore; + + public AzureHttpHandler( + final String account, + final String container, + @Nullable Predicate authHeaderPredicate, + MockAzureBlobStore.LeaseExpiryPredicate leaseExpiryPredicate + ) { this.account = Objects.requireNonNull(account); this.container = Objects.requireNonNull(container); this.authHeaderPredicate = authHeaderPredicate; - this.blobs = new ConcurrentHashMap<>(); + this.mockAzureBlobStore = new MockAzureBlobStore(leaseExpiryPredicate); } private static List getAuthHeader(HttpExchange exchange) { @@ -134,7 +144,7 @@ public void handle(final HttpExchange exchange) throws IOException { final String blockId = params.get("blockid"); assert assertValidBlockId(blockId); - blobs.put(blockId, Streams.readFully(exchange.getRequestBody())); + mockAzureBlobStore.putBlock(blobPath(exchange), blockId, Streams.readFully(exchange.getRequestBody()), leaseId(exchange)); exchange.sendResponseHeaders(RestStatus.CREATED.getStatus(), -1); } else if (Regex.simpleMatch("PUT /" + account + "/" + container + "/*comp=blocklist*", request)) { @@ -145,83 +155,124 @@ public void handle(final HttpExchange exchange) throws IOException { .map(line -> line.substring(0, line.indexOf(""))) .toList(); - final ByteArrayOutputStream blob = new ByteArrayOutputStream(); - for (String blockId : blockIds) { - BytesReference block = blobs.remove(blockId); - assert block != null; - block.writeTo(blob); - } - blobs.put(exchange.getRequestURI().getPath(), new BytesArray(blob.toByteArray())); + mockAzureBlobStore.putBlockList(blobPath(exchange), blockIds, leaseId(exchange)); exchange.getResponseHeaders().add("x-ms-request-server-encrypted", "false"); exchange.sendResponseHeaders(RestStatus.CREATED.getStatus(), -1); + } else if (Regex.simpleMatch("PUT /" + account + "/" + container + "*comp=lease*", request)) { + // Lease Blob (https://learn.microsoft.com/en-us/rest/api/storageservices/lease-blob) + final String leaseAction = requireHeader(exchange, "x-ms-lease-action"); + + switch (leaseAction) { + case "acquire" -> { + final int leaseDurationSeconds = requireIntegerHeader(exchange, X_MS_LEASE_DURATION); + final String proposedLeaseId = exchange.getRequestHeaders().getFirst(X_MS_PROPOSED_LEASE_ID); + final String newLeaseId = mockAzureBlobStore.acquireLease( + blobPath(exchange), + leaseDurationSeconds, + proposedLeaseId + ); + exchange.getResponseHeaders().set(X_MS_LEASE_ID, newLeaseId); + exchange.sendResponseHeaders(RestStatus.CREATED.getStatus(), -1); + } + case "release" -> { + final String leaseId = requireHeader(exchange, X_MS_LEASE_ID); + mockAzureBlobStore.releaseLease(blobPath(exchange), leaseId); + exchange.sendResponseHeaders(RestStatus.OK.getStatus(), -1); + } + case "break" -> { + mockAzureBlobStore.breakLease(blobPath(exchange), getOptionalIntegerHeader(exchange, X_MS_LEASE_BREAK_PERIOD)); + exchange.sendResponseHeaders(RestStatus.ACCEPTED.getStatus(), -1); + } + case "renew", "change" -> { + failTestWithAssertionError("Attempt was made to use not-implemented lease action: " + leaseAction); + throw new MockAzureBlobStore.AzureBlobStoreError( + RestStatus.NOT_IMPLEMENTED, + "NotImplemented", + "Attempted to use unsupported lease API: " + leaseAction + ); + } + default -> { + failTestWithAssertionError("Unrecognized lease action: " + leaseAction); + throw new MockAzureBlobStore.BadRequestException( + "InvalidHeaderValue", + "Invalid x-ms-lease-action header: " + leaseAction + ); + } + } } else if (Regex.simpleMatch("PUT /" + account + "/" + container + "/*", request)) { // PUT Blob (see https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob) + final String blobType = requireHeader(exchange, X_MS_BLOB_TYPE); final String ifNoneMatch = exchange.getRequestHeaders().getFirst("If-None-Match"); - if ("*".equals(ifNoneMatch)) { - if (blobs.putIfAbsent(exchange.getRequestURI().getPath(), Streams.readFully(exchange.getRequestBody())) != null) { - sendError(exchange, RestStatus.CONFLICT); - return; - } - } else { - blobs.put(exchange.getRequestURI().getPath(), Streams.readFully(exchange.getRequestBody())); - } + mockAzureBlobStore.putBlob( + blobPath(exchange), + Streams.readFully(exchange.getRequestBody()), + blobType, + ifNoneMatch, + leaseId(exchange) + ); exchange.getResponseHeaders().add("x-ms-request-server-encrypted", "false"); exchange.sendResponseHeaders(RestStatus.CREATED.getStatus(), -1); } else if (Regex.simpleMatch("HEAD /" + account + "/" + container + "/*", request)) { // Get Blob Properties (see https://docs.microsoft.com/en-us/rest/api/storageservices/get-blob-properties) - final BytesReference blob = blobs.get(exchange.getRequestURI().getPath()); - if (blob == null) { - sendError(exchange, RestStatus.NOT_FOUND); - return; - } - exchange.getResponseHeaders().add("x-ms-blob-content-length", String.valueOf(blob.length())); - exchange.getResponseHeaders().add("Content-Length", String.valueOf(blob.length())); - exchange.getResponseHeaders().add("x-ms-blob-type", "BlockBlob"); + final MockAzureBlobStore.AzureBlockBlob blob = mockAzureBlobStore.getBlob(blobPath(exchange), leaseId(exchange)); + + final Headers responseHeaders = exchange.getResponseHeaders(); + final BytesReference blobContents = blob.getContents(); + responseHeaders.add(X_MS_BLOB_CONTENT_LENGTH, String.valueOf(blobContents.length())); + responseHeaders.add("Content-Length", String.valueOf(blobContents.length())); + responseHeaders.add(X_MS_BLOB_TYPE, blob.type()); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), -1); } else if (Regex.simpleMatch("GET /" + account + "/" + container + "/*", request)) { - // GET Object (https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html) - final BytesReference blob = blobs.get(exchange.getRequestURI().getPath()); - if (blob == null) { - sendError(exchange, RestStatus.NOT_FOUND); - return; - } + // Get Blob (https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob) + final MockAzureBlobStore.AzureBlockBlob blob = mockAzureBlobStore.getBlob(blobPath(exchange), leaseId(exchange)); + final BytesReference responseContent; + final RestStatus successStatus; // see Constants.HeaderConstants.STORAGE_RANGE_HEADER final String range = exchange.getRequestHeaders().getFirst("x-ms-range"); - final Matcher matcher = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$").matcher(range); - if (matcher.matches() == false) { - throw new AssertionError("Range header does not match expected format: " + range); - } + if (range != null) { + final Matcher matcher = RANGE_HEADER_PATTERN.matcher(range); + if (matcher.matches() == false) { + throw new MockAzureBlobStore.BadRequestException( + "InvalidHeaderValue", + "Range header does not match expected format: " + range + ); + } - final long start = Long.parseLong(matcher.group(1)); - final long end = Long.parseLong(matcher.group(2)); + final long start = Long.parseLong(matcher.group(1)); + final long end = Long.parseLong(matcher.group(2)); - if (blob.length() <= start) { - exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); - exchange.sendResponseHeaders(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), -1); - return; - } + final BytesReference blobContents = blob.getContents(); + if (blobContents.length() <= start) { + exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); + exchange.sendResponseHeaders(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), -1); + return; + } - var responseBlob = blob.slice(Math.toIntExact(start), Math.toIntExact(Math.min(end - start + 1, blob.length() - start))); + responseContent = blobContents.slice( + Math.toIntExact(start), + Math.toIntExact(Math.min(end - start + 1, blobContents.length() - start)) + ); + successStatus = RestStatus.PARTIAL_CONTENT; + } else { + responseContent = blob.getContents(); + successStatus = RestStatus.OK; + } exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); - exchange.getResponseHeaders().add("x-ms-blob-content-length", String.valueOf(responseBlob.length())); - exchange.getResponseHeaders().add("x-ms-blob-type", "blockblob"); + exchange.getResponseHeaders().add(X_MS_BLOB_CONTENT_LENGTH, String.valueOf(responseContent.length())); + exchange.getResponseHeaders().add(X_MS_BLOB_TYPE, blob.type()); exchange.getResponseHeaders().add("ETag", "\"blockblob\""); - exchange.sendResponseHeaders(RestStatus.OK.getStatus(), responseBlob.length()); - responseBlob.writeTo(exchange.getResponseBody()); + exchange.sendResponseHeaders(successStatus.getStatus(), responseContent.length() == 0 ? -1 : responseContent.length()); + responseContent.writeTo(exchange.getResponseBody()); } else if (Regex.simpleMatch("DELETE /" + account + "/" + container + "/*", request)) { // Delete Blob (https://docs.microsoft.com/en-us/rest/api/storageservices/delete-blob) - final boolean deleted = blobs.entrySet().removeIf(blob -> blob.getKey().startsWith(exchange.getRequestURI().getPath())); - if (deleted) { - exchange.sendResponseHeaders(RestStatus.ACCEPTED.getStatus(), -1); - } else { - exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), -1); - } + mockAzureBlobStore.deleteBlob(blobPath(exchange), leaseId(exchange)); + exchange.sendResponseHeaders(RestStatus.ACCEPTED.getStatus(), -1); } else if (Regex.simpleMatch("GET /" + account + "/" + container + "?*restype=container*comp=list*", request)) { // List Blobs (https://docs.microsoft.com/en-us/rest/api/storageservices/list-blobs) @@ -239,11 +290,12 @@ public void handle(final HttpExchange exchange) throws IOException { list.append("").append(delimiter).append(""); } list.append(""); - for (Map.Entry blob : blobs.entrySet()) { - if (prefix != null && blob.getKey().startsWith("/" + account + "/" + container + "/" + prefix) == false) { - continue; - } - String blobPath = blob.getKey().replace("/" + account + "/" + container + "/", ""); + final Map matchingBlobs = mockAzureBlobStore.listBlobs( + prefix, + leaseId(exchange) + ); + for (Map.Entry blob : matchingBlobs.entrySet()) { + final String blobPath = blob.getKey(); if (delimiter != null) { int fromIndex = (prefix != null ? prefix.length() : 0); int delimiterPosition = blobPath.indexOf(delimiter, fromIndex); @@ -259,7 +311,7 @@ public void handle(final HttpExchange exchange) throws IOException { %s BlockBlob - """, blobPath, blob.getValue().length())); + """, blobPath, blob.getValue().getContents().length())); } if (blobPrefixes.isEmpty() == false) { blobPrefixes.forEach(p -> list.append("").append(p).append("")); @@ -294,7 +346,8 @@ public void handle(final HttpExchange exchange) throws IOException { } // Process the deletion - if (blobs.remove("/" + account + toDelete) != null) { + try { + mockAzureBlobStore.deleteBlob(toDelete, leaseId(exchange)); final String acceptedPart = Strings.format(""" --%s Content-Type: application/http @@ -307,32 +360,43 @@ public void handle(final HttpExchange exchange) throws IOException { """, responseBoundary, contentId, requestId).replaceAll("\n", "\r\n"); response.append(acceptedPart); - } else { - final String notFoundBody = Strings.format( + } catch (MockAzureBlobStore.AzureBlobStoreError e) { + final String errorResponseBody = Strings.format( """ - BlobNotFoundThe specified blob does not exist. + %s%s RequestId:%s Time:%s""", + e.getErrorCode(), + e.getMessage(), requestId, DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("UTC"))) ); - final String notFoundPart = Strings.format(""" - --%s - Content-Type: application/http - Content-ID: %s - - HTTP/1.1 404 The specified blob does not exist. - x-ms-error-code: BlobNotFound - x-ms-request-id: %s - x-ms-version: 2018-11-09 - Content-Length: %d - Content-Type: application/xml - - %s - """, responseBoundary, contentId, requestId, notFoundBody.length(), notFoundBody) - .replaceAll("\n", "\r\n"); - response.append(notFoundPart); + final String errorResponsePart = Strings.format( + """ + --%s + Content-Type: application/http + Content-ID: %s + + HTTP/1.1 %s %s + x-ms-error-code: %s + x-ms-request-id: %s + x-ms-version: 2018-11-09 + Content-Length: %d + Content-Type: application/xml + + %s + """, + responseBoundary, + contentId, + e.getRestStatus().getStatus(), + e.getMessage(), + e.getErrorCode(), + requestId, + errorResponseBody.length(), + errorResponseBody + ).replaceAll("\n", "\r\n"); + response.append(errorResponsePart); } // Clear the state @@ -350,19 +414,18 @@ public void handle(final HttpExchange exchange) throws IOException { } contentId = line.split("\\s")[1]; } else if (Regex.simpleMatch("DELETE /" + container + "/*", line)) { - String blobName = RestUtils.decodeComponent(line.split("(\\s|\\?)")[1]); + final String path = RestUtils.decodeComponent(line.split("(\\s|\\?)")[1]); if (toDelete != null) { throw new IllegalStateException("Got multiple deletes in a single request?"); } - toDelete = blobName; + toDelete = stripPrefix("/" + container + "/", path); } else if (Regex.simpleMatch("DELETE /" + account + "/" + container + "/*", line)) { // possible alternative DELETE url, depending on which method is used in the batch client String path = RestUtils.decodeComponent(line.split("(\\s|\\?)")[1]); - String blobName = path.split(account)[1]; if (toDelete != null) { throw new IllegalStateException("Got multiple deletes in a single request?"); } - toDelete = blobName; + toDelete = stripPrefix("/" + account + "/" + container + "/", path); } } response.append("--").append(responseBoundary).append("--\r\n0\r\n"); @@ -372,20 +435,90 @@ public void handle(final HttpExchange exchange) throws IOException { logger.debug("--> Sending response:\n{}", response); exchange.getResponseBody().write(response.toString().getBytes(StandardCharsets.UTF_8)); } - } else { - logger.warn("--> Unrecognised request received: {}", request); - sendError(exchange, RestStatus.BAD_REQUEST); - } + } else if (Regex.simpleMatch("PUT /*/*/*master.dat", request) + && Regex.simpleMatch("PUT /" + account + "/" + container + "*", request) == false) { + // An attempt to put master.dat to a different container. This is probably + // org.elasticsearch.repositories.blobstore.BlobStoreRepository#startVerification + throw new MockAzureBlobStore.AzureBlobStoreError( + RestStatus.NOT_FOUND, + "ContainerNotFound", + "The specified container does not exist." + ); + } else if (Regex.simpleMatch("GET /*/*restype=container*comp=list*", request) + && Regex.simpleMatch("GET /" + account + "/" + container + "*", request) == false) { + // An attempt to list the contents of a different container. This is probably + // org.elasticsearch.repositories.blobstore.BlobStoreRepository#startVerification for a read-only + // repository + throw new MockAzureBlobStore.AzureBlobStoreError( + RestStatus.NOT_FOUND, + "ContainerNotFound", + "The specified container does not exist." + ); + } else { + final String message = "You sent a request that is not supported by AzureHttpHandler: " + request; + failTestWithAssertionError(message); + throw new MockAzureBlobStore.BadRequestException("UnrecognisedRequest", message); + } + } catch (MockAzureBlobStore.AzureBlobStoreError e) { + sendError(exchange, e); + } catch (Exception e) { + failTestWithAssertionError("Uncaught exception", e); + sendError(exchange, RestStatus.INTERNAL_SERVER_ERROR, "InternalError", e.getMessage()); } finally { exchange.close(); } } + private String requireHeader(HttpExchange exchange, String headerName) { + final String headerValue = exchange.getRequestHeaders().getFirst(headerName); + if (headerValue == null) { + throw new MockAzureBlobStore.BadRequestException("MissingRequiredHeader", "Missing " + headerName + " header"); + } + return headerValue; + } + + private int requireIntegerHeader(HttpExchange exchange, String headerName) { + final String headerValue = requireHeader(exchange, headerName); + try { + return Integer.parseInt(headerValue); + } catch (NumberFormatException e) { + throw new MockAzureBlobStore.BadRequestException("InvalidHeaderValue", "Invalid " + headerName + " header"); + } + } + + @Nullable + private Integer getOptionalIntegerHeader(HttpExchange exchange, String headerName) { + final String headerValue = exchange.getRequestHeaders().getFirst(headerName); + try { + return headerValue == null ? null : Integer.parseInt(headerValue); + } catch (NumberFormatException e) { + throw new MockAzureBlobStore.BadRequestException("InvalidHeaderValue", "Invalid " + headerName + " header"); + } + } + + @Nullable + private String leaseId(HttpExchange exchange) { + return exchange.getRequestHeaders().getFirst(X_MS_LEASE_ID); + } + + private String blobPath(HttpExchange exchange) { + return stripPrefix("/" + account + "/" + container + "/", exchange.getRequestURI().getPath()); + } + public Map blobs() { - return blobs; + return mockAzureBlobStore.blobs(); + } + + public static void sendError(HttpExchange exchange, MockAzureBlobStore.AzureBlobStoreError error) throws IOException { + sendError(exchange, error.getRestStatus(), error.getErrorCode(), error.getMessage()); } public static void sendError(final HttpExchange exchange, final RestStatus status) throws IOException { + final String errorCode = toAzureErrorCode(status); + sendError(exchange, status, errorCode, status.toString()); + } + + public static void sendError(HttpExchange exchange, RestStatus restStatus, String errorCode, String errorMessage) throws IOException { final Headers headers = exchange.getResponseHeaders(); headers.add("Content-Type", "application/xml"); @@ -396,20 +529,19 @@ public static void sendError(final HttpExchange exchange, final RestStatus statu headers.add("x-ms-request-id", requestId); } - final String errorCode = toAzureErrorCode(status); // see Constants.HeaderConstants.ERROR_CODE headers.add("x-ms-error-code", errorCode); if ("HEAD".equals(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(status.getStatus(), -1L); + exchange.sendResponseHeaders(restStatus.getStatus(), -1L); } else { final byte[] response = (String.format(Locale.ROOT, """ %s %s - """, errorCode, status)).getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(status.getStatus(), response.length); + """, errorCode, errorMessage)).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(restStatus.getStatus(), response.length); exchange.getResponseBody().write(response); } } @@ -428,4 +560,9 @@ private static String toAzureErrorCode(final RestStatus status) { ); }; } + + private String stripPrefix(String prefix, String toStrip) { + assert toStrip.startsWith(prefix); + return toStrip.substring(prefix.length()); + } } diff --git a/test/fixtures/azure-fixture/src/main/java/fixture/azure/MockAzureBlobStore.java b/test/fixtures/azure-fixture/src/main/java/fixture/azure/MockAzureBlobStore.java new file mode 100644 index 0000000000000..c694c27c1293b --- /dev/null +++ b/test/fixtures/azure-fixture/src/main/java/fixture/azure/MockAzureBlobStore.java @@ -0,0 +1,484 @@ +/* + * 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 fixture.azure; + +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.bytes.CompositeBytesReference; +import org.elasticsearch.common.util.Maps; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; +import org.elasticsearch.rest.RestStatus; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class MockAzureBlobStore { + + private static final Logger logger = LogManager.getLogger(MockAzureBlobStore.class); + private static final String BLOCK_BLOB_TYPE = "BlockBlob"; + private static final String PAGE_BLOB_TYPE = "PageBlob"; + private static final String APPEND_BLOB_TYPE = "AppendBlob"; + + private final LeaseExpiryPredicate leaseExpiryPredicate; + private final Map blobs; + + /** + * Provide the means of triggering lease expiration + * + * @param leaseExpiryPredicate A Predicate that takes an active lease ID and returns true when it should be expired, or null to never fail leases + */ + public MockAzureBlobStore(LeaseExpiryPredicate leaseExpiryPredicate) { + this.blobs = new ConcurrentHashMap<>(); + this.leaseExpiryPredicate = Objects.requireNonNull(leaseExpiryPredicate); + } + + public void putBlock(String path, String blockId, BytesReference content, @Nullable String leaseId) { + blobs.compute(path, (p, existing) -> { + if (existing != null) { + existing.putBlock(blockId, content, leaseId); + return existing; + } else { + final AzureBlockBlob azureBlockBlob = new AzureBlockBlob(); + azureBlockBlob.putBlock(blockId, content, leaseId); + return azureBlockBlob; + } + }); + } + + public void putBlockList(String path, List blockIds, @Nullable String leaseId) { + final AzureBlockBlob blob = getExistingBlob(path); + blob.putBlockList(blockIds, leaseId); + } + + public void putBlob(String path, BytesReference contents, String blobType, @Nullable String ifNoneMatch, @Nullable String leaseId) { + blobs.compute(path, (p, existingValue) -> { + if (existingValue != null) { + existingValue.setContents(contents, leaseId, ifNoneMatch); + return existingValue; + } else { + validateBlobType(blobType); + final AzureBlockBlob newBlob = new AzureBlockBlob(); + newBlob.setContents(contents, leaseId); + return newBlob; + } + }); + } + + private void validateBlobType(String blobType) { + if (BLOCK_BLOB_TYPE.equals(blobType)) { + return; + } + final String errorMessage; + if (PAGE_BLOB_TYPE.equals(blobType) || APPEND_BLOB_TYPE.equals(blobType)) { + errorMessage = "Only BlockBlob is supported. This is a limitation of the MockAzureBlobStore"; + } else { + errorMessage = "Invalid blobType: " + blobType; + } + // Fail the test and respond with an error + failTestWithAssertionError(errorMessage); + throw new MockAzureBlobStore.BadRequestException("InvalidHeaderValue", errorMessage); + } + + public AzureBlockBlob getBlob(String path, @Nullable String leaseId) { + final AzureBlockBlob blob = getExistingBlob(path); + // This is the public implementation of "get blob" which will 404 for uncommitted block blobs + if (blob.isCommitted() == false) { + throw new BlobNotFoundException(); + } + blob.checkLeaseForRead(leaseId); + return blob; + } + + public void deleteBlob(String path, @Nullable String leaseId) { + final AzureBlockBlob blob = getExistingBlob(path); + blob.checkLeaseForWrite(leaseId); + blobs.remove(path); + } + + public Map listBlobs(String prefix, @Nullable String leaseId) { + return blobs.entrySet().stream().filter(e -> { + if (prefix == null || e.getKey().startsWith(prefix)) { + return true; + } + return false; + }) + .filter(e -> e.getValue().isCommitted()) + .peek(e -> e.getValue().checkLeaseForRead(leaseId)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + public String acquireLease(String path, int leaseTimeSeconds, @Nullable String proposedLeaseId) { + final AzureBlockBlob blob = getExistingBlob(path); + return blob.acquireLease(proposedLeaseId, leaseTimeSeconds); + } + + public void releaseLease(String path, @Nullable String leaseId) { + final AzureBlockBlob blob = getExistingBlob(path); + blob.releaseLease(leaseId); + } + + public void breakLease(String path, @Nullable Integer leaseBreakPeriod) { + final AzureBlockBlob blob = getExistingBlob(path); + blob.breakLease(leaseBreakPeriod); + } + + public Map blobs() { + return Maps.transformValues(blobs, AzureBlockBlob::getContents); + } + + private AzureBlockBlob getExistingBlob(String path) { + final AzureBlockBlob blob = blobs.get(path); + if (blob == null) { + throw new BlobNotFoundException(); + } + return blob; + } + + static void failTestWithAssertionError(String message) { + ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError(message)); + } + + static void failTestWithAssertionError(String message, Throwable throwable) { + ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError(message, throwable)); + } + + public class AzureBlockBlob { + private final Object writeLock = new Object(); + private final Lease lease = new Lease(); + private final Map blocks; + private volatile BytesReference contents; + + private AzureBlockBlob() { + this.blocks = new ConcurrentHashMap<>(); + } + + public void putBlock(String blockId, BytesReference content, @Nullable String leaseId) { + synchronized (writeLock) { + lease.checkLeaseForWrite(leaseId); + this.blocks.put(blockId, content); + } + } + + public void putBlockList(List blockIds, @Nullable String leaseId) throws BadRequestException { + synchronized (writeLock) { + lease.checkLeaseForWrite(leaseId); + final List unresolvedBlocks = blockIds.stream().filter(bId -> blocks.containsKey(bId) == false).toList(); + if (unresolvedBlocks.isEmpty() == false) { + logger.warn("Block list contained non-existent block IDs: {}", unresolvedBlocks); + throw new BadRequestException("InvalidBlockList", "The specified blocklist is invalid."); + } + final BytesReference[] resolvedContents = blockIds.stream().map(blocks::get).toList().toArray(new BytesReference[0]); + contents = CompositeBytesReference.of(resolvedContents); + } + } + + private boolean matches(String ifNoneMatchHeaderValue) { + if (ifNoneMatchHeaderValue == null) { + return false; + } + // We only support * + if ("*".equals(ifNoneMatchHeaderValue)) { + return true; + } + // Fail the test, trigger an internal server error + failTestWithAssertionError("We've only implemented 'If-None-Match: *' in the MockAzureBlobStore"); + throw new AzureBlobStoreError( + RestStatus.INTERNAL_SERVER_ERROR, + "UnsupportedHeader", + "The test fixture only supports * for If-None-Match" + ); + } + + public synchronized void setContents(BytesReference contents, @Nullable String leaseId) { + synchronized (writeLock) { + lease.checkLeaseForWrite(leaseId); + this.contents = contents; + this.blocks.clear(); + } + } + + public void setContents(BytesReference contents, @Nullable String leaseId, @Nullable String ifNoneMatchHeaderValue) { + synchronized (writeLock) { + if (matches(ifNoneMatchHeaderValue)) { + throw new PreconditionFailedException( + "TargetConditionNotMet", + "The target condition specified using HTTP conditional header(s) is not met." + ); + } + setContents(contents, leaseId); + } + } + + /** + * Get the committed contents of the blob + * + * @return The last committed contents of the blob, or null if the blob is uncommitted + */ + @Nullable + public BytesReference getContents() { + return contents; + } + + public String type() { + return BLOCK_BLOB_TYPE; + } + + public boolean isCommitted() { + return contents != null; + } + + @Override + public String toString() { + return "MockAzureBlockBlob{" + "blocks=" + blocks + ", contents=" + contents + '}'; + } + + public String acquireLease(@Nullable String proposedLeaseId, int leaseTimeSeconds) { + synchronized (writeLock) { + return lease.acquire(proposedLeaseId, leaseTimeSeconds); + } + } + + public void releaseLease(String leaseId) { + synchronized (writeLock) { + lease.release(leaseId); + } + } + + public void breakLease(@Nullable Integer leaseBreakPeriod) { + synchronized (writeLock) { + lease.breakLease(leaseBreakPeriod); + } + } + + public void checkLeaseForRead(@Nullable String leaseId) { + lease.checkLeaseForRead(leaseId); + } + + public void checkLeaseForWrite(@Nullable String leaseId) { + lease.checkLeaseForWrite(leaseId); + } + } + + /** + * @see acquire/release rules + * @see read/write rules + */ + public class Lease { + + /** + * Minimal set of states, we don't support breaking/broken + */ + enum State { + Available, + Leased, + Expired, + Broken + } + + private String leaseId; + private State state = State.Available; + private int leaseDurationSeconds; + + public synchronized String acquire(@Nullable String proposedLeaseId, int leaseDurationSeconds) { + maybeExpire(proposedLeaseId); + switch (state) { + case Available, Expired, Broken -> { + final State prevState = state; + state = State.Leased; + leaseId = proposedLeaseId != null ? proposedLeaseId : UUID.randomUUID().toString(); + validateLeaseDuration(leaseDurationSeconds); + this.leaseDurationSeconds = leaseDurationSeconds; + logger.debug("Granting lease, prior state={}, leaseId={}, expires={}", prevState, leaseId); + } + case Leased -> { + if (leaseId.equals(proposedLeaseId) == false) { + logger.debug("Mismatch on acquire - proposed leaseId: {}, active leaseId: {}", proposedLeaseId, leaseId); + throw new ConflictException( + "LeaseIdMismatchWithLeaseOperation", + "The lease ID specified did not match the lease ID for the blob/container." + ); + } + validateLeaseDuration(leaseDurationSeconds); + } + } + return leaseId; + } + + public synchronized void release(String requestLeaseId) { + switch (state) { + case Available -> throw new ConflictException( + "LeaseNotPresentWithLeaseOperation", + "There is currently no lease on the blob/container." + ); + case Leased, Expired, Broken -> { + if (leaseId.equals(requestLeaseId) == false) { + logger.debug("Mismatch on release - submitted leaseId: {}, active leaseId: {}", requestLeaseId, this.leaseId); + throw new ConflictException( + "LeaseIdMismatchWithLeaseOperation", + "The lease ID specified did not match the lease ID for the blob/container." + ); + } + state = State.Available; + this.leaseId = null; + } + } + } + + public synchronized void breakLease(Integer leaseBreakPeriod) { + // We haven't implemented the "Breaking" state so we don't support 'breaks' for non-infinite leases unless break-period is 0 + if (leaseDurationSeconds != -1 && (leaseBreakPeriod == null || leaseBreakPeriod != 0)) { + failTestWithAssertionError( + "MockAzureBlobStore only supports breaking non-infinite leases with 'x-ms-lease-break-period: 0'" + ); + } + switch (state) { + case Available -> throw new ConflictException( + "LeaseNotPresentWithLeaseOperation", + "There is currently no lease on the blob/container." + ); + case Leased, Expired, Broken -> state = State.Broken; + } + } + + public synchronized void checkLeaseForWrite(@Nullable String requestLeaseId) { + maybeExpire(requestLeaseId); + switch (state) { + case Available, Expired, Broken -> { + if (requestLeaseId != null) { + throw new PreconditionFailedException( + "LeaseLost", + "A lease ID was specified, but the lease for the blob/container has expired." + ); + } + } + case Leased -> { + if (requestLeaseId == null) { + throw new PreconditionFailedException( + "LeaseIdMissing", + "There is currently a lease on the blob/container and no lease ID was specified in the request." + ); + } + if (leaseId.equals(requestLeaseId) == false) { + throw new ConflictException( + "LeaseIdMismatchWithBlobOperation", + "The lease ID specified did not match the lease ID for the blob." + ); + } + } + } + } + + public synchronized void checkLeaseForRead(@Nullable String requestLeaseId) { + maybeExpire(requestLeaseId); + switch (state) { + case Available, Expired, Broken -> { + if (requestLeaseId != null) { + throw new PreconditionFailedException( + "LeaseLost", + "A lease ID was specified, but the lease for the blob/container has expired." + ); + } + } + case Leased -> { + if (requestLeaseId != null && requestLeaseId.equals(leaseId) == false) { + throw new ConflictException( + "LeaseIdMismatchWithBlobOperation", + "The lease ID specified did not match the lease ID for the blob." + ); + } + } + } + } + + /** + * If there's an active lease, ask the predicate if we should expire the existing it + * + * @param requestLeaseId The lease of the request + */ + private void maybeExpire(String requestLeaseId) { + if (state == State.Leased && leaseExpiryPredicate.shouldExpireLease(leaseId, requestLeaseId)) { + logger.debug("Expiring lease, id={}", leaseId); + state = State.Expired; + } + } + + private void validateLeaseDuration(long leaseTimeSeconds) { + if (leaseTimeSeconds != -1 && (leaseTimeSeconds < 15 || leaseTimeSeconds > 60)) { + throw new BadRequestException( + "InvalidHeaderValue", + AzureHttpHandler.X_MS_LEASE_DURATION + " must be between 16 and 60 seconds (was " + leaseTimeSeconds + ")" + ); + } + } + } + + public static class AzureBlobStoreError extends RuntimeException { + private final RestStatus restStatus; + private final String errorCode; + + public AzureBlobStoreError(RestStatus restStatus, String errorCode, String message) { + super(message); + this.restStatus = restStatus; + this.errorCode = errorCode; + } + + public RestStatus getRestStatus() { + return restStatus; + } + + public String getErrorCode() { + return errorCode; + } + } + + public static class BlobNotFoundException extends AzureBlobStoreError { + public BlobNotFoundException() { + super(RestStatus.NOT_FOUND, "BlobNotFound", "The specified blob does not exist."); + } + } + + public static class BadRequestException extends AzureBlobStoreError { + public BadRequestException(String errorCode, String message) { + super(RestStatus.BAD_REQUEST, errorCode, message); + } + } + + public static class ConflictException extends AzureBlobStoreError { + public ConflictException(String errorCode, String message) { + super(RestStatus.CONFLICT, errorCode, message); + } + } + + public static class PreconditionFailedException extends AzureBlobStoreError { + public PreconditionFailedException(String errorCode, String message) { + super(RestStatus.PRECONDITION_FAILED, errorCode, message); + } + } + + public interface LeaseExpiryPredicate { + + LeaseExpiryPredicate NEVER_EXPIRE = (activeLeaseId, requestLeaseId) -> false; + + /** + * Should the lease be expired? + * + * @param activeLeaseId The current active lease ID + * @param requestLeaseId The request lease ID (if any) + * @return true to expire the lease, false otherwise + */ + boolean shouldExpireLease(String activeLeaseId, @Nullable String requestLeaseId); + } +} diff --git a/x-pack/plugin/repositories-metering-api/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/repositories/metering/azure/AzureRepositoriesMeteringIT.java b/x-pack/plugin/repositories-metering-api/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/repositories/metering/azure/AzureRepositoriesMeteringIT.java index 7029a38edcb5a..d21dc4b2982f1 100644 --- a/x-pack/plugin/repositories-metering-api/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/repositories/metering/azure/AzureRepositoriesMeteringIT.java +++ b/x-pack/plugin/repositories-metering-api/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/repositories/metering/azure/AzureRepositoriesMeteringIT.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.repositories.metering.azure; import fixture.azure.AzureHttpFixture; +import fixture.azure.MockAzureBlobStore; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Booleans; @@ -37,7 +38,8 @@ public class AzureRepositoriesMeteringIT extends AbstractRepositoriesMeteringAPI AZURE_TEST_CONTAINER, System.getProperty("test.azure.tenant_id"), System.getProperty("test.azure.client_id"), - AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT) + AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT), + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE ); private static TestTrustStore trustStore = new TestTrustStore( diff --git a/x-pack/plugin/searchable-snapshots/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/AzureSearchableSnapshotsIT.java b/x-pack/plugin/searchable-snapshots/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/AzureSearchableSnapshotsIT.java index 610b58453716c..f65db6dab1e68 100644 --- a/x-pack/plugin/searchable-snapshots/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/AzureSearchableSnapshotsIT.java +++ b/x-pack/plugin/searchable-snapshots/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/AzureSearchableSnapshotsIT.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.searchablesnapshots; import fixture.azure.AzureHttpFixture; +import fixture.azure.MockAzureBlobStore; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Booleans; @@ -38,7 +39,8 @@ public class AzureSearchableSnapshotsIT extends AbstractSearchableSnapshotsRestT AZURE_TEST_CONTAINER, System.getProperty("test.azure.tenant_id"), System.getProperty("test.azure.client_id"), - AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT) + AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT), + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE ); private static TestTrustStore trustStore = new TestTrustStore( diff --git a/x-pack/plugin/snapshot-based-recoveries/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/snapshotbasedrecoveries/recovery/AzureSnapshotBasedRecoveryIT.java b/x-pack/plugin/snapshot-based-recoveries/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/snapshotbasedrecoveries/recovery/AzureSnapshotBasedRecoveryIT.java index 591d4582d5905..8142b40166840 100644 --- a/x-pack/plugin/snapshot-based-recoveries/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/snapshotbasedrecoveries/recovery/AzureSnapshotBasedRecoveryIT.java +++ b/x-pack/plugin/snapshot-based-recoveries/qa/azure/src/javaRestTest/java/org/elasticsearch/xpack/snapshotbasedrecoveries/recovery/AzureSnapshotBasedRecoveryIT.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.snapshotbasedrecoveries.recovery; import fixture.azure.AzureHttpFixture; +import fixture.azure.MockAzureBlobStore; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Booleans; @@ -37,7 +38,8 @@ public class AzureSnapshotBasedRecoveryIT extends AbstractSnapshotBasedRecoveryR AZURE_TEST_CONTAINER, System.getProperty("test.azure.tenant_id"), System.getProperty("test.azure.client_id"), - AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT) + AzureHttpFixture.sharedKeyForAccountPredicate(AZURE_TEST_ACCOUNT), + MockAzureBlobStore.LeaseExpiryPredicate.NEVER_EXPIRE ); private static TestTrustStore trustStore = new TestTrustStore( diff --git a/x-pack/plugin/snapshot-repo-test-kit/qa/azure/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/AzureRepositoryAnalysisRestIT.java b/x-pack/plugin/snapshot-repo-test-kit/qa/azure/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/AzureRepositoryAnalysisRestIT.java index a9b8fe51c01cc..03906b3cf69da 100644 --- a/x-pack/plugin/snapshot-repo-test-kit/qa/azure/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/AzureRepositoryAnalysisRestIT.java +++ b/x-pack/plugin/snapshot-repo-test-kit/qa/azure/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/AzureRepositoryAnalysisRestIT.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; import static org.hamcrest.Matchers.blankOrNullString; @@ -49,7 +50,10 @@ public class AzureRepositoryAnalysisRestIT extends AbstractRepositoryAnalysisRes AZURE_TEST_CONTAINER, AZURE_TEST_TENANT_ID, AZURE_TEST_CLIENT_ID, - decideAuthHeaderPredicate() + decideAuthHeaderPredicate(), + // 5% of the time, in a contended lease scenario, expire the existing lease + (currentLeaseId, requestLeaseId) -> currentLeaseId.equals(requestLeaseId) == false + && ThreadLocalRandom.current().nextDouble() < 0.05 ); private static Predicate decideAuthHeaderPredicate() { @@ -78,12 +82,6 @@ private static Predicate decideAuthHeaderPredicate() { () -> "ignored;DefaultEndpointsProtocol=http;BlobEndpoint=" + fixture.getAddress(), s -> USE_FIXTURE ) - .apply(c -> { - if (USE_FIXTURE) { - // test fixture does not support CAS yet; TODO fix this - c.systemProperty("test.repository_test_kit.skip_cas", "true"); - } - }) .systemProperty( "tests.azure.credentials.disable_instance_discovery", () -> "true", From 24bc505e28cadad4a3253a458ce6493a916b22e8 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Fri, 29 Nov 2024 14:07:48 +1100 Subject: [PATCH 107/129] [Test] Increase test secret key length (#117675) Running with FIPS approved mode requires secret keys to be at least 114 bits long. Relates: #117324 Resolves: #117596 Resolves: #117709 Resolves: #117710 Resolves: #117711 Resolves: #117712 --- .../RepositoryS3RestReloadCredentialsIT.java | 19 +++++++++++++------ muted-tests.yml | 2 -- .../fixture/aws/sts/AwsStsHttpHandler.java | 3 ++- .../fixture/aws/imds/Ec2ImdsHttpHandler.java | 3 ++- .../org/elasticsearch/test/ESTestCase.java | 7 +++++++ 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java index 430c0a1994967..1f09fa6b081b9 100644 --- a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RestReloadCredentialsIT.java @@ -10,6 +10,7 @@ package org.elasticsearch.repositories.s3; import fixture.s3.S3HttpFixture; +import io.netty.handler.codec.http.HttpMethod; import org.elasticsearch.client.Request; import org.elasticsearch.client.ResponseException; @@ -61,8 +62,6 @@ protected String getTestRestCluster() { } public void testReloadCredentialsFromKeystore() throws IOException { - assumeFalse("doesn't work in a FIPS JVM, but that's ok", inFipsJvm()); - // Register repository (?verify=false because we don't have access to the blob store yet) final var repositoryName = randomIdentifier(); registerRepository( @@ -77,15 +76,16 @@ public void testReloadCredentialsFromKeystore() throws IOException { final var accessKey1 = randomIdentifier(); repositoryAccessKey = accessKey1; keystoreSettings.put("s3.client.default.access_key", accessKey1); - keystoreSettings.put("s3.client.default.secret_key", randomIdentifier()); + keystoreSettings.put("s3.client.default.secret_key", randomSecretKey()); cluster.updateStoredSecureSettings(); - assertOK(client().performRequest(new Request("POST", "/_nodes/reload_secure_settings"))); + + assertOK(client().performRequest(createReloadSecureSettingsRequest())); // Check access using initial credentials assertOK(client().performRequest(verifyRequest)); // Rotate credentials in blob store - final var accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomIdentifier); + final var accessKey2 = randomValueOtherThan(accessKey1, ESTestCase::randomSecretKey); repositoryAccessKey = accessKey2; // Ensure that initial credentials now invalid @@ -99,10 +99,17 @@ public void testReloadCredentialsFromKeystore() throws IOException { // Set up refreshed credentials keystoreSettings.put("s3.client.default.access_key", accessKey2); cluster.updateStoredSecureSettings(); - assertOK(client().performRequest(new Request("POST", "/_nodes/reload_secure_settings"))); + assertOK(client().performRequest(createReloadSecureSettingsRequest())); // Check access using refreshed credentials assertOK(client().performRequest(verifyRequest)); } + private Request createReloadSecureSettingsRequest() throws IOException { + return newXContentRequest( + HttpMethod.POST, + "/_nodes/reload_secure_settings", + (b, p) -> inFipsJvm() ? b.field("secure_settings_password", "keystore-password") : b + ); + } } diff --git a/muted-tests.yml b/muted-tests.yml index d703cfaa1b9aa..c3f67f97011ee 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -216,8 +216,6 @@ tests: - class: org.elasticsearch.reservedstate.service.FileSettingsServiceTests method: testStopWorksInMiddleOfProcessing 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 diff --git a/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java index 84541f5e15211..ac3299f157485 100644 --- a/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java +++ b/test/fixtures/aws-sts-fixture/src/main/java/fixture/aws/sts/AwsStsHttpHandler.java @@ -28,6 +28,7 @@ import java.util.stream.Collectors; import static org.elasticsearch.test.ESTestCase.randomIdentifier; +import static org.elasticsearch.test.ESTestCase.randomSecretKey; /** * Minimal HTTP handler that emulates the AWS STS server @@ -102,7 +103,7 @@ public void handle(final HttpExchange exchange) throws IOException { ROLE_ARN, ROLE_NAME, sessionToken, - randomIdentifier(), + randomSecretKey(), ZonedDateTime.now().plusDays(1L).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")), accessKey ).getBytes(StandardCharsets.UTF_8); diff --git a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java index a92f1bdc5f9ae..bc87eff592bec 100644 --- a/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java +++ b/test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java @@ -28,6 +28,7 @@ import java.util.function.BiConsumer; import static org.elasticsearch.test.ESTestCase.randomIdentifier; +import static org.elasticsearch.test.ESTestCase.randomSecretKey; /** * Minimal HTTP handler that emulates the EC2 IMDS server @@ -84,7 +85,7 @@ public void handle(final HttpExchange exchange) throws IOException { accessKey, ZonedDateTime.now(Clock.systemUTC()).plusDays(1L).format(DateTimeFormatter.ISO_DATE_TIME), randomIdentifier(), - randomIdentifier(), + randomSecretKey(), sessionToken ).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); diff --git a/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java index 5b2beaee00bfe..d983fc854bdfd 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java @@ -1358,6 +1358,13 @@ public static String randomDateFormatterPattern() { return randomFrom(FormatNames.values()).getName(); } + /** + * Generate a random string of at least 112 bits to satisfy minimum entropy requirement when running in FIPS mode. + */ + public static String randomSecretKey() { + return randomAlphaOfLengthBetween(14, 20); + } + /** * Randomly choose between {@link EsExecutors#DIRECT_EXECUTOR_SERVICE} (which does not fork), {@link ThreadPool#generic}, and one of the * other named threadpool executors. From 5935f766df80325f748c3193e13e6e74fb5c1f37 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:44:27 +1100 Subject: [PATCH 108/129] Mute org.elasticsearch.xpack.inference.InferenceCrudIT testSupportedStream #117745 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index c3f67f97011ee..40d3dcf46e1b9 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -222,6 +222,9 @@ tests: - class: "org.elasticsearch.xpack.esql.qa.single_node.EsqlSpecIT" method: "test {scoring.*}" issue: https://github.com/elastic/elasticsearch/issues/117641 +- class: org.elasticsearch.xpack.inference.InferenceCrudIT + method: testSupportedStream + issue: https://github.com/elastic/elasticsearch/issues/117745 # Examples: # From 17d280363c62dc4d35c320246d36ec8cd14e4533 Mon Sep 17 00:00:00 2001 From: David Turner Date: Fri, 29 Nov 2024 09:54:38 +0000 Subject: [PATCH 109/129] Add YAML test for status in indices stats (#116711) The feature added in #81954 lacks coverage in BwC situations. This commit adds a YAML test to address that. --- .../indices.stats/15_open_closed_state.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.stats/15_open_closed_state.yml diff --git a/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.stats/15_open_closed_state.yml b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.stats/15_open_closed_state.yml new file mode 100644 index 0000000000000..94b6a3acc83a8 --- /dev/null +++ b/rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.stats/15_open_closed_state.yml @@ -0,0 +1,22 @@ +--- +"Ensure index state is exposed": + - requires: + cluster_features: ["gte_v8.1.0"] + reason: index state added to stats in 8.1.0 + + - do: + indices.create: + index: openindex + - do: + indices.create: + index: closedindex + - do: + indices.close: + index: closedindex + - do: + indices.stats: + expand_wildcards: [open,closed] + forbid_closed_indices: false + + - match: { indices.openindex.status: open } + - match: { indices.closedindex.status: close } From c3f9e0172333b8edae525865c9d84b29a1c6ab8f Mon Sep 17 00:00:00 2001 From: David Turner Date: Fri, 29 Nov 2024 09:58:09 +0000 Subject: [PATCH 110/129] Migrate `repository-s3` YAML tests to Java REST tests (#117628) Today these YAML tests rely on a bunch of rather complex setup organised by Gradle, and contain lots of duplication and coincident strings, mostly because that was the only way to achieve what we wanted before we could orchestrate test clusters and fixtures directly from Java test suites. We're not actually running the YAML tests in ways that take advantage of their YAMLness (e.g. in mixed-version clusters, or from other client libraries). This commit replaces these tests with Java REST tests which enormously simplifies this area of code. Relates ES-9984 --- modules/repository-s3/build.gradle | 118 +----- .../s3/S3RepositoryThirdPartyTests.java | 7 +- .../s3/AbstractRepositoryS3RestTestCase.java | 383 ++++++++++++++++++ .../RepositoryS3BasicCredentialsRestIT.java | 65 +++ .../s3/RepositoryS3EcsCredentialsRestIT.java} | 44 +- .../RepositoryS3ImdsV1CredentialsRestIT.java | 73 ++++ ...ositoryS3MinioBasicCredentialsRestIT.java} | 44 +- .../RepositoryS3SessionCredentialsRestIT.java | 72 ++++ .../s3/RepositoryS3StsCredentialsRestIT.java} | 64 +-- .../repositories/s3/S3BlobStore.java | 2 +- .../repositories/s3/S3Service.java | 8 +- .../resources/aws-web-identity-token-file | 1 - .../s3/RepositoryS3ClientYamlTestSuiteIT.java | 57 +-- ...oryS3RegionalStsClientYamlTestSuiteIT.java | 12 +- .../20_repository_permanent_credentials.yml | 265 +----------- .../30_repository_temporary_credentials.yml | 278 ------------- .../40_repository_ec2_credentials.yml | 278 ------------- .../50_repository_ecs_credentials.yml | 278 ------------- .../60_repository_sts_credentials.yml | 279 ------------- .../fixtures/minio/MinioTestContainer.java | 12 +- .../main/java/fixture/s3/S3HttpFixture.java | 4 - .../local/AbstractLocalClusterFactory.java | 2 + .../minio/MinioSearchableSnapshotsIT.java | 7 +- .../MinioRepositoryAnalysisRestIT.java | 7 +- 24 files changed, 765 insertions(+), 1595 deletions(-) create mode 100644 modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/AbstractRepositoryS3RestTestCase.java create mode 100644 modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3BasicCredentialsRestIT.java rename modules/repository-s3/src/{yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java => javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsCredentialsRestIT.java} (59%) create mode 100644 modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ImdsV1CredentialsRestIT.java rename modules/repository-s3/src/{yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioClientYamlTestSuiteIT.java => javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioBasicCredentialsRestIT.java} (50%) create mode 100644 modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3SessionCredentialsRestIT.java rename modules/repository-s3/src/{yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java => javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsCredentialsRestIT.java} (53%) delete mode 100644 modules/repository-s3/src/test/resources/aws-web-identity-token-file delete mode 100644 modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/30_repository_temporary_credentials.yml delete mode 100644 modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/40_repository_ec2_credentials.yml delete mode 100644 modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/50_repository_ecs_credentials.yml delete mode 100644 modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/60_repository_sts_credentials.yml diff --git a/modules/repository-s3/build.gradle b/modules/repository-s3/build.gradle index ed1777891f40d..2cfb5d23db4ff 100644 --- a/modules/repository-s3/build.gradle +++ b/modules/repository-s3/build.gradle @@ -43,19 +43,24 @@ dependencies { api 'javax.xml.bind:jaxb-api:2.2.2' testImplementation project(':test:fixtures:s3-fixture') - yamlRestTestImplementation project(":test:framework") - yamlRestTestImplementation project(':test:fixtures:s3-fixture') - yamlRestTestImplementation project(':test:fixtures:ec2-imds-fixture') - yamlRestTestImplementation project(':test:fixtures:aws-sts-fixture') - yamlRestTestImplementation project(':test:fixtures:minio-fixture') - internalClusterTestImplementation project(':test:fixtures:minio-fixture') - javaRestTestImplementation project(":test:framework") - javaRestTestImplementation project(':test:fixtures:s3-fixture') - javaRestTestImplementation project(':modules:repository-s3') + internalClusterTestImplementation project(':test:fixtures:minio-fixture') + internalClusterTestRuntimeOnly "org.slf4j:slf4j-simple:${versions.slf4j}" + yamlRestTestImplementation project(':modules:repository-s3') + yamlRestTestImplementation project(':test:fixtures:s3-fixture') + yamlRestTestImplementation project(':test:fixtures:testcontainer-utils') + yamlRestTestImplementation project(':test:framework') yamlRestTestRuntimeOnly "org.slf4j:slf4j-simple:${versions.slf4j}" - internalClusterTestRuntimeOnly "org.slf4j:slf4j-simple:${versions.slf4j}" + + javaRestTestImplementation project(':modules:repository-s3') + javaRestTestImplementation project(':test:fixtures:aws-sts-fixture') + javaRestTestImplementation project(':test:fixtures:ec2-imds-fixture') + javaRestTestImplementation project(':test:fixtures:minio-fixture') + javaRestTestImplementation project(':test:fixtures:s3-fixture') + javaRestTestImplementation project(':test:fixtures:testcontainer-utils') + javaRestTestImplementation project(':test:framework') + javaRestTestRuntimeOnly "org.slf4j:slf4j-simple:${versions.slf4j}" } restResources { @@ -82,90 +87,25 @@ def testRepositoryCreds = tasks.register("testRepositoryCreds", Test) { testClassesDirs = sourceSets.test.output.classesDirs } -tasks.named('check').configure { - dependsOn(testRepositoryCreds) -} - tasks.named('test').configure { // this is tested explicitly in separate test tasks exclude '**/RepositoryCredentialsTests.class' } boolean useFixture = false - -// We test against two repositories, one which uses the usual two-part "permanent" credentials and -// the other which uses three-part "temporary" or "session" credentials. - String s3PermanentAccessKey = System.getenv("amazon_s3_access_key") String s3PermanentSecretKey = System.getenv("amazon_s3_secret_key") String s3PermanentBucket = System.getenv("amazon_s3_bucket") String s3PermanentBasePath = System.getenv("amazon_s3_base_path") -String s3TemporaryAccessKey = System.getenv("amazon_s3_access_key_temporary") -String s3TemporarySecretKey = System.getenv("amazon_s3_secret_key_temporary") -String s3TemporarySessionToken = System.getenv("amazon_s3_session_token_temporary") -String s3TemporaryBucket = System.getenv("amazon_s3_bucket_temporary") -String s3TemporaryBasePath = System.getenv("amazon_s3_base_path_temporary") - -String s3EC2Bucket = System.getenv("amazon_s3_bucket_ec2") -String s3EC2BasePath = System.getenv("amazon_s3_base_path_ec2") - -String s3ECSBucket = System.getenv("amazon_s3_bucket_ecs") -String s3ECSBasePath = System.getenv("amazon_s3_base_path_ecs") - -String s3STSBucket = System.getenv("amazon_s3_bucket_sts") -String s3STSBasePath = System.getenv("amazon_s3_base_path_sts") - -boolean s3DisableChunkedEncoding = buildParams.random.nextBoolean() - -// If all these variables are missing then we are testing against the internal fixture instead, which has the following -// credentials hard-coded in. +// If all these variables are missing then we are testing against the internal fixture instead, which has the following credentials hard-coded in. if (!s3PermanentAccessKey && !s3PermanentSecretKey && !s3PermanentBucket && !s3PermanentBasePath) { + useFixture = true s3PermanentAccessKey = 's3_test_access_key' s3PermanentSecretKey = 's3_test_secret_key' s3PermanentBucket = 'bucket' s3PermanentBasePath = 'base_path' - useFixture = true -} -if (!s3TemporaryAccessKey && !s3TemporarySecretKey && !s3TemporaryBucket && !s3TemporaryBasePath && !s3TemporarySessionToken) { - s3TemporaryAccessKey = 'session_token_access_key' - s3TemporarySecretKey = 'session_token_secret_key' - s3TemporaryBucket = 'session_token_bucket' - s3TemporaryBasePath = 'session_token_base_path' -} - -if (!s3EC2Bucket && !s3EC2BasePath && !s3ECSBucket && !s3ECSBasePath) { - s3EC2Bucket = 'ec2_bucket' - s3EC2BasePath = 'ec2_base_path' - s3ECSBucket = 'ecs_bucket' - s3ECSBasePath = 'ecs_base_path' -} - -if (!s3STSBucket && !s3STSBasePath) { - s3STSBucket = 'sts_bucket' - s3STSBasePath = 'sts_base_path' -} - -tasks.named("processYamlRestTestResources").configure { - from("src/test/resources") { - include "aws-web-identity-token-file" - } - Map expansions = [ - 'permanent_bucket' : s3PermanentBucket, - 'permanent_base_path' : s3PermanentBasePath + "_integration_tests", - 'temporary_bucket' : s3TemporaryBucket, - 'temporary_base_path' : s3TemporaryBasePath + "_integration_tests", - 'ec2_bucket' : s3EC2Bucket, - 'ec2_base_path' : s3EC2BasePath, - 'ecs_bucket' : s3ECSBucket, - 'ecs_base_path' : s3ECSBasePath, - 'sts_bucket' : s3STSBucket, - 'sts_base_path' : s3STSBasePath, - 'disable_chunked_encoding': s3DisableChunkedEncoding - ] - inputs.properties(expansions) - filter("tokens" : expansions.collectEntries {k, v -> [k, v.toString()]} /* must be a map of strings */, ReplaceTokens.class) } tasks.named("internalClusterTest").configure { @@ -175,22 +115,7 @@ tasks.named("internalClusterTest").configure { systemProperty 'es.insecure_network_trace_enabled', 'true' } -tasks.named("yamlRestTest").configure { - systemProperty("s3PermanentAccessKey", s3PermanentAccessKey) - systemProperty("s3PermanentSecretKey", s3PermanentSecretKey) - systemProperty("s3TemporaryAccessKey", s3TemporaryAccessKey) - systemProperty("s3TemporarySecretKey", s3TemporarySecretKey) - systemProperty("s3EC2AccessKey", s3PermanentAccessKey) - - // ideally we could resolve an env path in cluster config as resource similar to configuring a config file - // not sure how common this is, but it would be nice to support - File awsWebIdentityTokenExternalLocation = file('src/test/resources/aws-web-identity-token-file') - // The web identity token can be read only from the plugin config directory because of security restrictions - // Ideally we would create a symlink, but extraConfigFile doesn't support it - nonInputProperties.systemProperty("awsWebIdentityTokenExternalLocation", awsWebIdentityTokenExternalLocation.getAbsolutePath()) -} - -// 3rd Party Tests +// 3rd Party Tests, i.e. testing against a real S3 repository tasks.register("s3ThirdPartyTest", Test) { SourceSetContainer sourceSets = project.getExtensions().getByType(SourceSetContainer.class); SourceSet internalTestSourceSet = sourceSets.getByName(InternalClusterTestPlugin.SOURCE_SET_NAME) @@ -198,13 +123,13 @@ tasks.register("s3ThirdPartyTest", Test) { setClasspath(internalTestSourceSet.getRuntimeClasspath()) include '**/S3RepositoryThirdPartyTests.class' systemProperty("tests.use.fixture", Boolean.toString(useFixture)) - - // test container accesses ~/.testcontainers.properties read - systemProperty "tests.security.manager", "false" systemProperty 'test.s3.account', s3PermanentAccessKey systemProperty 'test.s3.key', s3PermanentSecretKey systemProperty 'test.s3.bucket', s3PermanentBucket nonInputProperties.systemProperty 'test.s3.base', s3PermanentBasePath + "_third_party_tests_" + buildParams.testSeed + + // test container accesses ~/.testcontainers.properties read + systemProperty "tests.security.manager", "false" } tasks.named("thirdPartyAudit").configure { @@ -241,5 +166,6 @@ tasks.named("thirdPartyAudit").configure { tasks.named("check").configure { dependsOn(tasks.withType(Test)) + dependsOn(testRepositoryCreds) } diff --git a/modules/repository-s3/src/internalClusterTest/java/org/elasticsearch/repositories/s3/S3RepositoryThirdPartyTests.java b/modules/repository-s3/src/internalClusterTest/java/org/elasticsearch/repositories/s3/S3RepositoryThirdPartyTests.java index 3552cb8d9389a..4cebedebfba07 100644 --- a/modules/repository-s3/src/internalClusterTest/java/org/elasticsearch/repositories/s3/S3RepositoryThirdPartyTests.java +++ b/modules/repository-s3/src/internalClusterTest/java/org/elasticsearch/repositories/s3/S3RepositoryThirdPartyTests.java @@ -61,7 +61,12 @@ public class S3RepositoryThirdPartyTests extends AbstractThirdPartyRepositoryTes static final boolean USE_FIXTURE = Booleans.parseBoolean(System.getProperty("tests.use.fixture", "true")); @ClassRule - public static MinioTestContainer minio = new MinioTestContainer(USE_FIXTURE); + public static MinioTestContainer minio = new MinioTestContainer( + USE_FIXTURE, + System.getProperty("test.s3.account"), + System.getProperty("test.s3.key"), + System.getProperty("test.s3.bucket") + ); @Override protected Collection> getPlugins() { diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/AbstractRepositoryS3RestTestCase.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/AbstractRepositoryS3RestTestCase.java new file mode 100644 index 0000000000000..2199a64521759 --- /dev/null +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/AbstractRepositoryS3RestTestCase.java @@ -0,0 +1,383 @@ +/* + * 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.repositories.s3; + +import io.netty.handler.codec.http.HttpMethod; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.cluster.routing.Murmur3HashFunction; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.repositories.blobstore.BlobStoreRepository; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.ESRestTestCase; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Set; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public abstract class AbstractRepositoryS3RestTestCase extends ESRestTestCase { + + public record TestRepository(String repositoryName, String clientName, String bucketName, String basePath) { + + public Closeable register() throws IOException { + return register(UnaryOperator.identity()); + } + + public Closeable register(UnaryOperator settingsUnaryOperator) throws IOException { + assertOK(client().performRequest(getRegisterRequest(settingsUnaryOperator))); + return () -> assertOK(client().performRequest(new Request("DELETE", "/_snapshot/" + repositoryName()))); + } + + private Request getRegisterRequest(UnaryOperator settingsUnaryOperator) throws IOException { + return newXContentRequest( + HttpMethod.PUT, + "/_snapshot/" + repositoryName(), + (b, p) -> b.field("type", S3Repository.TYPE) + .startObject("settings") + .value( + settingsUnaryOperator.apply( + Settings.builder() + .put("bucket", bucketName()) + .put("base_path", basePath()) + .put("client", clientName()) + .put("canned_acl", "private") + .put("storage_class", "standard") + .put("disable_chunked_encoding", randomBoolean()) + .build() + ) + ) + .endObject() + ); + } + } + + protected abstract String getBucketName(); + + protected abstract String getBasePath(); + + protected abstract String getClientName(); + + protected static String getIdentifierPrefix(String testSuiteName) { + return testSuiteName + "-" + Integer.toString(Murmur3HashFunction.hash(testSuiteName + System.getProperty("tests.seed")), 16) + "-"; + } + + private TestRepository newTestRepository() { + return new TestRepository(randomIdentifier(), getClientName(), getBucketName(), getBasePath()); + } + + private static UnaryOperator readonlyOperator(Boolean readonly) { + return readonly == null + ? UnaryOperator.identity() + : s -> Settings.builder().put(s).put(BlobStoreRepository.READONLY_SETTING_KEY, readonly).build(); + } + + public void testGetRepository() throws IOException { + testGetRepository(null); + } + + public void testGetRepositoryReadonlyTrue() throws IOException { + testGetRepository(Boolean.TRUE); + } + + public void testGetRepositoryReadonlyFalse() throws IOException { + testGetRepository(Boolean.FALSE); + } + + private void testGetRepository(Boolean readonly) throws IOException { + final var repository = newTestRepository(); + try (var ignored = repository.register(readonlyOperator(readonly))) { + final var repositoryName = repository.repositoryName(); + final var responseObjectPath = assertOKAndCreateObjectPath( + client().performRequest(new Request("GET", "/_snapshot/" + repositoryName)) + ); + + assertEquals("s3", responseObjectPath.evaluate(repositoryName + ".type")); + assertNotNull(responseObjectPath.evaluate(repositoryName + ".settings")); + assertEquals(repository.bucketName(), responseObjectPath.evaluate(repositoryName + ".settings.bucket")); + assertEquals(repository.clientName(), responseObjectPath.evaluate(repositoryName + ".settings.client")); + assertEquals(repository.basePath(), responseObjectPath.evaluate(repositoryName + ".settings.base_path")); + assertEquals("private", responseObjectPath.evaluate(repositoryName + ".settings.canned_acl")); + assertEquals("standard", responseObjectPath.evaluate(repositoryName + ".settings.storage_class")); + assertNull(responseObjectPath.evaluate(repositoryName + ".settings.access_key")); + assertNull(responseObjectPath.evaluate(repositoryName + ".settings.secret_key")); + assertNull(responseObjectPath.evaluate(repositoryName + ".settings.session_token")); + + if (readonly == null) { + assertNull(responseObjectPath.evaluate(repositoryName + ".settings." + BlobStoreRepository.READONLY_SETTING_KEY)); + } else { + assertEquals( + Boolean.toString(readonly), + responseObjectPath.evaluate(repositoryName + ".settings." + BlobStoreRepository.READONLY_SETTING_KEY) + ); + } + } + } + + public void testNonexistentBucket() throws Exception { + testNonexistentBucket(null); + } + + public void testNonexistentBucketReadonlyTrue() throws Exception { + testNonexistentBucket(Boolean.TRUE); + } + + public void testNonexistentBucketReadonlyFalse() throws Exception { + testNonexistentBucket(Boolean.FALSE); + } + + private void testNonexistentBucket(Boolean readonly) throws Exception { + final var repository = new TestRepository( + randomIdentifier(), + getClientName(), + randomValueOtherThan(getBucketName(), ESTestCase::randomIdentifier), + getBasePath() + ); + final var registerRequest = repository.getRegisterRequest(readonlyOperator(readonly)); + + final var responseException = expectThrows(ResponseException.class, () -> client().performRequest(registerRequest)); + assertEquals(RestStatus.INTERNAL_SERVER_ERROR.getStatus(), responseException.getResponse().getStatusLine().getStatusCode()); + assertThat( + responseException.getMessage(), + allOf(containsString("repository_verification_exception"), containsString("is not accessible on master node")) + ); + } + + public void testNonexistentClient() throws Exception { + testNonexistentClient(null); + } + + public void testNonexistentClientReadonlyTrue() throws Exception { + testNonexistentClient(Boolean.TRUE); + } + + public void testNonexistentClientReadonlyFalse() throws Exception { + testNonexistentClient(Boolean.FALSE); + } + + private void testNonexistentClient(Boolean readonly) throws Exception { + final var repository = new TestRepository( + randomIdentifier(), + randomValueOtherThanMany(c -> c.equals(getClientName()) || c.equals("default"), ESTestCase::randomIdentifier), + getBucketName(), + getBasePath() + ); + final var registerRequest = repository.getRegisterRequest(readonlyOperator(readonly)); + + final var responseException = expectThrows(ResponseException.class, () -> client().performRequest(registerRequest)); + assertEquals(RestStatus.INTERNAL_SERVER_ERROR.getStatus(), responseException.getResponse().getStatusLine().getStatusCode()); + assertThat( + responseException.getMessage(), + allOf( + containsString("repository_verification_exception"), + containsString("is not accessible on master node"), + containsString("illegal_argument_exception"), + containsString("Unknown s3 client name") + ) + ); + } + + public void testNonexistentSnapshot() throws Exception { + testNonexistentSnapshot(null); + } + + public void testNonexistentSnapshotReadonlyTrue() throws Exception { + testNonexistentSnapshot(Boolean.TRUE); + } + + public void testNonexistentSnapshotReadonlyFalse() throws Exception { + testNonexistentSnapshot(Boolean.FALSE); + } + + private void testNonexistentSnapshot(Boolean readonly) throws Exception { + final var repository = newTestRepository(); + try (var ignored = repository.register(readonlyOperator(readonly))) { + final var repositoryName = repository.repositoryName(); + + final var getSnapshotRequest = new Request("GET", "/_snapshot/" + repositoryName + "/" + randomIdentifier()); + final var getSnapshotException = expectThrows(ResponseException.class, () -> client().performRequest(getSnapshotRequest)); + assertEquals(RestStatus.NOT_FOUND.getStatus(), getSnapshotException.getResponse().getStatusLine().getStatusCode()); + assertThat(getSnapshotException.getMessage(), containsString("snapshot_missing_exception")); + + final var restoreRequest = new Request("POST", "/_snapshot/" + repositoryName + "/" + randomIdentifier() + "/_restore"); + if (randomBoolean()) { + restoreRequest.addParameter("wait_for_completion", Boolean.toString(randomBoolean())); + } + final var restoreException = expectThrows(ResponseException.class, () -> client().performRequest(restoreRequest)); + assertEquals(RestStatus.INTERNAL_SERVER_ERROR.getStatus(), restoreException.getResponse().getStatusLine().getStatusCode()); + assertThat(restoreException.getMessage(), containsString("snapshot_restore_exception")); + + if (readonly != Boolean.TRUE) { + final var deleteRequest = new Request("DELETE", "/_snapshot/" + repositoryName + "/" + randomIdentifier()); + final var deleteException = expectThrows(ResponseException.class, () -> client().performRequest(deleteRequest)); + assertEquals(RestStatus.NOT_FOUND.getStatus(), deleteException.getResponse().getStatusLine().getStatusCode()); + assertThat(deleteException.getMessage(), containsString("snapshot_missing_exception")); + } + } + } + + public void testUsageStats() throws Exception { + testUsageStats(null); + } + + public void testUsageStatsReadonlyTrue() throws Exception { + testUsageStats(Boolean.TRUE); + } + + public void testUsageStatsReadonlyFalse() throws Exception { + testUsageStats(Boolean.FALSE); + } + + private void testUsageStats(Boolean readonly) throws Exception { + final var repository = newTestRepository(); + try (var ignored = repository.register(readonlyOperator(readonly))) { + final var responseObjectPath = assertOKAndCreateObjectPath(client().performRequest(new Request("GET", "/_cluster/stats"))); + assertThat(responseObjectPath.evaluate("repositories.s3.count"), equalTo(1)); + + if (readonly == Boolean.TRUE) { + assertThat(responseObjectPath.evaluate("repositories.s3.read_only"), equalTo(1)); + assertNull(responseObjectPath.evaluate("repositories.s3.read_write")); + } else { + assertNull(responseObjectPath.evaluate("repositories.s3.read_only")); + assertThat(responseObjectPath.evaluate("repositories.s3.read_write"), equalTo(1)); + } + } + } + + public void testSnapshotAndRestore() throws Exception { + final var repository = newTestRepository(); + try (var ignored = repository.register()) { + final var repositoryName = repository.repositoryName(); + final var indexName = randomIdentifier(); + final var snapshotsToDelete = new ArrayList(2); + + try { + indexDocuments(indexName, """ + {"index":{"_id":"1"}} + {"snapshot":"one"} + {"index":{"_id":"2"}} + {"snapshot":"one"} + {"index":{"_id":"3"}} + {"snapshot":"one"} + """, 3); + + // create the first snapshot + final var snapshot1Name = randomIdentifier(); + createSnapshot(repositoryName, snapshotsToDelete, snapshot1Name); + + // check the first snapshot's status + { + final var snapshotStatusResponse = assertOKAndCreateObjectPath( + client().performRequest(new Request("GET", "/_snapshot/" + repositoryName + "/" + snapshot1Name + "/_status")) + ); + assertEquals(snapshot1Name, snapshotStatusResponse.evaluate("snapshots.0.snapshot")); + assertEquals("SUCCESS", snapshotStatusResponse.evaluate("snapshots.0.state")); + } + + // add more documents to the index + indexDocuments(indexName, """ + {"index":{"_id":"4"}} + {"snapshot":"one"} + {"index":{"_id":"5"}} + {"snapshot":"one"} + {"index":{"_id":"6"}} + {"snapshot":"one"} + {"index":{"_id":"7"}} + {"snapshot":"one"} + """, 7); + + // create the second snapshot + final var snapshot2Name = randomValueOtherThan(snapshot1Name, ESTestCase::randomIdentifier); + createSnapshot(repositoryName, snapshotsToDelete, snapshot2Name); + + // list the snapshots + { + final var listSnapshotsResponse = assertOKAndCreateObjectPath( + client().performRequest( + new Request("GET", "/_snapshot/" + repositoryName + "/" + snapshot1Name + "," + snapshot2Name) + ) + ); + assertEquals(2, listSnapshotsResponse.evaluateArraySize("snapshots")); + assertEquals( + Set.of(snapshot1Name, snapshot2Name), + Set.of( + listSnapshotsResponse.evaluate("snapshots.0.snapshot"), + listSnapshotsResponse.evaluate("snapshots.1.snapshot") + ) + ); + assertEquals("SUCCESS", listSnapshotsResponse.evaluate("snapshots.0.state")); + assertEquals("SUCCESS", listSnapshotsResponse.evaluate("snapshots.1.state")); + } + + // delete and restore the index from snapshot 2 + deleteAndRestoreIndex(indexName, repositoryName, snapshot2Name, 7); + + // delete and restore the index from snapshot 1 + deleteAndRestoreIndex(indexName, repositoryName, snapshot1Name, 3); + } finally { + if (snapshotsToDelete.isEmpty() == false) { + assertOK( + client().performRequest( + new Request( + "DELETE", + "/_snapshot/" + repositoryName + "/" + snapshotsToDelete.stream().collect(Collectors.joining(",")) + ) + ) + ); + } + } + } + } + + private static void deleteAndRestoreIndex(String indexName, String repositoryName, String snapshot2Name, int expectedDocCount) + throws IOException { + assertOK(client().performRequest(new Request("DELETE", "/" + indexName))); + final var restoreRequest = new Request("POST", "/_snapshot/" + repositoryName + "/" + snapshot2Name + "/_restore"); + restoreRequest.addParameter("wait_for_completion", "true"); + assertOK(client().performRequest(restoreRequest)); + assertIndexDocCount(indexName, expectedDocCount); + } + + private static void indexDocuments(String indexName, String body, int expectedDocCount) throws IOException { + // create and populate an index + final var indexDocsRequest = new Request("POST", "/" + indexName + "/_bulk"); + indexDocsRequest.addParameter("refresh", "true"); + indexDocsRequest.setJsonEntity(body); + assertFalse(assertOKAndCreateObjectPath(client().performRequest(indexDocsRequest)).evaluate("errors")); + + // check the index contents + assertIndexDocCount(indexName, expectedDocCount); + } + + private static void createSnapshot(String repositoryName, ArrayList snapshotsToDelete, String snapshotName) throws IOException { + final var createSnapshotRequest = new Request("POST", "/_snapshot/" + repositoryName + "/" + snapshotName); + createSnapshotRequest.addParameter("wait_for_completion", "true"); + final var createSnapshotResponse = assertOKAndCreateObjectPath(client().performRequest(createSnapshotRequest)); + snapshotsToDelete.add(snapshotName); + assertEquals(snapshotName, createSnapshotResponse.evaluate("snapshot.snapshot")); + assertEquals("SUCCESS", createSnapshotResponse.evaluate("snapshot.state")); + assertThat(createSnapshotResponse.evaluate("snapshot.shards.failed"), equalTo(0)); + } + + private static void assertIndexDocCount(String indexName, int expectedCount) throws IOException { + assertThat( + assertOKAndCreateObjectPath(client().performRequest(new Request("GET", "/" + indexName + "/_count"))).evaluate("count"), + equalTo(expectedCount) + ); + } +} diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3BasicCredentialsRestIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3BasicCredentialsRestIT.java new file mode 100644 index 0000000000000..45844703683bb --- /dev/null +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3BasicCredentialsRestIT.java @@ -0,0 +1,65 @@ +/* + * 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.repositories.s3; + +import fixture.s3.S3HttpFixture; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; + +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; +import org.junit.ClassRule; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; + +@ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3BasicCredentialsRestIT extends AbstractRepositoryS3RestTestCase { + + private static final String PREFIX = getIdentifierPrefix("RepositoryS3BasicCredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String ACCESS_KEY = PREFIX + "access-key"; + private static final String SECRET_KEY = PREFIX + "secret-key"; + private static final String CLIENT = "basic_credentials_client"; + + private static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, S3HttpFixture.fixedAccessKey(ACCESS_KEY)); + + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() + .module("repository-s3") + .keystore("s3.client." + CLIENT + ".access_key", ACCESS_KEY) + .keystore("s3.client." + CLIENT + ".secret_key", SECRET_KEY) + .setting("s3.client." + CLIENT + ".endpoint", s3Fixture::getAddress) + .build(); + + @ClassRule + public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(cluster); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + @Override + protected String getBucketName() { + return BUCKET; + } + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Override + protected String getClientName() { + return CLIENT; + } +} diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsCredentialsRestIT.java similarity index 59% rename from modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java rename to modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsCredentialsRestIT.java index bbd003f506ead..267ba6e6b3a13 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsCredentialsRestIT.java @@ -13,18 +13,25 @@ import fixture.s3.DynamicS3Credentials; import fixture.s3.S3HttpFixture; -import com.carrotsearch.randomizedtesting.annotations.Name; -import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.elasticsearch.test.cluster.ElasticsearchCluster; -import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; +import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; import org.junit.ClassRule; import org.junit.rules.RuleChain; import org.junit.rules.TestRule; import java.util.Set; -public class RepositoryS3EcsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { +@ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3EcsCredentialsRestIT extends AbstractRepositoryS3RestTestCase { + + private static final String PREFIX = getIdentifierPrefix("RepositoryS3EcsCredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String CLIENT = "ecs_credentials_client"; private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); @@ -33,33 +40,34 @@ public class RepositoryS3EcsClientYamlTestSuiteIT extends AbstractRepositoryS3Cl Set.of("/ecs_credentials_endpoint") ); - private static final S3HttpFixture s3Fixture = new S3HttpFixture( - true, - "ecs_bucket", - "ecs_base_path", - dynamicS3Credentials::isAuthorized - ); + private static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, dynamicS3Credentials::isAuthorized); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .setting("s3.client.integration_test_ecs.endpoint", s3Fixture::getAddress) + .setting("s3.client." + CLIENT + ".endpoint", s3Fixture::getAddress) .environment("AWS_CONTAINER_CREDENTIALS_FULL_URI", () -> ec2ImdsHttpFixture.getAddress() + "/ecs_credentials_endpoint") .build(); @ClassRule public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(ec2ImdsHttpFixture).around(cluster); - @ParametersFactory - public static Iterable parameters() throws Exception { - return createParameters(new String[] { "repository_s3/50_repository_ecs_credentials" }); + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); } - public RepositoryS3EcsClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) { - super(testCandidate); + @Override + protected String getBucketName() { + return BUCKET; } @Override - protected String getTestRestCluster() { - return cluster.getHttpAddresses(); + protected String getBasePath() { + return BASE_PATH; + } + + @Override + protected String getClientName() { + return CLIENT; } } diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ImdsV1CredentialsRestIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ImdsV1CredentialsRestIT.java new file mode 100644 index 0000000000000..de9c9b6ae0695 --- /dev/null +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ImdsV1CredentialsRestIT.java @@ -0,0 +1,73 @@ +/* + * 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.repositories.s3; + +import fixture.aws.imds.Ec2ImdsHttpFixture; +import fixture.s3.DynamicS3Credentials; +import fixture.s3.S3HttpFixture; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; + +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; +import org.junit.ClassRule; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; + +import java.util.Set; + +@ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3ImdsV1CredentialsRestIT extends AbstractRepositoryS3RestTestCase { + + private static final String PREFIX = getIdentifierPrefix("RepositoryS3ImdsV1CredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String CLIENT = "imdsv1_credentials_client"; + + private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); + + private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture( + dynamicS3Credentials::addValidCredentials, + Set.of() + ); + + private static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, dynamicS3Credentials::isAuthorized); + + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() + .module("repository-s3") + .setting("s3.client." + CLIENT + ".endpoint", s3Fixture::getAddress) + .systemProperty("com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", ec2ImdsHttpFixture::getAddress) + .build(); + + @ClassRule + public static TestRule ruleChain = RuleChain.outerRule(ec2ImdsHttpFixture).around(s3Fixture).around(cluster); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + @Override + protected String getBucketName() { + return BUCKET; + } + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Override + protected String getClientName() { + return CLIENT; + } +} diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioClientYamlTestSuiteIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioBasicCredentialsRestIT.java similarity index 50% rename from modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioClientYamlTestSuiteIT.java rename to modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioBasicCredentialsRestIT.java index d2b1413295ceb..93915e8491d5b 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3MinioBasicCredentialsRestIT.java @@ -9,44 +9,56 @@ package org.elasticsearch.repositories.s3; -import com.carrotsearch.randomizedtesting.annotations.Name; -import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.fixtures.minio.MinioTestContainer; import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; -import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; import org.junit.ClassRule; import org.junit.rules.RuleChain; import org.junit.rules.TestRule; @ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) -public class RepositoryS3MinioClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3MinioBasicCredentialsRestIT extends AbstractRepositoryS3RestTestCase { - public static MinioTestContainer minio = new MinioTestContainer(); + private static final String PREFIX = getIdentifierPrefix("RepositoryS3MinioBasicCredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String ACCESS_KEY = PREFIX + "access-key"; + private static final String SECRET_KEY = PREFIX + "secret-key"; + private static final String CLIENT = "minio_client"; + + private static final MinioTestContainer minioFixture = new MinioTestContainer(true, ACCESS_KEY, SECRET_KEY, BUCKET); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .keystore("s3.client.integration_test_permanent.access_key", System.getProperty("s3PermanentAccessKey")) - .keystore("s3.client.integration_test_permanent.secret_key", System.getProperty("s3PermanentSecretKey")) - .setting("s3.client.integration_test_permanent.endpoint", () -> minio.getAddress()) + .keystore("s3.client." + CLIENT + ".access_key", ACCESS_KEY) + .keystore("s3.client." + CLIENT + ".secret_key", SECRET_KEY) + .setting("s3.client." + CLIENT + ".endpoint", minioFixture::getAddress) .build(); @ClassRule - public static TestRule ruleChain = RuleChain.outerRule(minio).around(cluster); + public static TestRule ruleChain = RuleChain.outerRule(minioFixture).around(cluster); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } - @ParametersFactory - public static Iterable parameters() throws Exception { - return createParameters(new String[] { "repository_s3/10_basic", "repository_s3/20_repository_permanent_credentials" }); + @Override + protected String getBucketName() { + return BUCKET; } - public RepositoryS3MinioClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) { - super(testCandidate); + @Override + protected String getBasePath() { + return BASE_PATH; } @Override - protected String getTestRestCluster() { - return cluster.getHttpAddresses(); + protected String getClientName() { + return CLIENT; } } diff --git a/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3SessionCredentialsRestIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3SessionCredentialsRestIT.java new file mode 100644 index 0000000000000..84a327ee131ae --- /dev/null +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3SessionCredentialsRestIT.java @@ -0,0 +1,72 @@ +/* + * 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.repositories.s3; + +import fixture.s3.S3HttpFixture; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; + +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; +import org.junit.ClassRule; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; + +@ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3SessionCredentialsRestIT extends AbstractRepositoryS3RestTestCase { + + private static final String PREFIX = getIdentifierPrefix("RepositoryS3SessionCredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String ACCESS_KEY = PREFIX + "access-key"; + private static final String SECRET_KEY = PREFIX + "secret-key"; + private static final String SESSION_TOKEN = PREFIX + "session-token"; + private static final String CLIENT = "session_credentials_client"; + + private static final S3HttpFixture s3Fixture = new S3HttpFixture( + true, + BUCKET, + BASE_PATH, + S3HttpFixture.fixedAccessKeyAndToken(ACCESS_KEY, SESSION_TOKEN) + ); + + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() + .module("repository-s3") + .keystore("s3.client." + CLIENT + ".access_key", ACCESS_KEY) + .keystore("s3.client." + CLIENT + ".secret_key", SECRET_KEY) + .keystore("s3.client." + CLIENT + ".session_token", SESSION_TOKEN) + .setting("s3.client." + CLIENT + ".endpoint", s3Fixture::getAddress) + .build(); + + @ClassRule + public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(cluster); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + @Override + protected String getBucketName() { + return BUCKET; + } + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Override + protected String getClientName() { + return CLIENT; + } +} diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsCredentialsRestIT.java similarity index 53% rename from modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java rename to modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsCredentialsRestIT.java index 7c4d719485113..de80e4179ef5e 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3StsCredentialsRestIT.java @@ -13,43 +13,53 @@ import fixture.s3.DynamicS3Credentials; import fixture.s3.S3HttpFixture; -import com.carrotsearch.randomizedtesting.annotations.Name; -import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.cluster.util.resource.Resource; -import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; +import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; import org.junit.ClassRule; import org.junit.rules.RuleChain; import org.junit.rules.TestRule; -public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { +@ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 +public class RepositoryS3StsCredentialsRestIT extends AbstractRepositoryS3RestTestCase { + + private static final String PREFIX = getIdentifierPrefix("RepositoryS3StsCredentialsRestIT"); + private static final String BUCKET = PREFIX + "bucket"; + private static final String BASE_PATH = PREFIX + "base_path"; + private static final String CLIENT = "sts_credentials_client"; private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); - private static final S3HttpFixture s3HttpFixture = new S3HttpFixture( - true, - "sts_bucket", - "sts_base_path", - dynamicS3Credentials::isAuthorized - ); + private static final S3HttpFixture s3HttpFixture = new S3HttpFixture(true, BUCKET, BASE_PATH, dynamicS3Credentials::isAuthorized); - private static final AwsStsHttpFixture stsHttpFixture = new AwsStsHttpFixture(dynamicS3Credentials::addValidCredentials, """ + private static final String WEB_IDENTITY_TOKEN_FILE_CONTENTS = """ Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDans\ FBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFO\ - zTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ"""); + zTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ"""; + + private static final AwsStsHttpFixture stsHttpFixture = new AwsStsHttpFixture( + dynamicS3Credentials::addValidCredentials, + WEB_IDENTITY_TOKEN_FILE_CONTENTS + ); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .setting("s3.client.integration_test_sts.endpoint", s3HttpFixture::getAddress) + .setting("s3.client." + CLIENT + ".endpoint", s3HttpFixture::getAddress) .systemProperty( "com.amazonaws.sdk.stsMetadataServiceEndpointOverride", () -> stsHttpFixture.getAddress() + "/assume-role-with-web-identity" ) - .configFile("repository-s3/aws-web-identity-token-file", Resource.fromClasspath("aws-web-identity-token-file")) - .environment("AWS_WEB_IDENTITY_TOKEN_FILE", System.getProperty("awsWebIdentityTokenExternalLocation")) - // // The AWS STS SDK requires the role and session names to be set. We can verify that they are sent to S3S in the - // // S3HttpFixtureWithSTS fixture + .configFile( + S3Service.CustomWebIdentityTokenCredentialsProvider.WEB_IDENTITY_TOKEN_FILE_LOCATION, + Resource.fromString(WEB_IDENTITY_TOKEN_FILE_CONTENTS) + ) + .environment("AWS_WEB_IDENTITY_TOKEN_FILE", S3Service.CustomWebIdentityTokenCredentialsProvider.WEB_IDENTITY_TOKEN_FILE_LOCATION) + // The AWS STS SDK requires the role and session names to be set. We can verify that they are sent to S3S in the + // S3HttpFixtureWithSTS fixture .environment("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/FederatedWebIdentityRole") .environment("AWS_ROLE_SESSION_NAME", "sts-fixture-test") .build(); @@ -57,17 +67,23 @@ public class RepositoryS3StsClientYamlTestSuiteIT extends AbstractRepositoryS3Cl @ClassRule public static TestRule ruleChain = RuleChain.outerRule(s3HttpFixture).around(stsHttpFixture).around(cluster); - @ParametersFactory - public static Iterable parameters() throws Exception { - return createParameters(new String[] { "repository_s3/60_repository_sts_credentials" }); + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + @Override + protected String getBucketName() { + return BUCKET; } - public RepositoryS3StsClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) { - super(testCandidate); + @Override + protected String getBasePath() { + return BASE_PATH; } @Override - protected String getTestRestCluster() { - return cluster.getHttpAddresses(); + protected String getClientName() { + return CLIENT; } } diff --git a/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobStore.java b/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobStore.java index 5fb3254df819b..d08bd40275fec 100644 --- a/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobStore.java +++ b/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobStore.java @@ -450,7 +450,7 @@ private static DeleteObjectsRequest bulkDelete(OperationPurpose purpose, S3BlobS @Override public void close() throws IOException { - this.service.close(); + service.onBlobStoreClose(); } @Override diff --git a/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3Service.java b/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3Service.java index 1ebd6f920d518..1a66f5782fc03 100644 --- a/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3Service.java +++ b/modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3Service.java @@ -303,6 +303,10 @@ private synchronized void releaseCachedClients() { IdleConnectionReaper.shutdown(); } + public void onBlobStoreClose() { + releaseCachedClients(); + } + @Override public void close() throws IOException { releaseCachedClients(); @@ -345,6 +349,8 @@ static class CustomWebIdentityTokenCredentialsProvider implements AWSCredentials private static final String STS_HOSTNAME = "https://sts.amazonaws.com"; + static final String WEB_IDENTITY_TOKEN_FILE_LOCATION = "repository-s3/aws-web-identity-token-file"; + private STSAssumeRoleWithWebIdentitySessionCredentialsProvider credentialsProvider; private AWSSecurityTokenService stsClient; private String stsRegion; @@ -363,7 +369,7 @@ static class CustomWebIdentityTokenCredentialsProvider implements AWSCredentials } // Make sure that a readable symlink to the token file exists in the plugin config directory // AWS_WEB_IDENTITY_TOKEN_FILE exists but we only use Web Identity Tokens if a corresponding symlink exists and is readable - Path webIdentityTokenFileSymlink = environment.configFile().resolve("repository-s3/aws-web-identity-token-file"); + Path webIdentityTokenFileSymlink = environment.configFile().resolve(WEB_IDENTITY_TOKEN_FILE_LOCATION); if (Files.exists(webIdentityTokenFileSymlink) == false) { LOGGER.warn( "Cannot use AWS Web Identity Tokens: AWS_WEB_IDENTITY_TOKEN_FILE is defined but no corresponding symlink exists " diff --git a/modules/repository-s3/src/test/resources/aws-web-identity-token-file b/modules/repository-s3/src/test/resources/aws-web-identity-token-file deleted file mode 100644 index 15cb29eac2ff6..0000000000000 --- a/modules/repository-s3/src/test/resources/aws-web-identity-token-file +++ /dev/null @@ -1 +0,0 @@ -Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDansFBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFOzTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java index a3b154b4bdfed..3d34934e54945 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ClientYamlTestSuiteIT.java @@ -9,8 +9,6 @@ package org.elasticsearch.repositories.s3; -import fixture.aws.imds.Ec2ImdsHttpFixture; -import fixture.s3.DynamicS3Credentials; import fixture.s3.S3HttpFixture; import com.carrotsearch.randomizedtesting.annotations.Name; @@ -18,7 +16,6 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; -import org.elasticsearch.cluster.routing.Murmur3HashFunction; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter; import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; @@ -26,67 +23,33 @@ import org.junit.rules.RuleChain; import org.junit.rules.TestRule; -import java.util.Set; - @ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) @ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482 public class RepositoryS3ClientYamlTestSuiteIT extends AbstractRepositoryS3ClientYamlTestSuiteIT { - private static final String HASHED_SEED = Integer.toString(Murmur3HashFunction.hash(System.getProperty("tests.seed"))); - private static final String TEMPORARY_SESSION_TOKEN = "session_token-" + HASHED_SEED; - - private static final S3HttpFixture s3Fixture = new S3HttpFixture(); - - private static final S3HttpFixture s3HttpFixtureWithSessionToken = new S3HttpFixture( - true, - "session_token_bucket", - "session_token_base_path_integration_tests", - S3HttpFixture.fixedAccessKeyAndToken(System.getProperty("s3TemporaryAccessKey"), TEMPORARY_SESSION_TOKEN) - ); - - private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials(); - - private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture( - dynamicS3Credentials::addValidCredentials, - Set.of() - ); + private static final String ACCESS_KEY = "RepositoryS3ClientYamlTestSuiteIT-access-key"; + private static final String SECRET_KEY = "RepositoryS3ClientYamlTestSuiteIT-secret-key"; - private static final S3HttpFixture s3HttpFixtureWithImdsSessionToken = new S3HttpFixture( + private static final S3HttpFixture s3Fixture = new S3HttpFixture( true, - "ec2_bucket", - "ec2_base_path", - dynamicS3Credentials::isAuthorized + "bucket", + "base_path_integration_tests", + S3HttpFixture.fixedAccessKey(ACCESS_KEY) ); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .keystore("s3.client.integration_test_permanent.access_key", System.getProperty("s3PermanentAccessKey")) - .keystore("s3.client.integration_test_permanent.secret_key", System.getProperty("s3PermanentSecretKey")) - .keystore("s3.client.integration_test_temporary.access_key", System.getProperty("s3TemporaryAccessKey")) - .keystore("s3.client.integration_test_temporary.secret_key", System.getProperty("s3TemporarySecretKey")) - .keystore("s3.client.integration_test_temporary.session_token", TEMPORARY_SESSION_TOKEN) + .keystore("s3.client.integration_test_permanent.access_key", ACCESS_KEY) + .keystore("s3.client.integration_test_permanent.secret_key", SECRET_KEY) .setting("s3.client.integration_test_permanent.endpoint", s3Fixture::getAddress) - .setting("s3.client.integration_test_temporary.endpoint", s3HttpFixtureWithSessionToken::getAddress) - .setting("s3.client.integration_test_ec2.endpoint", s3HttpFixtureWithImdsSessionToken::getAddress) - .systemProperty("com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", ec2ImdsHttpFixture::getAddress) .build(); @ClassRule - public static TestRule ruleChain = RuleChain.outerRule(s3Fixture) - .around(s3HttpFixtureWithSessionToken) - .around(s3HttpFixtureWithImdsSessionToken) - .around(ec2ImdsHttpFixture) - .around(cluster); + public static TestRule ruleChain = RuleChain.outerRule(s3Fixture).around(cluster); @ParametersFactory public static Iterable parameters() throws Exception { - return createParameters( - new String[] { - "repository_s3/10_basic", - "repository_s3/20_repository_permanent_credentials", - "repository_s3/30_repository_temporary_credentials", - "repository_s3/40_repository_ec2_credentials" } - ); + return createParameters(new String[] { "repository_s3/10_basic", "repository_s3/20_repository_permanent_credentials" }); } public RepositoryS3ClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) { diff --git a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RegionalStsClientYamlTestSuiteIT.java b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RegionalStsClientYamlTestSuiteIT.java index 2baba66a8a4d0..ac356083983eb 100644 --- a/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RegionalStsClientYamlTestSuiteIT.java +++ b/modules/repository-s3/src/yamlRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3RegionalStsClientYamlTestSuiteIT.java @@ -21,10 +21,11 @@ public class RepositoryS3RegionalStsClientYamlTestSuiteIT extends AbstractReposi @ClassRule public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .module("repository-s3") - .configFile("repository-s3/aws-web-identity-token-file", Resource.fromClasspath("aws-web-identity-token-file")) - .environment("AWS_WEB_IDENTITY_TOKEN_FILE", System.getProperty("awsWebIdentityTokenExternalLocation")) - // The AWS STS SDK requires the role and session names to be set. We can verify that they are sent to S3S in the - // S3HttpFixtureWithSTS fixture + .configFile(S3Service.CustomWebIdentityTokenCredentialsProvider.WEB_IDENTITY_TOKEN_FILE_LOCATION, Resource.fromString(""" + Atza|IQEBLjAsAhRFiXuWpUXuRvQ9PZL3GMFcYevydwIUFAHZwXZXXXXXXXXJnrulxKDHwy87oGKPznh0D6bEQZTSCzyoCtL_8S07pLpr0zMbn6w1lfVZKNTBdDans\ + FBmtGnIsIapjI6xKR02Yc_2bQ8LZbUXSGm6Ry6_BG7PrtLZtj_dfCTj92xNGed-CrKqjG7nPBjNIL016GGvuS5gSvPRUxWES3VYfm1wl7WTI7jn-Pcb6M-buCgHhFO\ + zTQxod27L9CqnOLio7N3gZAGpsp6n1-AJBOCJckcyXe2c6uD0srOJeZlKUm2eTDVMf8IehDVI0r1QOnTV6KzzAI3OY87Vd_cVMQ""")) + .environment("AWS_WEB_IDENTITY_TOKEN_FILE", S3Service.CustomWebIdentityTokenCredentialsProvider.WEB_IDENTITY_TOKEN_FILE_LOCATION) .environment("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/FederatedWebIdentityRole") .environment("AWS_ROLE_SESSION_NAME", "sts-fixture-test") .environment("AWS_STS_REGIONAL_ENDPOINTS", "regional") @@ -33,6 +34,9 @@ public class RepositoryS3RegionalStsClientYamlTestSuiteIT extends AbstractReposi @ParametersFactory public static Iterable parameters() throws Exception { + // Run just the basic sanity test to make sure ES starts up and loads the S3 repository with a regional endpoint without an error. + // It would be great to make actual requests against a test fixture, but setting the region means using a production endpoint. + // See #102230 for more details. return createParameters(new String[] { "repository_s3/10_basic" }); } diff --git a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/20_repository_permanent_credentials.yml b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/20_repository_permanent_credentials.yml index e88a0861ec01c..6f6fdaed8c666 100644 --- a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/20_repository_permanent_credentials.yml +++ b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/20_repository_permanent_credentials.yml @@ -10,12 +10,11 @@ setup: body: type: s3 settings: - bucket: @permanent_bucket@ + bucket: bucket client: integration_test_permanent - base_path: "@permanent_base_path@" + base_path: base_path_integration_tests canned_acl: private storage_class: standard - disable_chunked_encoding: @disable_chunked_encoding@ # Remove the snapshots, if a previous test failed to delete them. This is # useful for third party tests that runs the test against a real external service. @@ -40,9 +39,9 @@ setup: body: type: s3 settings: - bucket: @permanent_bucket@ + bucket: bucket client: integration_test_permanent - base_path: "@permanent_base_path@" + base_path: base_path_integration_tests endpoint: 127.0.0.1:5 canned_acl: private storage_class: standard @@ -55,9 +54,9 @@ setup: body: type: s3 settings: - bucket: @permanent_bucket@ + bucket: bucket client: integration_test_permanent - base_path: "@permanent_base_path@" + base_path: base_path_integration_tests endpoint: 127.0.0.1:5 canned_acl: private storage_class: standard @@ -106,258 +105,6 @@ setup: - match: { snapshot.include_global_state: true } - match: { snapshot.shards.failed: 0 } ---- -"Snapshot and Restore with repository-s3 using permanent credentials": - - # Get repository - - do: - snapshot.get_repository: - repository: repository_permanent - - - match: { repository_permanent.settings.bucket : @permanent_bucket@ } - - match: { repository_permanent.settings.client : "integration_test_permanent" } - - match: { repository_permanent.settings.base_path : "@permanent_base_path@" } - - match: { repository_permanent.settings.canned_acl : "private" } - - match: { repository_permanent.settings.storage_class : "standard" } - - is_false: repository_permanent.settings.access_key - - is_false: repository_permanent.settings.secret_key - - is_false: repository_permanent.settings.session_token - - # Index documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "1" - - snapshot: one - - index: - _index: docs - _id: "2" - - snapshot: one - - index: - _index: docs - _id: "3" - - snapshot: one - - - do: - count: - index: docs - - - match: {count: 3} - - # Create a first snapshot - - do: - snapshot.create: - repository: repository_permanent - snapshot: snapshot-one - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-one } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.include_global_state: true } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.status: - repository: repository_permanent - snapshot: snapshot-one - - - is_true: snapshots - - match: { snapshots.0.snapshot: snapshot-one } - - match: { snapshots.0.state : SUCCESS } - - # Index more documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "4" - - snapshot: two - - index: - _index: docs - _id: "5" - - snapshot: two - - index: - _index: docs - _id: "6" - - snapshot: two - - index: - _index: docs - _id: "7" - - snapshot: two - - - do: - count: - index: docs - - - match: {count: 7} - - # Create a second snapshot - - do: - snapshot.create: - repository: repository_permanent - snapshot: snapshot-two - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-two } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.get: - repository: repository_permanent - snapshot: snapshot-one,snapshot-two - - - is_true: snapshots - - match: { snapshots.0.state : SUCCESS } - - match: { snapshots.1.state : SUCCESS } - - # Delete the index - - do: - indices.delete: - index: docs - - # Restore the second snapshot - - do: - snapshot.restore: - repository: repository_permanent - snapshot: snapshot-two - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 7} - - # Delete the index again - - do: - indices.delete: - index: docs - - # Restore the first snapshot - - do: - snapshot.restore: - repository: repository_permanent - snapshot: snapshot-one - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 3} - - # Remove the snapshots - - do: - snapshot.delete: - repository: repository_permanent - snapshot: snapshot-two - - - do: - snapshot.delete: - repository: repository_permanent - snapshot: snapshot-one - ---- -"Register a repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_permanent - body: - type: s3 - settings: - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_permanent - ---- -"Register a repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_permanent - body: - type: s3 - settings: - bucket: repository_permanent - client: unknown - ---- -"Register a read-only repository with a non existing bucket": - -- do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_permanent - body: - type: s3 - settings: - readonly: true - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_permanent - ---- -"Register a read-only repository with a non existing client": - -- do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_permanent - body: - type: s3 - settings: - readonly: true - bucket: repository_permanent - client: unknown - ---- -"Get a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.get: - repository: repository_permanent - snapshot: missing - ---- -"Delete a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.delete: - repository: repository_permanent - snapshot: missing - ---- -"Restore a non existing snapshot": - - - do: - catch: /snapshot_restore_exception/ - snapshot.restore: - repository: repository_permanent - snapshot: missing - wait_for_completion: true - ---- -"Usage stats": - - requires: - cluster_features: - - repositories.supports_usage_stats - reason: requires this feature - - - do: - cluster.stats: {} - - - gte: { repositories.s3.count: 1 } - - gte: { repositories.s3.read_write: 1 } - --- teardown: diff --git a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/30_repository_temporary_credentials.yml b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/30_repository_temporary_credentials.yml deleted file mode 100644 index 501af980e17e3..0000000000000 --- a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/30_repository_temporary_credentials.yml +++ /dev/null @@ -1,278 +0,0 @@ -# Integration tests for repository-s3 - ---- -setup: - - # Register repository with temporary credentials - - do: - snapshot.create_repository: - repository: repository_temporary - body: - type: s3 - settings: - bucket: @temporary_bucket@ - client: integration_test_temporary - base_path: "@temporary_base_path@" - canned_acl: private - storage_class: standard - disable_chunked_encoding: @disable_chunked_encoding@ - ---- -"Snapshot and Restore with repository-s3 using temporary credentials": - - # Get repository - - do: - snapshot.get_repository: - repository: repository_temporary - - - match: { repository_temporary.settings.bucket : @temporary_bucket@ } - - match: { repository_temporary.settings.client : "integration_test_temporary" } - - match: { repository_temporary.settings.base_path : "@temporary_base_path@" } - - match: { repository_temporary.settings.canned_acl : "private" } - - match: { repository_temporary.settings.storage_class : "standard" } - - is_false: repository_temporary.settings.access_key - - is_false: repository_temporary.settings.secret_key - - is_false: repository_temporary.settings.session_token - - # Index documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "1" - - snapshot: one - - index: - _index: docs - _id: "2" - - snapshot: one - - index: - _index: docs - _id: "3" - - snapshot: one - - - do: - count: - index: docs - - - match: {count: 3} - - # Create a first snapshot - - do: - snapshot.create: - repository: repository_temporary - snapshot: snapshot-one - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-one } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.include_global_state: true } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.status: - repository: repository_temporary - snapshot: snapshot-one - - - is_true: snapshots - - match: { snapshots.0.snapshot: snapshot-one } - - match: { snapshots.0.state : SUCCESS } - - # Index more documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "4" - - snapshot: two - - index: - _index: docs - _id: "5" - - snapshot: two - - index: - _index: docs - _id: "6" - - snapshot: two - - index: - _index: docs - _id: "7" - - snapshot: two - - - do: - count: - index: docs - - - match: {count: 7} - - # Create a second snapshot - - do: - snapshot.create: - repository: repository_temporary - snapshot: snapshot-two - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-two } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.get: - repository: repository_temporary - snapshot: snapshot-one,snapshot-two - - - is_true: snapshots - - match: { snapshots.0.state : SUCCESS } - - match: { snapshots.1.state : SUCCESS } - - # Delete the index - - do: - indices.delete: - index: docs - - # Restore the second snapshot - - do: - snapshot.restore: - repository: repository_temporary - snapshot: snapshot-two - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 7} - - # Delete the index again - - do: - indices.delete: - index: docs - - # Restore the first snapshot - - do: - snapshot.restore: - repository: repository_temporary - snapshot: snapshot-one - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 3} - - # Remove the snapshots - - do: - snapshot.delete: - repository: repository_temporary - snapshot: snapshot-two - - - do: - snapshot.delete: - repository: repository_temporary - snapshot: snapshot-one - ---- -"Register a repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_temporary - body: - type: s3 - settings: - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_temporary - ---- -"Register a repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_temporary - body: - type: s3 - settings: - bucket: repository_temporary - client: unknown - ---- -"Register a read-only repository with a non existing bucket": - -- do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_temporary - body: - type: s3 - settings: - readonly: true - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_temporary - ---- -"Register a read-only repository with a non existing client": - -- do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_temporary - body: - type: s3 - settings: - readonly: true - bucket: repository_temporary - client: unknown - ---- -"Get a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.get: - repository: repository_temporary - snapshot: missing - ---- -"Delete a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.delete: - repository: repository_temporary - snapshot: missing - ---- -"Restore a non existing snapshot": - - - do: - catch: /snapshot_restore_exception/ - snapshot.restore: - repository: repository_temporary - snapshot: missing - wait_for_completion: true - ---- -"Usage stats": - - requires: - cluster_features: - - repositories.supports_usage_stats - reason: requires this feature - - - do: - cluster.stats: {} - - - gte: { repositories.s3.count: 1 } - - gte: { repositories.s3.read_write: 1 } - ---- -teardown: - - # Remove our repository - - do: - snapshot.delete_repository: - repository: repository_temporary diff --git a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/40_repository_ec2_credentials.yml b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/40_repository_ec2_credentials.yml deleted file mode 100644 index 129f0ba5d7588..0000000000000 --- a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/40_repository_ec2_credentials.yml +++ /dev/null @@ -1,278 +0,0 @@ -# Integration tests for repository-s3 - ---- -setup: - - # Register repository with ec2 credentials - - do: - snapshot.create_repository: - repository: repository_ec2 - body: - type: s3 - settings: - bucket: @ec2_bucket@ - client: integration_test_ec2 - base_path: "@ec2_base_path@" - canned_acl: private - storage_class: standard - disable_chunked_encoding: @disable_chunked_encoding@ - ---- -"Snapshot and Restore with repository-s3 using ec2 credentials": - - # Get repository - - do: - snapshot.get_repository: - repository: repository_ec2 - - - match: { repository_ec2.settings.bucket : @ec2_bucket@ } - - match: { repository_ec2.settings.client : "integration_test_ec2" } - - match: { repository_ec2.settings.base_path : "@ec2_base_path@" } - - match: { repository_ec2.settings.canned_acl : "private" } - - match: { repository_ec2.settings.storage_class : "standard" } - - is_false: repository_ec2.settings.access_key - - is_false: repository_ec2.settings.secret_key - - is_false: repository_ec2.settings.session_token - - # Index documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "1" - - snapshot: one - - index: - _index: docs - _id: "2" - - snapshot: one - - index: - _index: docs - _id: "3" - - snapshot: one - - - do: - count: - index: docs - - - match: {count: 3} - - # Create a first snapshot - - do: - snapshot.create: - repository: repository_ec2 - snapshot: snapshot-one - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-one } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.include_global_state: true } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.status: - repository: repository_ec2 - snapshot: snapshot-one - - - is_true: snapshots - - match: { snapshots.0.snapshot: snapshot-one } - - match: { snapshots.0.state : SUCCESS } - - # Index more documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "4" - - snapshot: two - - index: - _index: docs - _id: "5" - - snapshot: two - - index: - _index: docs - _id: "6" - - snapshot: two - - index: - _index: docs - _id: "7" - - snapshot: two - - - do: - count: - index: docs - - - match: {count: 7} - - # Create a second snapshot - - do: - snapshot.create: - repository: repository_ec2 - snapshot: snapshot-two - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-two } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.get: - repository: repository_ec2 - snapshot: snapshot-one,snapshot-two - - - is_true: snapshots - - match: { snapshots.0.state : SUCCESS } - - match: { snapshots.1.state : SUCCESS } - - # Delete the index - - do: - indices.delete: - index: docs - - # Restore the second snapshot - - do: - snapshot.restore: - repository: repository_ec2 - snapshot: snapshot-two - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 7} - - # Delete the index again - - do: - indices.delete: - index: docs - - # Restore the first snapshot - - do: - snapshot.restore: - repository: repository_ec2 - snapshot: snapshot-one - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 3} - - # Remove the snapshots - - do: - snapshot.delete: - repository: repository_ec2 - snapshot: snapshot-two - - - do: - snapshot.delete: - repository: repository_ec2 - snapshot: snapshot-one - ---- -"Register a repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_ec2 - body: - type: s3 - settings: - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_temporary - ---- -"Register a repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_ec2 - body: - type: s3 - settings: - bucket: repository_ec2 - client: unknown - ---- -"Register a read-only repository with a non existing bucket": - -- do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_ec2 - body: - type: s3 - settings: - readonly: true - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_temporary - ---- -"Register a read-only repository with a non existing client": - -- do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_ec2 - body: - type: s3 - settings: - readonly: true - bucket: repository_ec2 - client: unknown - ---- -"Get a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.get: - repository: repository_ec2 - snapshot: missing - ---- -"Delete a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.delete: - repository: repository_ec2 - snapshot: missing - ---- -"Restore a non existing snapshot": - - - do: - catch: /snapshot_restore_exception/ - snapshot.restore: - repository: repository_ec2 - snapshot: missing - wait_for_completion: true - ---- -"Usage stats": - - requires: - cluster_features: - - repositories.supports_usage_stats - reason: requires this feature - - - do: - cluster.stats: {} - - - gte: { repositories.s3.count: 1 } - - gte: { repositories.s3.read_write: 1 } - ---- -teardown: - - # Remove our repository - - do: - snapshot.delete_repository: - repository: repository_ec2 diff --git a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/50_repository_ecs_credentials.yml b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/50_repository_ecs_credentials.yml deleted file mode 100644 index de334b4b3df96..0000000000000 --- a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/50_repository_ecs_credentials.yml +++ /dev/null @@ -1,278 +0,0 @@ -# Integration tests for repository-s3 - ---- -setup: - - # Register repository with ecs credentials - - do: - snapshot.create_repository: - repository: repository_ecs - body: - type: s3 - settings: - bucket: @ecs_bucket@ - client: integration_test_ecs - base_path: "@ecs_base_path@" - canned_acl: private - storage_class: standard - disable_chunked_encoding: @disable_chunked_encoding@ - ---- -"Snapshot and Restore with repository-s3 using ecs credentials": - - # Get repository - - do: - snapshot.get_repository: - repository: repository_ecs - - - match: { repository_ecs.settings.bucket : @ecs_bucket@ } - - match: { repository_ecs.settings.client : "integration_test_ecs" } - - match: { repository_ecs.settings.base_path : "@ecs_base_path@" } - - match: { repository_ecs.settings.canned_acl : "private" } - - match: { repository_ecs.settings.storage_class : "standard" } - - is_false: repository_ecs.settings.access_key - - is_false: repository_ecs.settings.secret_key - - is_false: repository_ecs.settings.session_token - - # Index documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "1" - - snapshot: one - - index: - _index: docs - _id: "2" - - snapshot: one - - index: - _index: docs - _id: "3" - - snapshot: one - - - do: - count: - index: docs - - - match: {count: 3} - - # Create a first snapshot - - do: - snapshot.create: - repository: repository_ecs - snapshot: snapshot-one - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-one } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.include_global_state: true } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.status: - repository: repository_ecs - snapshot: snapshot-one - - - is_true: snapshots - - match: { snapshots.0.snapshot: snapshot-one } - - match: { snapshots.0.state : SUCCESS } - - # Index more documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: "4" - - snapshot: two - - index: - _index: docs - _id: "5" - - snapshot: two - - index: - _index: docs - _id: "6" - - snapshot: two - - index: - _index: docs - _id: "7" - - snapshot: two - - - do: - count: - index: docs - - - match: {count: 7} - - # Create a second snapshot - - do: - snapshot.create: - repository: repository_ecs - snapshot: snapshot-two - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-two } - - match: { snapshot.state : SUCCESS } - - match: { snapshot.shards.failed : 0 } - - - do: - snapshot.get: - repository: repository_ecs - snapshot: snapshot-one,snapshot-two - - - is_true: snapshots - - match: { snapshots.0.state : SUCCESS } - - match: { snapshots.1.state : SUCCESS } - - # Delete the index - - do: - indices.delete: - index: docs - - # Restore the second snapshot - - do: - snapshot.restore: - repository: repository_ecs - snapshot: snapshot-two - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 7} - - # Delete the index again - - do: - indices.delete: - index: docs - - # Restore the first snapshot - - do: - snapshot.restore: - repository: repository_ecs - snapshot: snapshot-one - wait_for_completion: true - - - do: - count: - index: docs - - - match: {count: 3} - - # Remove the snapshots - - do: - snapshot.delete: - repository: repository_ecs - snapshot: snapshot-two - - - do: - snapshot.delete: - repository: repository_ecs - snapshot: snapshot-one - ---- -"Register a repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_ecs - body: - type: s3 - settings: - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_ecs - ---- -"Register a repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_ecs - body: - type: s3 - settings: - bucket: repository_ecs - client: unknown - ---- -"Register a read-only repository with a non existing bucket": - -- do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_ecs - body: - type: s3 - settings: - readonly: true - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_ecs - ---- -"Register a read-only repository with a non existing client": - -- do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_ecs - body: - type: s3 - settings: - readonly: true - bucket: repository_ecs - client: unknown - ---- -"Get a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.get: - repository: repository_ecs - snapshot: missing - ---- -"Delete a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.delete: - repository: repository_ecs - snapshot: missing - ---- -"Restore a non existing snapshot": - - - do: - catch: /snapshot_restore_exception/ - snapshot.restore: - repository: repository_ecs - snapshot: missing - wait_for_completion: true - ---- -"Usage stats": - - requires: - cluster_features: - - repositories.supports_usage_stats - reason: requires this feature - - - do: - cluster.stats: {} - - - gte: { repositories.s3.count: 1 } - - gte: { repositories.s3.read_write: 1 } - ---- -teardown: - - # Remove our repository - - do: - snapshot.delete_repository: - repository: repository_ecs diff --git a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/60_repository_sts_credentials.yml b/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/60_repository_sts_credentials.yml deleted file mode 100644 index 09a8526017960..0000000000000 --- a/modules/repository-s3/src/yamlRestTest/resources/rest-api-spec/test/repository_s3/60_repository_sts_credentials.yml +++ /dev/null @@ -1,279 +0,0 @@ -# Integration tests for repository-s3 - ---- -setup: - - # Register repository with sts credentials - - do: - snapshot.create_repository: - repository: repository_sts - body: - type: s3 - settings: - bucket: @sts_bucket@ - client: integration_test_sts - base_path: "@sts_base_path@" - canned_acl: private - storage_class: standard - disable_chunked_encoding: @disable_chunked_encoding@ - ---- -"Snapshot and Restore repository-s3 using sts credentials": - - # Get repository - - do: - snapshot.get_repository: - repository: repository_sts - - - match: { repository_sts.settings.bucket: @sts_bucket@ } - - match: { repository_sts.settings.client: "integration_test_sts" } - - match: { repository_sts.settings.base_path: "@sts_base_path@" } - - match: { repository_sts.settings.canned_acl: "private" } - - match: { repository_sts.settings.storage_class: "standard" } - - is_false: repository_sts.settings.access_key - - is_false: repository_sts.settings.secret_key - - is_false: repository_sts.settings.session_token - - # Index documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: 1 - - snapshot: one - - index: - _index: docs - _id: 2 - - snapshot: one - - index: - _index: docs - _id: 3 - - snapshot: one - - - do: - count: - index: docs - - - match: { count: 3 } - - # Create a first snapshot - - do: - snapshot.create: - repository: repository_sts - snapshot: snapshot-one - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-one } - - match: { snapshot.state: SUCCESS } - - match: { snapshot.include_global_state: true } - - match: { snapshot.shards.failed: 0 } - - - do: - snapshot.status: - repository: repository_sts - snapshot: snapshot-one - - - is_true: snapshots - - match: { snapshots.0.snapshot: snapshot-one } - - match: { snapshots.0.state: SUCCESS } - - # Index more documents - - do: - bulk: - refresh: true - body: - - index: - _index: docs - _id: 4 - - snapshot: two - - index: - _index: docs - _id: 5 - - snapshot: two - - index: - _index: docs - _id: 6 - - snapshot: two - - index: - _index: docs - _id: 7 - - snapshot: two - - - do: - count: - index: docs - - - match: { count: 7 } - - # Create a second snapshot - - do: - snapshot.create: - repository: repository_sts - snapshot: snapshot-two - wait_for_completion: true - - - match: { snapshot.snapshot: snapshot-two } - - match: { snapshot.state: SUCCESS } - - match: { snapshot.shards.failed: 0 } - - - do: - snapshot.get: - repository: repository_sts - snapshot: snapshot-one,snapshot-two - - - is_true: snapshots - - match: { snapshots.0.state: SUCCESS } - - match: { snapshots.1.state: SUCCESS } - - # Delete the index - - do: - indices.delete: - index: docs - - # Restore the second snapshot - - do: - snapshot.restore: - repository: repository_sts - snapshot: snapshot-two - wait_for_completion: true - - - do: - count: - index: docs - - - match: { count: 7 } - - # Delete the index again - - do: - indices.delete: - index: docs - - # Restore the first snapshot - - do: - snapshot.restore: - repository: repository_sts - snapshot: snapshot-one - wait_for_completion: true - - - do: - count: - index: docs - - - match: { count: 3 } - - # Remove the snapshots - - do: - snapshot.delete: - repository: repository_sts - snapshot: snapshot-two - - - do: - snapshot.delete: - repository: repository_sts - snapshot: snapshot-one - ---- - -"Register a repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_sts - body: - type: s3 - settings: - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_sts - ---- -"Register a repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_sts - body: - type: s3 - settings: - bucket: repository_sts - client: unknown - ---- -"Register a read-only repository with a non existing bucket": - - - do: - catch: /repository_verification_exception/ - snapshot.create_repository: - repository: repository_sts - body: - type: s3 - settings: - readonly: true - bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE - client: integration_test_sts - ---- -"Register a read-only repository with a non existing client": - - - do: - catch: /illegal_argument_exception/ - snapshot.create_repository: - repository: repository_sts - body: - type: s3 - settings: - readonly: true - bucket: repository_sts - client: unknown - ---- -"Get a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.get: - repository: repository_sts - snapshot: missing - ---- -"Delete a non existing snapshot": - - - do: - catch: /snapshot_missing_exception/ - snapshot.delete: - repository: repository_sts - snapshot: missing - ---- -"Restore a non existing snapshot": - - - do: - catch: /snapshot_restore_exception/ - snapshot.restore: - repository: repository_sts - snapshot: missing - wait_for_completion: true - ---- -"Usage stats": - - requires: - cluster_features: - - repositories.supports_usage_stats - reason: requires this feature - - - do: - cluster.stats: {} - - - gte: { repositories.s3.count: 1 } - - gte: { repositories.s3.read_write: 1 } - ---- -teardown: - - # Remove our repository - - do: - snapshot.delete_repository: - repository: repository_sts diff --git a/test/fixtures/minio-fixture/src/main/java/org/elasticsearch/test/fixtures/minio/MinioTestContainer.java b/test/fixtures/minio-fixture/src/main/java/org/elasticsearch/test/fixtures/minio/MinioTestContainer.java index 285bbb91983cc..3ee18d71a5a79 100644 --- a/test/fixtures/minio-fixture/src/main/java/org/elasticsearch/test/fixtures/minio/MinioTestContainer.java +++ b/test/fixtures/minio-fixture/src/main/java/org/elasticsearch/test/fixtures/minio/MinioTestContainer.java @@ -18,17 +18,13 @@ public final class MinioTestContainer extends DockerEnvironmentAwareTestContaine public static final String DOCKER_BASE_IMAGE = "minio/minio:RELEASE.2021-03-01T04-20-55Z"; private final boolean enabled; - public MinioTestContainer() { - this(true); - } - - public MinioTestContainer(boolean enabled) { + public MinioTestContainer(boolean enabled, String accessKey, String secretKey, String bucketName) { super( new ImageFromDockerfile("es-minio-testfixture").withDockerfileFromBuilder( builder -> builder.from(DOCKER_BASE_IMAGE) - .env("MINIO_ACCESS_KEY", "s3_test_access_key") - .env("MINIO_SECRET_KEY", "s3_test_secret_key") - .run("mkdir -p /minio/data/bucket") + .env("MINIO_ACCESS_KEY", accessKey) + .env("MINIO_SECRET_KEY", secretKey) + .run("mkdir -p /minio/data/" + bucketName) .cmd("server", "/minio/data") .build() ) diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java index 36f8fedcb3335..ab70f043043cc 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpFixture.java @@ -33,10 +33,6 @@ public class S3HttpFixture extends ExternalResource { private final String basePath; private final BiPredicate authorizationPredicate; - public S3HttpFixture() { - this(true); - } - public S3HttpFixture(boolean enabled) { this(enabled, "bucket", "base_path_integration_tests", fixedAccessKey("s3_test_access_key")); } diff --git a/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java index 717cf96ad6a92..2dac2ee232aa5 100644 --- a/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java +++ b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java @@ -49,6 +49,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Properties; import java.util.Set; import java.util.UUID; @@ -473,6 +474,7 @@ private void createKeystore() { private void addKeystoreSettings() { spec.resolveKeystore().forEach((key, value) -> { + Objects.requireNonNull(value, "keystore setting for '" + key + "' may not be null"); String input = spec.getKeystorePassword() == null || spec.getKeystorePassword().isEmpty() ? value : spec.getKeystorePassword() + "\n" + value; diff --git a/x-pack/plugin/searchable-snapshots/qa/minio/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/minio/MinioSearchableSnapshotsIT.java b/x-pack/plugin/searchable-snapshots/qa/minio/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/minio/MinioSearchableSnapshotsIT.java index 5c2b19fe75a07..53f1a9a88e10e 100644 --- a/x-pack/plugin/searchable-snapshots/qa/minio/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/minio/MinioSearchableSnapshotsIT.java +++ b/x-pack/plugin/searchable-snapshots/qa/minio/src/javaRestTest/java/org/elasticsearch/xpack/searchablesnapshots/minio/MinioSearchableSnapshotsIT.java @@ -21,7 +21,12 @@ @ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) public class MinioSearchableSnapshotsIT extends AbstractSearchableSnapshotsRestTestCase { - public static final MinioTestContainer minioFixture = new MinioTestContainer(); + public static final MinioTestContainer minioFixture = new MinioTestContainer( + true, + "s3_test_access_key", + "s3_test_secret_key", + "bucket" + ); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .distribution(DistributionType.DEFAULT) diff --git a/x-pack/plugin/snapshot-repo-test-kit/qa/minio/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/MinioRepositoryAnalysisRestIT.java b/x-pack/plugin/snapshot-repo-test-kit/qa/minio/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/MinioRepositoryAnalysisRestIT.java index b0068bd7bfdaf..3b5edaf768057 100644 --- a/x-pack/plugin/snapshot-repo-test-kit/qa/minio/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/MinioRepositoryAnalysisRestIT.java +++ b/x-pack/plugin/snapshot-repo-test-kit/qa/minio/src/javaRestTest/java/org/elasticsearch/repositories/blobstore/testkit/analyze/MinioRepositoryAnalysisRestIT.java @@ -20,7 +20,12 @@ @ThreadLeakFilters(filters = { TestContainersThreadFilter.class }) public class MinioRepositoryAnalysisRestIT extends AbstractRepositoryAnalysisRestTestCase { - public static final MinioTestContainer minioFixture = new MinioTestContainer(); + public static final MinioTestContainer minioFixture = new MinioTestContainer( + true, + "s3_test_access_key", + "s3_test_secret_key", + "bucket" + ); public static ElasticsearchCluster cluster = ElasticsearchCluster.local() .distribution(DistributionType.DEFAULT) From d729558529cafc80d705296328140b45830aa974 Mon Sep 17 00:00:00 2001 From: Jan Kuipers <148754765+jan-elastic@users.noreply.github.com> Date: Fri, 29 Nov 2024 11:00:54 +0100 Subject: [PATCH 111/129] Correct categorization analyzer in ES|QL categorize (#117695) * Correct categorization analyzer in ES|QL categorize * close categorizer if constructing analyzer fails * Rename capability CATEGORIZE_V4 * add comments --- x-pack/plugin/esql/compute/build.gradle | 4 +- .../compute/src/main/java/module-info.java | 1 + .../aggregation/blockhash/BlockHash.java | 10 +- .../blockhash/CategorizeRawBlockHash.java | 34 ++--- .../operator/HashAggregationOperator.java | 6 +- .../GroupingAggregatorFunctionTestCase.java | 4 +- .../blockhash/CategorizeBlockHashTests.java | 76 +++++++---- .../HashAggregationOperatorTests.java | 3 +- .../src/main/resources/categorize.csv-spec | 123 ++++++++++-------- .../xpack/esql/action/EsqlCapabilities.java | 2 +- .../AbstractPhysicalOperationProviders.java | 9 +- .../planner/EsPhysicalOperationProviders.java | 4 +- .../xpack/esql/plugin/ComputeService.java | 2 +- .../xpack/esql/analysis/VerifierTests.java | 6 +- .../optimizer/LogicalPlanOptimizerTests.java | 4 +- .../planner/LocalExecutionPlannerTests.java | 4 +- .../TestPhysicalOperationProviders.java | 20 ++- 17 files changed, 199 insertions(+), 113 deletions(-) diff --git a/x-pack/plugin/esql/compute/build.gradle b/x-pack/plugin/esql/compute/build.gradle index 609c778df5929..8e866cec3f421 100644 --- a/x-pack/plugin/esql/compute/build.gradle +++ b/x-pack/plugin/esql/compute/build.gradle @@ -11,11 +11,13 @@ base { dependencies { compileOnly project(':server') compileOnly project('ann') + compileOnly project(xpackModule('core')) compileOnly project(xpackModule('ml')) annotationProcessor project('gen') implementation 'com.carrotsearch:hppc:0.8.1' - testImplementation project(':test:framework') + testImplementation(project(':modules:analysis-common')) + testImplementation(project(':test:framework')) testImplementation(project(xpackModule('esql-core'))) testImplementation(project(xpackModule('core'))) testImplementation(project(xpackModule('ml'))) diff --git a/x-pack/plugin/esql/compute/src/main/java/module-info.java b/x-pack/plugin/esql/compute/src/main/java/module-info.java index 573d9e048a4d4..1b3253694b298 100644 --- a/x-pack/plugin/esql/compute/src/main/java/module-info.java +++ b/x-pack/plugin/esql/compute/src/main/java/module-info.java @@ -19,6 +19,7 @@ requires org.elasticsearch.ml; requires org.elasticsearch.tdigest; requires org.elasticsearch.geo; + requires org.elasticsearch.xcore; requires hppc; exports org.elasticsearch.compute; 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 ef0f3ceb112c4..ea76c3bd0a0aa 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 @@ -25,6 +25,7 @@ import org.elasticsearch.compute.data.Page; import org.elasticsearch.core.Releasable; import org.elasticsearch.core.ReleasableIterator; +import org.elasticsearch.index.analysis.AnalysisRegistry; import java.util.Iterator; import java.util.List; @@ -169,14 +170,19 @@ public static BlockHash buildPackedValuesBlockHash(List groups, Block /** * Builds a BlockHash for the Categorize grouping function. */ - public static BlockHash buildCategorizeBlockHash(List groups, AggregatorMode aggregatorMode, BlockFactory blockFactory) { + public static BlockHash buildCategorizeBlockHash( + List groups, + AggregatorMode aggregatorMode, + BlockFactory blockFactory, + AnalysisRegistry analysisRegistry + ) { 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()); + : new CategorizeRawBlockHash(groups.get(0).channel, blockFactory, aggregatorMode.isOutputPartial(), analysisRegistry); } /** 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 index 0d0a2fef2f82b..47dd7f650dffa 100644 --- 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 @@ -7,7 +7,6 @@ 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; @@ -19,13 +18,14 @@ 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.index.analysis.AnalysisRegistry; +import org.elasticsearch.xpack.core.ml.job.config.CategorizationAnalyzerConfig; import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer; import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer; +import java.io.IOException; +import java.util.List; + /** * BlockHash implementation for {@code Categorize} grouping function. *

@@ -33,19 +33,23 @@ *

*/ public class CategorizeRawBlockHash extends AbstractCategorizeBlockHash { + private static final CategorizationAnalyzerConfig ANALYZER_CONFIG = CategorizationAnalyzerConfig.buildStandardCategorizationAnalyzer( + List.of() + ); + private final CategorizeEvaluator evaluator; - CategorizeRawBlockHash(int channel, BlockFactory blockFactory, boolean outputPartial) { + CategorizeRawBlockHash(int channel, BlockFactory blockFactory, boolean outputPartial, AnalysisRegistry analysisRegistry) { 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 - ); + + CategorizationAnalyzer analyzer; + try { + analyzer = new CategorizationAnalyzer(analysisRegistry, ANALYZER_CONFIG); + } catch (IOException e) { + categorizer.close(); + throw new RuntimeException(e); + } + this.evaluator = new CategorizeEvaluator(analyzer, categorizer, blockFactory); } 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 a69e8ca767014..6f8386ec08de1 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 @@ -24,6 +24,7 @@ import org.elasticsearch.compute.data.Page; import org.elasticsearch.core.Releasables; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.index.analysis.AnalysisRegistry; import org.elasticsearch.xcontent.XContentBuilder; import java.io.IOException; @@ -42,14 +43,15 @@ public record HashAggregationOperatorFactory( List groups, AggregatorMode aggregatorMode, List aggregators, - int maxPageSize + int maxPageSize, + AnalysisRegistry analysisRegistry ) 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()), + () -> BlockHash.buildCategorizeBlockHash(groups, aggregatorMode, driverContext.blockFactory(), analysisRegistry), driverContext ); } 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 1e97bdf5a2e79..58925a5ca36fc 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 @@ -54,7 +54,6 @@ import static org.elasticsearch.compute.data.BlockTestUtils.append; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.in; /** * Shared tests for testing grouped aggregations. @@ -107,7 +106,8 @@ private Operator.OperatorFactory simpleWithMode( List.of(new BlockHash.GroupSpec(0, ElementType.LONG)), mode, List.of(supplier.groupingAggregatorFactory(mode)), - randomPageSize() + randomPageSize(), + null ); } 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 index dd7a87dc4a574..8a3c723557151 100644 --- 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 @@ -8,8 +8,10 @@ package org.elasticsearch.compute.aggregation.blockhash; import org.apache.lucene.util.BytesRef; +import org.elasticsearch.analysis.common.CommonAnalysisPlugin; import org.elasticsearch.common.breaker.CircuitBreaker; import org.elasticsearch.common.collect.Iterators; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.ByteSizeValue; import org.elasticsearch.common.util.BigArrays; import org.elasticsearch.common.util.MockBigArrays; @@ -35,7 +37,15 @@ import org.elasticsearch.compute.operator.LocalSourceOperator; import org.elasticsearch.compute.operator.PageConsumerOperator; import org.elasticsearch.core.Releasables; - +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.index.analysis.AnalysisRegistry; +import org.elasticsearch.indices.analysis.AnalysisModule; +import org.elasticsearch.plugins.scanners.StablePluginsRegistry; +import org.elasticsearch.xpack.ml.MachineLearning; +import org.junit.Before; + +import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -50,6 +60,19 @@ public class CategorizeBlockHashTests extends BlockHashTestCase { + private AnalysisRegistry analysisRegistry; + + @Before + private void initAnalysisRegistry() throws IOException { + analysisRegistry = new AnalysisModule( + TestEnvironment.newEnvironment( + Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build() + ), + List.of(new MachineLearning(Settings.EMPTY), new CommonAnalysisPlugin()), + new StablePluginsRegistry() + ).getAnalysisRegistry(); + } + public void testCategorizeRaw() { final Page page; boolean withNull = randomBoolean(); @@ -72,7 +95,7 @@ public void testCategorizeRaw() { page = new Page(builder.build()); } - try (BlockHash hash = new CategorizeRawBlockHash(0, blockFactory, true)) { + try (BlockHash hash = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry)) { hash.add(page, new GroupingAggregatorFunction.AddInput() { @Override public void add(int positionOffset, IntBlock groupIds) { @@ -145,8 +168,8 @@ public void testCategorizeIntermediate() { // 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) + BlockHash rawHash1 = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry); + BlockHash rawHash2 = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry); ) { rawHash1.add(page1, new GroupingAggregatorFunction.AddInput() { @Override @@ -267,14 +290,16 @@ public void testCategorize_withDriver() { BytesRefVector.Builder textsBuilder = driverContext.blockFactory().newBytesRefVectorBuilder(10); LongVector.Builder countsBuilder = driverContext.blockFactory().newLongVectorBuilder(10) ) { - textsBuilder.appendBytesRef(new BytesRef("a")); - textsBuilder.appendBytesRef(new BytesRef("b")); + // Note that just using "a" or "aaa" doesn't work, because the ml_standard + // tokenizer drops numbers, including hexadecimal ones. + textsBuilder.appendBytesRef(new BytesRef("aaazz")); + textsBuilder.appendBytesRef(new BytesRef("bbbzz")); 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")); + textsBuilder.appendBytesRef(new BytesRef("ccczz")); + textsBuilder.appendBytesRef(new BytesRef("dddzz")); countsBuilder.appendLong(1); countsBuilder.appendLong(2); countsBuilder.appendLong(800); @@ -293,10 +318,10 @@ public void testCategorize_withDriver() { ) { 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("ccczz")); textsBuilder.appendBytesRef(new BytesRef("words words words goodbye chris")); - textsBuilder.appendBytesRef(new BytesRef("d")); - textsBuilder.appendBytesRef(new BytesRef("e")); + textsBuilder.appendBytesRef(new BytesRef("dddzz")); + textsBuilder.appendBytesRef(new BytesRef("eeezz")); countsBuilder.appendLong(9); countsBuilder.appendLong(90); countsBuilder.appendLong(3); @@ -320,7 +345,8 @@ public void testCategorize_withDriver() { new SumLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL), new MaxLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL) ), - 16 * 1024 + 16 * 1024, + analysisRegistry ).get(driverContext) ), new PageConsumerOperator(intermediateOutput::add), @@ -339,7 +365,8 @@ public void testCategorize_withDriver() { new SumLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL), new MaxLongAggregatorFunctionSupplier(List.of(1)).groupingAggregatorFactory(AggregatorMode.INITIAL) ), - 16 * 1024 + 16 * 1024, + analysisRegistry ).get(driverContext) ), new PageConsumerOperator(intermediateOutput::add), @@ -360,7 +387,8 @@ public void testCategorize_withDriver() { new SumLongAggregatorFunctionSupplier(List.of(1, 2)).groupingAggregatorFactory(AggregatorMode.FINAL), new MaxLongAggregatorFunctionSupplier(List.of(3, 4)).groupingAggregatorFactory(AggregatorMode.FINAL) ), - 16 * 1024 + 16 * 1024, + analysisRegistry ).get(driverContext) ), new PageConsumerOperator(finalOutput::add), @@ -385,15 +413,15 @@ public void testCategorize_withDriver() { sums, equalTo( Map.of( - ".*?a.*?", + ".*?aaazz.*?", 1L, - ".*?b.*?", + ".*?bbbzz.*?", 2L, - ".*?c.*?", + ".*?ccczz.*?", 33L, - ".*?d.*?", + ".*?dddzz.*?", 44L, - ".*?e.*?", + ".*?eeezz.*?", 5L, ".*?words.+?words.+?words.+?goodbye.*?", 8888L, @@ -406,15 +434,15 @@ public void testCategorize_withDriver() { maxs, equalTo( Map.of( - ".*?a.*?", + ".*?aaazz.*?", 1L, - ".*?b.*?", + ".*?bbbzz.*?", 2L, - ".*?c.*?", + ".*?ccczz.*?", 30L, - ".*?d.*?", + ".*?dddzz.*?", 40L, - ".*?e.*?", + ".*?eeezz.*?", 5L, ".*?words.+?words.+?words.+?goodbye.*?", 8000L, 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 b2f4ad594936e..953c7d1c313f1 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 @@ -59,7 +59,8 @@ protected Operator.OperatorFactory simpleWithMode(AggregatorMode mode) { new SumLongAggregatorFunctionSupplier(sumChannels).groupingAggregatorFactory(mode), new MaxLongAggregatorFunctionSupplier(maxChannels).groupingAggregatorFactory(mode) ), - randomPageSize() + randomPageSize(), + null ); } 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 547c430ed7518..e45b10d1aa122 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,5 +1,5 @@ standard aggs -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS count=COUNT(), @@ -17,7 +17,7 @@ count:long | sum:long | avg:double | count_distinct:long | category:keyw ; values aggs -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS values=MV_SORT(VALUES(message)), @@ -33,7 +33,7 @@ values:keyword | top ; mv -required_capability: categorize_v3 +required_capability: categorize_v4 FROM mv_sample_data | STATS COUNT(), SUM(event_duration) BY category=CATEGORIZE(message) @@ -48,7 +48,7 @@ COUNT():long | SUM(event_duration):long | category:keyword ; row mv -required_capability: categorize_v3 +required_capability: categorize_v4 ROW message = ["connected to a", "connected to b", "disconnected"], str = ["a", "b", "c"] | STATS COUNT(), VALUES(str) BY category=CATEGORIZE(message) @@ -60,8 +60,20 @@ COUNT():long | VALUES(str):keyword | category:keyword 1 | [a, b, c] | .*?disconnected.*? ; +skips stopwords +required_capability: categorize_v4 + +ROW message = ["Mon Tue connected to a", "Jul Aug connected to b September ", "UTC connected GMT to c UTC"] + | STATS COUNT() BY category=CATEGORIZE(message) + | SORT category +; + +COUNT():long | category:keyword + 3 | .*?connected.+?to.*? +; + with multiple indices -required_capability: categorize_v3 +required_capability: categorize_v4 required_capability: union_types FROM sample_data* @@ -76,7 +88,7 @@ COUNT():long | category:keyword ; mv with many values -required_capability: categorize_v3 +required_capability: categorize_v4 FROM employees | STATS COUNT() BY category=CATEGORIZE(job_positions) @@ -93,7 +105,7 @@ COUNT():long | category:keyword ; mv with many values and SUM -required_capability: categorize_v3 +required_capability: categorize_v4 FROM employees | STATS SUM(languages) BY category=CATEGORIZE(job_positions) @@ -108,7 +120,7 @@ SUM(languages):long | category:keyword ; mv with many values and nulls and SUM -required_capability: categorize_v3 +required_capability: categorize_v4 FROM employees | STATS SUM(languages) BY category=CATEGORIZE(job_positions) @@ -122,7 +134,7 @@ SUM(languages):long | category:keyword ; mv via eval -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL message = MV_APPEND(message, "Banana") @@ -138,7 +150,7 @@ COUNT():long | category:keyword ; mv via eval const -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL message = ["Banana", "Bread"] @@ -152,7 +164,7 @@ COUNT():long | category:keyword ; mv via eval const without aliases -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL message = ["Banana", "Bread"] @@ -166,7 +178,7 @@ COUNT():long | CATEGORIZE(message):keyword ; mv const in parameter -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY c = CATEGORIZE(["Banana", "Bread"]) @@ -179,7 +191,7 @@ COUNT():long | c:keyword ; agg alias shadowing -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS c = COUNT() BY c = CATEGORIZE(["Banana", "Bread"]) @@ -194,7 +206,7 @@ c:keyword ; chained aggregations using categorize -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -203,13 +215,13 @@ FROM sample_data ; COUNT():long | category:keyword - 1 | .*?\.\*\?Connected\.\+\?to\.\*\?.*? - 1 | .*?\.\*\?Connection\.\+\?error\.\*\?.*? - 1 | .*?\.\*\?Disconnected\.\*\?.*? + 1 | .*?Connected.+?to.*? + 1 | .*?Connection.+?error.*? + 1 | .*?Disconnected.*? ; stats without aggs -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS BY category=CATEGORIZE(message) @@ -223,7 +235,7 @@ category:keyword ; text field -required_capability: categorize_v3 +required_capability: categorize_v4 FROM hosts | STATS COUNT() BY category=CATEGORIZE(host_group) @@ -231,14 +243,17 @@ FROM hosts ; COUNT():long | category:keyword - 2 | .*?DB.+?servers.*? 2 | .*?Gateway.+?instances.*? 5 | .*?Kubernetes.+?cluster.*? + 2 | .*?servers.*? 1 | null + +// Note: DB is removed from "DB servers", because the ml_standard +// tokenizer drops numbers, including hexadecimal ones. ; on TO_UPPER -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(TO_UPPER(message)) @@ -252,7 +267,7 @@ COUNT():long | category:keyword ; on CONCAT -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " banana")) @@ -266,7 +281,7 @@ COUNT():long | category:keyword ; on CONCAT with unicode -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(CONCAT(message, " 👍🏽😊")) @@ -274,13 +289,13 @@ FROM sample_data ; COUNT():long | category:keyword - 3 | .*?Connected.+?to.+?👍🏽😊.*? - 3 | .*?Connection.+?error.+?👍🏽😊.*? - 1 | .*?Disconnected.+?👍🏽😊.*? + 3 | .*?Connected.+?to.*? + 3 | .*?Connection.+?error.*? + 1 | .*?Disconnected.*? ; on REVERSE(CONCAT()) -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(REVERSE(CONCAT(message, " 👍🏽😊"))) @@ -288,13 +303,13 @@ FROM sample_data ; COUNT():long | category:keyword - 1 | .*?😊👍🏽.+?detcennocsiD.*? - 3 | .*?😊👍🏽.+?ot.+?detcennoC.*? - 3 | .*?😊👍🏽.+?rorre.+?noitcennoC.*? + 1 | .*?detcennocsiD.*? + 3 | .*?ot.+?detcennoC.*? + 3 | .*?rorre.+?noitcennoC.*? ; and then TO_LOWER -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -309,7 +324,7 @@ COUNT():long | category:keyword ; on const empty string -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE("") @@ -321,7 +336,7 @@ COUNT():long | category:keyword ; on const empty string from eval -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL x = "" @@ -334,7 +349,7 @@ COUNT():long | category:keyword ; on null -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL x = null @@ -347,7 +362,7 @@ COUNT():long | SUM(event_duration):long | category:keyword ; on null string -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL x = null::string @@ -360,7 +375,7 @@ COUNT():long | category:keyword ; filtering out all data -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | WHERE @timestamp < "2023-10-23T00:00:00Z" @@ -372,7 +387,7 @@ COUNT():long | category:keyword ; filtering out all data with constant -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS COUNT() BY category=CATEGORIZE(message) @@ -383,7 +398,7 @@ COUNT():long | category:keyword ; drop output columns -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS count=COUNT() BY category=CATEGORIZE(message) @@ -398,7 +413,7 @@ x:integer ; category value processing -required_capability: categorize_v3 +required_capability: categorize_v4 ROW message = ["connected to a", "connected to b", "disconnected"] | STATS COUNT() BY category=CATEGORIZE(message) @@ -412,21 +427,21 @@ COUNT():long | category:keyword ; row aliases -required_capability: categorize_v3 +required_capability: categorize_v4 -ROW message = "connected to a" +ROW message = "connected to xyz" | 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.*? +COUNT():long | category:keyword | y:keyword + 1 | .*?connected.+?to.+?xyz.*? | .*?connected.+?to.+?xyz.*? ; from aliases -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL x = message @@ -442,9 +457,9 @@ COUNT():long | category:keyword | y:keyword ; row aliases with keep -required_capability: categorize_v3 +required_capability: categorize_v4 -ROW message = "connected to a" +ROW message = "connected to xyz" | EVAL x = message | KEEP x | STATS COUNT() BY category=CATEGORIZE(x) @@ -454,11 +469,11 @@ ROW message = "connected to a" ; COUNT():long | y:keyword - 1 | .*?connected.+?to.+?a.*? + 1 | .*?connected.+?to.+?xyz.*? ; from aliases with keep -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | EVAL x = message @@ -476,9 +491,9 @@ COUNT():long | y:keyword ; row rename -required_capability: categorize_v3 +required_capability: categorize_v4 -ROW message = "connected to a" +ROW message = "connected to xyz" | RENAME message as x | STATS COUNT() BY category=CATEGORIZE(x) | RENAME category as y @@ -486,11 +501,11 @@ ROW message = "connected to a" ; COUNT():long | y:keyword - 1 | .*?connected.+?to.+?a.*? + 1 | .*?connected.+?to.+?xyz.*? ; from rename -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | RENAME message as x @@ -506,7 +521,7 @@ COUNT():long | y:keyword ; row drop -required_capability: categorize_v3 +required_capability: categorize_v4 ROW message = "connected to a" | STATS c = COUNT() BY category=CATEGORIZE(message) @@ -519,7 +534,7 @@ c:long ; from drop -required_capability: categorize_v3 +required_capability: categorize_v4 FROM sample_data | STATS c = COUNT() BY category=CATEGORIZE(message) 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 77a3e2840977f..373be23cdf847 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 @@ -403,7 +403,7 @@ public enum Cap { /** * Supported the text categorization function "CATEGORIZE". */ - CATEGORIZE_V3(Build.current().isSnapshot()), + CATEGORIZE_V4(Build.current().isSnapshot()), /** * QSTR function 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 a7418654f6b0e..69e2d1c45aa3c 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 @@ -18,6 +18,7 @@ import org.elasticsearch.compute.operator.EvalOperator; import org.elasticsearch.compute.operator.HashAggregationOperator.HashAggregationOperatorFactory; import org.elasticsearch.compute.operator.Operator; +import org.elasticsearch.index.analysis.AnalysisRegistry; import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException; import org.elasticsearch.xpack.esql.core.InvalidArgumentException; import org.elasticsearch.xpack.esql.core.expression.Alias; @@ -46,6 +47,11 @@ public abstract class AbstractPhysicalOperationProviders implements PhysicalOperationProviders { private final AggregateMapper aggregateMapper = new AggregateMapper(); + private final AnalysisRegistry analysisRegistry; + + AbstractPhysicalOperationProviders(AnalysisRegistry analysisRegistry) { + this.analysisRegistry = analysisRegistry; + } @Override public final PhysicalOperation groupingPhysicalOperation( @@ -173,7 +179,8 @@ else if (aggregatorMode.isOutputPartial()) { groupSpecs.stream().map(GroupSpec::toHashGroupSpec).toList(), aggregatorMode, aggregatorFactories, - context.pageSize(aggregateExec.estimatedRowSize()) + context.pageSize(aggregateExec.estimatedRowSize()), + analysisRegistry ); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java index 15f5b6579098d..7bf7d0e2d08eb 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java @@ -34,6 +34,7 @@ import org.elasticsearch.compute.operator.SourceOperator; import org.elasticsearch.index.IndexMode; import org.elasticsearch.index.IndexSettings; +import org.elasticsearch.index.analysis.AnalysisRegistry; import org.elasticsearch.index.mapper.BlockLoader; import org.elasticsearch.index.mapper.FieldNamesFieldMapper; import org.elasticsearch.index.mapper.MappedFieldType; @@ -98,7 +99,8 @@ public interface ShardContext extends org.elasticsearch.compute.lucene.ShardCont private final List shardContexts; - public EsPhysicalOperationProviders(List shardContexts) { + public EsPhysicalOperationProviders(List shardContexts, AnalysisRegistry analysisRegistry) { + super(analysisRegistry); this.shardContexts = shardContexts; } 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 73266551f169c..b06dd3cdb64d3 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 @@ -452,7 +452,7 @@ void runCompute(CancellableTask task, ComputeContext context, PhysicalPlan plan, context.exchangeSink(), enrichLookupService, lookupFromIndexService, - new EsPhysicalOperationProviders(contexts) + new EsPhysicalOperationProviders(contexts, searchService.getIndicesService().getAnalysis()) ); LOGGER.debug("Received physical plan:\n{}", plan); 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 dd14e8dd82123..d4fca2a0a2540 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 @@ -1846,7 +1846,7 @@ public void testIntervalAsString() { } public void testCategorizeSingleGrouping() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V4.isEnabled()); query("from test | STATS COUNT(*) BY CATEGORIZE(first_name)"); query("from test | STATS COUNT(*) BY cat = CATEGORIZE(first_name)"); @@ -1875,7 +1875,7 @@ public void testCategorizeSingleGrouping() { } public void testCategorizeNestedGrouping() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V4.isEnabled()); query("from test | STATS COUNT(*) BY CATEGORIZE(LENGTH(first_name)::string)"); @@ -1890,7 +1890,7 @@ public void testCategorizeNestedGrouping() { } public void testCategorizeWithinAggregations() { - assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V3.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V4.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/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java index e98f2b88b33c9..57d0c7432f97b 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 @@ -1212,7 +1212,7 @@ 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_V3.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V4.isEnabled()); var plan = plan(""" from test @@ -3949,7 +3949,7 @@ 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_V3.isEnabled()); + assumeTrue("requires Categorize capability", EsqlCapabilities.Cap.CATEGORIZE_V4.isEnabled()); var plan = optimizedPlan(""" from test diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java index ff9e45a9f9233..5d8da21c6faad 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java @@ -156,7 +156,7 @@ private Configuration config() { randomZone(), randomLocale(random()), "test_user", - "test_cluser", + "test_cluster", pragmas, EsqlPlugin.QUERY_RESULT_TRUNCATION_MAX_SIZE.getDefault(null), EsqlPlugin.QUERY_RESULT_TRUNCATION_DEFAULT_SIZE.getDefault(null), @@ -187,7 +187,7 @@ private EsPhysicalOperationProviders esPhysicalOperationProviders() throws IOExc ); } releasables.add(searcher); - return new EsPhysicalOperationProviders(shardContexts); + return new EsPhysicalOperationProviders(shardContexts, null); } private IndexReader reader() { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/TestPhysicalOperationProviders.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/TestPhysicalOperationProviders.java index c811643c8daea..e91fc6e49312d 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/TestPhysicalOperationProviders.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/TestPhysicalOperationProviders.java @@ -8,7 +8,9 @@ package org.elasticsearch.xpack.esql.planner; import org.apache.lucene.util.BytesRef; +import org.elasticsearch.analysis.common.CommonAnalysisPlugin; import org.elasticsearch.common.Randomness; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.BigArrays; import org.elasticsearch.compute.Describable; import org.elasticsearch.compute.aggregation.GroupingAggregator; @@ -28,7 +30,11 @@ import org.elasticsearch.compute.operator.OrdinalsGroupingOperator; import org.elasticsearch.compute.operator.SourceOperator; import org.elasticsearch.compute.operator.SourceOperator.SourceOperatorFactory; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; import org.elasticsearch.index.mapper.MappedFieldType; +import org.elasticsearch.indices.analysis.AnalysisModule; +import org.elasticsearch.plugins.scanners.StablePluginsRegistry; import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException; import org.elasticsearch.xpack.esql.TestBlockFactory; import org.elasticsearch.xpack.esql.core.expression.Attribute; @@ -39,7 +45,9 @@ import org.elasticsearch.xpack.esql.plan.physical.FieldExtractExec; import org.elasticsearch.xpack.esql.planner.LocalExecutionPlanner.LocalExecutionPlannerContext; import org.elasticsearch.xpack.esql.planner.LocalExecutionPlanner.PhysicalOperation; +import org.elasticsearch.xpack.ml.MachineLearning; +import java.io.IOException; import java.util.List; import java.util.Random; import java.util.function.Function; @@ -48,6 +56,7 @@ import static com.carrotsearch.randomizedtesting.generators.RandomNumbers.randomIntBetween; import static java.util.stream.Collectors.joining; +import static org.apache.lucene.tests.util.LuceneTestCase.createTempDir; import static org.elasticsearch.index.mapper.MappedFieldType.FieldExtractPreference.DOC_VALUES; import static org.elasticsearch.index.mapper.MappedFieldType.FieldExtractPreference.NONE; @@ -56,7 +65,16 @@ public class TestPhysicalOperationProviders extends AbstractPhysicalOperationPro private final Page testData; private final List columnNames; - public TestPhysicalOperationProviders(Page testData, List columnNames) { + public TestPhysicalOperationProviders(Page testData, List columnNames) throws IOException { + super( + new AnalysisModule( + TestEnvironment.newEnvironment( + Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build() + ), + List.of(new MachineLearning(Settings.EMPTY), new CommonAnalysisPlugin()), + new StablePluginsRegistry() + ).getAnalysisRegistry() + ); this.testData = testData; this.columnNames = columnNames; } From 2226d6cbfa434206826207da46e95969fc77776c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BCscher?= Date: Fri, 29 Nov 2024 11:24:28 +0100 Subject: [PATCH 112/129] Add _field_names disabling to archival index tests (#117703) Disabling the "_field_names" field in mappings was possible until 8.x and now issues a deprecation warning. We need to maintain the ability to read these mappings for archival indices so this change adds this case to one of the index mappings in tests and checks for the deprecation warning for it. --- .../test/java/org/elasticsearch/oldrepos/OldMappingsIT.java | 6 +++++- .../test/resources/org/elasticsearch/oldrepos/custom.json | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/x-pack/qa/repository-old-versions/src/test/java/org/elasticsearch/oldrepos/OldMappingsIT.java b/x-pack/qa/repository-old-versions/src/test/java/org/elasticsearch/oldrepos/OldMappingsIT.java index 67dbdec6b8399..95bc92d4f185a 100644 --- a/x-pack/qa/repository-old-versions/src/test/java/org/elasticsearch/oldrepos/OldMappingsIT.java +++ b/x-pack/qa/repository-old-versions/src/test/java/org/elasticsearch/oldrepos/OldMappingsIT.java @@ -13,6 +13,7 @@ import org.elasticsearch.Version; import org.elasticsearch.client.Request; import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.Response; import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.WarningsHandler; @@ -166,7 +167,10 @@ public void setupIndex() throws IOException { createRestoreRequest.addParameter("wait_for_completion", "true"); createRestoreRequest.setJsonEntity("{\"indices\":\"" + indices.stream().collect(Collectors.joining(",")) + "\"}"); createRestoreRequest.setOptions(RequestOptions.DEFAULT.toBuilder().setWarningsHandler(WarningsHandler.PERMISSIVE)); - assertOK(client().performRequest(createRestoreRequest)); + Response response = client().performRequest(createRestoreRequest); + // check deprecation warning for "_field_name" disabling + assertTrue(response.getWarnings().stream().filter(s -> s.contains("Disabling _field_names is not necessary")).count() > 0); + assertOK(response); } private Request createIndex(String indexName, String file) throws IOException { diff --git a/x-pack/qa/repository-old-versions/src/test/resources/org/elasticsearch/oldrepos/custom.json b/x-pack/qa/repository-old-versions/src/test/resources/org/elasticsearch/oldrepos/custom.json index ae52ccbcce330..ad1c6b0dc59ae 100644 --- a/x-pack/qa/repository-old-versions/src/test/resources/org/elasticsearch/oldrepos/custom.json +++ b/x-pack/qa/repository-old-versions/src/test/resources/org/elasticsearch/oldrepos/custom.json @@ -1,4 +1,7 @@ "_default_": { + "_field_names": { + "enabled": false + }, "properties": { "apache2": { "properties": { From b7c38a1451d13fa7402ff7055231451f43ac3ac6 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:54:34 +1100 Subject: [PATCH 113/129] Mute org.elasticsearch.xpack.esql.ccq.MultiClusterSpecIT test {scoring.QstrWithFieldAndScoringSortedEval} #117751 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 40d3dcf46e1b9..96631d15f374f 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -225,6 +225,9 @@ tests: - class: org.elasticsearch.xpack.inference.InferenceCrudIT method: testSupportedStream issue: https://github.com/elastic/elasticsearch/issues/117745 +- class: org.elasticsearch.xpack.esql.ccq.MultiClusterSpecIT + method: test {scoring.QstrWithFieldAndScoringSortedEval} + issue: https://github.com/elastic/elasticsearch/issues/117751 # Examples: # From 045f6a31f994f51d87a217be60251e060132c8a1 Mon Sep 17 00:00:00 2001 From: Tanguy Leroux Date: Fri, 29 Nov 2024 11:55:51 +0100 Subject: [PATCH 114/129] Add INDEX_REFRESH_BLOCK (#117543) This change adds a new ClusterBlockLevel called REFRESH. This level is used in a new ClusterBlock.INDEX_REFRESH_BLOCK which is automatically added to new indices that are created from empty store, with replicas, and only on serverless deployments that have a feature flag enabled. This block is also only added when all nodes of a cluster are in a recent enough transport version. If for some reason the new ClusterBlock is sent over the wire to a node with an old transport version, the REFRESH cluster block level will be removed from the set of level blocked. In the future, the REFRESH cluster block will be used: to block refreshes on shards until an unpromotable shard is started to allow skipping shards when searching Relates ES-10131 --- .../org/elasticsearch/TransportVersions.java | 1 + .../cluster/block/ClusterBlock.java | 24 +++++- .../cluster/block/ClusterBlockLevel.java | 3 +- .../cluster/metadata/IndexMetadata.java | 9 ++ .../metadata/MetadataCreateIndexService.java | 54 ++++++++++++ .../cluster/ClusterStateTests.java | 18 ++-- .../cluster/block/ClusterBlockTests.java | 49 +++++++++-- .../MetadataCreateIndexServiceTests.java | 86 ++++++++++++++++++- 8 files changed, 228 insertions(+), 16 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index a1315ccf66701..b38a285907937 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -211,6 +211,7 @@ static TransportVersion def(int id) { 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); + public static final TransportVersion NEW_REFRESH_CLUSTER_BLOCK = def(8_803_00_0); /* * STOP! READ THIS FIRST! No, really, diff --git a/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlock.java b/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlock.java index 4e47925d383c2..25c6a1ff5b67f 100644 --- a/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlock.java +++ b/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlock.java @@ -9,6 +9,7 @@ package org.elasticsearch.cluster.block; +import org.elasticsearch.TransportVersions; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.io.stream.Writeable; @@ -21,6 +22,7 @@ import java.util.EnumSet; import java.util.Locale; import java.util.Objects; +import java.util.function.Predicate; public class ClusterBlock implements Writeable, ToXContentFragment { @@ -142,7 +144,12 @@ public void writeTo(StreamOutput out) throws IOException { out.writeVInt(id); out.writeOptionalString(uuid); out.writeString(description); - out.writeEnumSet(levels); + if (out.getTransportVersion().onOrAfter(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK)) { + out.writeEnumSet(levels); + } else { + // do not send ClusterBlockLevel.REFRESH to old nodes + out.writeEnumSet(filterLevels(levels, level -> ClusterBlockLevel.REFRESH.equals(level) == false)); + } out.writeBoolean(retryable); out.writeBoolean(disableStatePersistence); RestStatus.writeTo(out, status); @@ -185,4 +192,19 @@ public int hashCode() { public boolean isAllowReleaseResources() { return allowReleaseResources; } + + static EnumSet filterLevels(EnumSet levels, Predicate predicate) { + assert levels != null; + int size = levels.size(); + if (size == 0 || (size == 1 && predicate.test(levels.iterator().next()))) { + return levels; + } + var filteredLevels = EnumSet.noneOf(ClusterBlockLevel.class); + for (ClusterBlockLevel level : levels) { + if (predicate.test(level)) { + filteredLevels.add(level); + } + } + return filteredLevels; + } } diff --git a/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlockLevel.java b/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlockLevel.java index f6330fb18e5e6..262044b091ac7 100644 --- a/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlockLevel.java +++ b/server/src/main/java/org/elasticsearch/cluster/block/ClusterBlockLevel.java @@ -15,7 +15,8 @@ public enum ClusterBlockLevel { READ, WRITE, METADATA_READ, - METADATA_WRITE; + METADATA_WRITE, + REFRESH; public static final EnumSet ALL = EnumSet.allOf(ClusterBlockLevel.class); public static final EnumSet READ_WRITE = EnumSet.of(READ, WRITE); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexMetadata.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexMetadata.java index 6456240c2317e..b7c1ee5fbad96 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexMetadata.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexMetadata.java @@ -140,6 +140,15 @@ public class IndexMetadata implements Diffable, ToXContentFragmen RestStatus.TOO_MANY_REQUESTS, EnumSet.of(ClusterBlockLevel.WRITE) ); + public static final ClusterBlock INDEX_REFRESH_BLOCK = new ClusterBlock( + 14, + "index refresh blocked, waiting for shard(s) to be started", + true, + false, + false, + RestStatus.REQUEST_TIMEOUT, + EnumSet.of(ClusterBlockLevel.REFRESH) + ); // 'event.ingested' (part of Elastic Common Schema) range is tracked in cluster state, along with @timestamp public static final String EVENT_INGESTED_FIELD_NAME = "event.ingested"; diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexService.java b/server/src/main/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexService.java index 1f014a526b9a6..52e4d75ac5116 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexService.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexService.java @@ -28,6 +28,7 @@ import org.elasticsearch.cluster.ClusterStateUpdateTask; import org.elasticsearch.cluster.block.ClusterBlockLevel; import org.elasticsearch.cluster.block.ClusterBlocks; +import org.elasticsearch.cluster.node.DiscoveryNode; import org.elasticsearch.cluster.node.DiscoveryNodes; import org.elasticsearch.cluster.routing.IndexRoutingTable; import org.elasticsearch.cluster.routing.RoutingTable; @@ -127,6 +128,16 @@ public class MetadataCreateIndexService { public static final int MAX_INDEX_NAME_BYTES = 255; + /** + * Name of the setting used to allow blocking refreshes on newly created indices. + */ + public static final String USE_INDEX_REFRESH_BLOCK_SETTING_NAME = "stateless.indices.use_refresh_block_upon_index_creation"; + + @FunctionalInterface + interface ClusterBlocksTransformer { + void apply(ClusterBlocks.Builder clusterBlocks, IndexMetadata indexMetadata, TransportVersion minClusterTransportVersion); + } + private final Settings settings; private final ClusterService clusterService; private final IndicesService indicesService; @@ -139,6 +150,7 @@ public class MetadataCreateIndexService { private final boolean forbidPrivateIndexSettings; private final Set indexSettingProviders; private final ThreadPool threadPool; + private final ClusterBlocksTransformer blocksTransformerUponIndexCreation; public MetadataCreateIndexService( final Settings settings, @@ -166,6 +178,7 @@ public MetadataCreateIndexService( this.shardLimitValidator = shardLimitValidator; this.indexSettingProviders = indexSettingProviders.getIndexSettingProviders(); this.threadPool = threadPool; + this.blocksTransformerUponIndexCreation = createClusterBlocksTransformerForIndexCreation(settings); } /** @@ -540,8 +553,10 @@ private ClusterState applyCreateIndexWithTemporaryService( currentState, indexMetadata, metadataTransformer, + blocksTransformerUponIndexCreation, allocationService.getShardRoutingRoleStrategy() ); + assert assertHasRefreshBlock(indexMetadata, updated, updated.getMinTransportVersion()); if (request.performReroute()) { updated = allocationService.reroute(updated, "index [" + indexMetadata.getIndex().getName() + "] created", rerouteListener); } @@ -1294,6 +1309,7 @@ static ClusterState clusterStateCreateIndex( ClusterState currentState, IndexMetadata indexMetadata, BiConsumer metadataTransformer, + ClusterBlocksTransformer blocksTransformer, ShardRoutingRoleStrategy shardRoutingRoleStrategy ) { final Metadata newMetadata; @@ -1307,6 +1323,9 @@ static ClusterState clusterStateCreateIndex( var blocksBuilder = ClusterBlocks.builder().blocks(currentState.blocks()); blocksBuilder.updateBlocks(indexMetadata); + if (blocksTransformer != null) { + blocksTransformer.apply(blocksBuilder, indexMetadata, currentState.getMinTransportVersion()); + } var routingTableBuilder = RoutingTable.builder(shardRoutingRoleStrategy, currentState.routingTable()) .addAsNew(newMetadata.index(indexMetadata.getIndex().getName())); @@ -1745,4 +1764,39 @@ public static void validateStoreTypeSetting(Settings indexSettings) { ); } } + + private static boolean useRefreshBlock(Settings settings) { + return DiscoveryNode.isStateless(settings) && settings.getAsBoolean(USE_INDEX_REFRESH_BLOCK_SETTING_NAME, false); + } + + static ClusterBlocksTransformer createClusterBlocksTransformerForIndexCreation(Settings settings) { + if (useRefreshBlock(settings) == false) { + return (clusterBlocks, indexMetadata, minClusterTransportVersion) -> {}; + } + logger.debug("applying refresh block on index creation"); + return (clusterBlocks, indexMetadata, minClusterTransportVersion) -> { + if (applyRefreshBlock(indexMetadata, minClusterTransportVersion)) { + // Applies the INDEX_REFRESH_BLOCK to the index. This block will remain in cluster state until an unpromotable shard is + // started or a configurable delay is elapsed. + clusterBlocks.addIndexBlock(indexMetadata.getIndex().getName(), IndexMetadata.INDEX_REFRESH_BLOCK); + } + }; + } + + private static boolean applyRefreshBlock(IndexMetadata indexMetadata, TransportVersion minClusterTransportVersion) { + return 0 < indexMetadata.getNumberOfReplicas() // index has replicas + && indexMetadata.getResizeSourceIndex() == null // index is not a split/shrink index + && indexMetadata.getInSyncAllocationIds().values().stream().allMatch(Set::isEmpty) // index is a new index + && minClusterTransportVersion.onOrAfter(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK); + } + + private boolean assertHasRefreshBlock(IndexMetadata indexMetadata, ClusterState clusterState, TransportVersion minTransportVersion) { + var hasRefreshBlock = clusterState.blocks().hasIndexBlock(indexMetadata.getIndex().getName(), IndexMetadata.INDEX_REFRESH_BLOCK); + if (useRefreshBlock(settings) == false || applyRefreshBlock(indexMetadata, minTransportVersion) == false) { + assert hasRefreshBlock == false : indexMetadata.getIndex(); + } else { + assert hasRefreshBlock : indexMetadata.getIndex(); + } + return true; + } } diff --git a/server/src/test/java/org/elasticsearch/cluster/ClusterStateTests.java b/server/src/test/java/org/elasticsearch/cluster/ClusterStateTests.java index 9613086aa9f57..668aea70c23f2 100644 --- a/server/src/test/java/org/elasticsearch/cluster/ClusterStateTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/ClusterStateTests.java @@ -167,7 +167,8 @@ public void testToXContent() throws IOException { "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } }, @@ -180,7 +181,8 @@ public void testToXContent() throws IOException { "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } } @@ -440,7 +442,8 @@ public void testToXContent_FlatSettingTrue_ReduceMappingFalse() throws IOExcepti "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } }, @@ -453,7 +456,8 @@ public void testToXContent_FlatSettingTrue_ReduceMappingFalse() throws IOExcepti "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } } @@ -712,7 +716,8 @@ public void testToXContent_FlatSettingFalse_ReduceMappingTrue() throws IOExcepti "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } }, @@ -725,7 +730,8 @@ public void testToXContent_FlatSettingFalse_ReduceMappingTrue() throws IOExcepti "read", "write", "metadata_read", - "metadata_write" + "metadata_write", + "refresh" ] } } diff --git a/server/src/test/java/org/elasticsearch/cluster/block/ClusterBlockTests.java b/server/src/test/java/org/elasticsearch/cluster/block/ClusterBlockTests.java index 311f2ec36af5c..0237fff8fdda5 100644 --- a/server/src/test/java/org/elasticsearch/cluster/block/ClusterBlockTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/block/ClusterBlockTests.java @@ -10,19 +10,22 @@ package org.elasticsearch.cluster.block; import org.elasticsearch.TransportVersion; +import org.elasticsearch.TransportVersions; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.ESTestCase; -import java.util.Arrays; import java.util.Collections; -import java.util.List; +import java.util.EnumSet; import java.util.Map; import static java.util.EnumSet.copyOf; +import static org.elasticsearch.test.TransportVersionUtils.getFirstVersion; +import static org.elasticsearch.test.TransportVersionUtils.getPreviousVersion; import static org.elasticsearch.test.TransportVersionUtils.randomVersion; +import static org.elasticsearch.test.TransportVersionUtils.randomVersionBetween; import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.not; @@ -36,7 +39,7 @@ public void testSerialization() throws Exception { int iterations = randomIntBetween(5, 20); for (int i = 0; i < iterations; i++) { TransportVersion version = randomVersion(random()); - ClusterBlock clusterBlock = randomClusterBlock(); + ClusterBlock clusterBlock = randomClusterBlock(version); BytesStreamOutput out = new BytesStreamOutput(); out.setTransportVersion(version); @@ -50,13 +53,41 @@ public void testSerialization() throws Exception { } } + public void testSerializationBwc() throws Exception { + var out = new BytesStreamOutput(); + out.setTransportVersion( + randomVersionBetween(random(), getFirstVersion(), getPreviousVersion(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK)) + ); + + var clusterBlock = randomClusterBlock(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK); + clusterBlock.writeTo(out); + + var in = out.bytes().streamInput(); + in.setTransportVersion(randomVersion()); + + assertClusterBlockEquals( + new ClusterBlock( + clusterBlock.id(), + clusterBlock.uuid(), + clusterBlock.description(), + clusterBlock.retryable(), + clusterBlock.disableStatePersistence(), + clusterBlock.isAllowReleaseResources(), + clusterBlock.status(), + // ClusterBlockLevel.REFRESH should not be sent over the wire to nodes with version < NEW_REFRESH_CLUSTER_BLOCK + ClusterBlock.filterLevels(clusterBlock.levels(), level -> ClusterBlockLevel.REFRESH.equals(level) == false) + ), + new ClusterBlock(in) + ); + } + public void testToStringDanglingComma() { - final ClusterBlock clusterBlock = randomClusterBlock(); + final ClusterBlock clusterBlock = randomClusterBlock(randomVersion(random())); assertThat(clusterBlock.toString(), not(endsWith(","))); } public void testGlobalBlocksCheckedIfNoIndicesSpecified() { - ClusterBlock globalBlock = randomClusterBlock(); + ClusterBlock globalBlock = randomClusterBlock(randomVersion(random())); ClusterBlocks clusterBlocks = new ClusterBlocks(Collections.singleton(globalBlock), Map.of()); ClusterBlockException exception = clusterBlocks.indicesBlockedException(randomFrom(globalBlock.levels()), new String[0]); assertNotNull(exception); @@ -113,9 +144,13 @@ public void testGetIndexBlockWithId() { assertThat(builder.build().getIndexBlockWithId("index", randomValueOtherThan(blockId, ESTestCase::randomInt)), nullValue()); } - private static ClusterBlock randomClusterBlock() { + private static ClusterBlock randomClusterBlock(TransportVersion version) { final String uuid = randomBoolean() ? UUIDs.randomBase64UUID() : null; - final List levels = Arrays.asList(ClusterBlockLevel.values()); + final EnumSet levels = ClusterBlock.filterLevels( + EnumSet.allOf(ClusterBlockLevel.class), + // Filter out ClusterBlockLevel.REFRESH for versions < TransportVersions.NEW_REFRESH_CLUSTER_BLOCK + level -> ClusterBlockLevel.REFRESH.equals(level) == false || version.onOrAfter(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK) + ); return new ClusterBlock( randomInt(), uuid, diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java index 96a74d2e23aad..1876a1f2da556 100644 --- a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java @@ -36,6 +36,7 @@ import org.elasticsearch.cluster.routing.allocation.decider.AllocationDeciders; import org.elasticsearch.cluster.routing.allocation.decider.MaxRetryAllocationDecider; import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.cluster.version.CompatibilityVersions; import org.elasticsearch.common.Strings; import org.elasticsearch.common.compress.CompressedXContent; import org.elasticsearch.common.settings.IndexScopedSettings; @@ -66,6 +67,7 @@ import org.elasticsearch.snapshots.EmptySnapshotsInfoService; import org.elasticsearch.test.ClusterServiceUtils; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.TransportVersionUtils; import org.elasticsearch.test.gateway.TestGatewayAllocator; import org.elasticsearch.test.index.IndexVersionUtils; import org.elasticsearch.threadpool.TestThreadPool; @@ -105,6 +107,8 @@ import static org.elasticsearch.cluster.metadata.MetadataCreateIndexService.resolveAndValidateAliases; import static org.elasticsearch.index.IndexSettings.INDEX_SOFT_DELETES_SETTING; import static org.elasticsearch.indices.ShardLimitValidatorTests.createTestShardLimitService; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; @@ -1133,7 +1137,7 @@ public void testClusterStateCreateIndexThrowsWriteIndexValidationException() thr assertThat( expectThrows( IllegalStateException.class, - () -> clusterStateCreateIndex(currentClusterState, newIndex, null, TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY) + () -> clusterStateCreateIndex(currentClusterState, newIndex, null, null, TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY) ).getMessage(), startsWith("alias [alias1] has more than one write index [") ); @@ -1153,6 +1157,7 @@ public void testClusterStateCreateIndex() { currentClusterState, newIndexMetadata, null, + null, TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY ); assertThat(updatedClusterState.blocks().getIndexBlockWithId("test", INDEX_READ_ONLY_BLOCK.id()), is(INDEX_READ_ONLY_BLOCK)); @@ -1198,6 +1203,7 @@ public void testClusterStateCreateIndexWithMetadataTransaction() { currentClusterState, newIndexMetadata, metadataTransformer, + null, TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY ); assertTrue(updatedClusterState.metadata().findAllAliases(new String[] { "my-index" }).containsKey("my-index")); @@ -1547,6 +1553,84 @@ public void testDeprecateSimpleFS() { ); } + public void testClusterStateCreateIndexWithClusterBlockTransformer() { + { + var emptyClusterState = ClusterState.builder(ClusterState.EMPTY_STATE).build(); + var updatedClusterState = clusterStateCreateIndex( + emptyClusterState, + IndexMetadata.builder("test") + .settings(settings(IndexVersion.current())) + .numberOfShards(1) + .numberOfReplicas(randomIntBetween(1, 3)) + .build(), + null, + MetadataCreateIndexService.createClusterBlocksTransformerForIndexCreation(Settings.EMPTY), + TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY + ); + assertThat(updatedClusterState.blocks().indices(), is(anEmptyMap())); + assertThat(updatedClusterState.blocks().hasIndexBlock("test", IndexMetadata.INDEX_REFRESH_BLOCK), is(false)); + assertThat(updatedClusterState.routingTable().index("test"), is(notNullValue())); + } + { + var minTransportVersion = TransportVersionUtils.randomCompatibleVersion(random()); + var emptyClusterState = ClusterState.builder(ClusterState.EMPTY_STATE) + .nodes(DiscoveryNodes.builder().add(DiscoveryNodeUtils.create("_node_id")).build()) + .putCompatibilityVersions("_node_id", new CompatibilityVersions(minTransportVersion, Map.of())) + .build(); + var settings = Settings.builder() + .put(DiscoveryNode.STATELESS_ENABLED_SETTING_NAME, true) + .put(MetadataCreateIndexService.USE_INDEX_REFRESH_BLOCK_SETTING_NAME, true) + .build(); + int nbReplicas = randomIntBetween(0, 1); + var updatedClusterState = clusterStateCreateIndex( + emptyClusterState, + IndexMetadata.builder("test") + .settings(settings(IndexVersion.current())) + .numberOfShards(1) + .numberOfReplicas(nbReplicas) + .build() + .withTimestampRanges(IndexLongFieldRange.UNKNOWN, IndexLongFieldRange.UNKNOWN, minTransportVersion), + null, + MetadataCreateIndexService.createClusterBlocksTransformerForIndexCreation(settings), + TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY + ); + + var expectRefreshBlock = 0 < nbReplicas && minTransportVersion.onOrAfter(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK); + assertThat(updatedClusterState.blocks().indices(), is(aMapWithSize(expectRefreshBlock ? 1 : 0))); + assertThat(updatedClusterState.blocks().hasIndexBlock("test", IndexMetadata.INDEX_REFRESH_BLOCK), is(expectRefreshBlock)); + assertThat(updatedClusterState.routingTable().index("test"), is(notNullValue())); + } + } + + public void testCreateClusterBlocksTransformerForIndexCreation() { + boolean isStateless = randomBoolean(); + boolean useRefreshBlock = randomBoolean(); + var minTransportVersion = TransportVersionUtils.randomCompatibleVersion(random()); + + var applier = MetadataCreateIndexService.createClusterBlocksTransformerForIndexCreation( + Settings.builder() + .put(DiscoveryNode.STATELESS_ENABLED_SETTING_NAME, isStateless) + .put(MetadataCreateIndexService.USE_INDEX_REFRESH_BLOCK_SETTING_NAME, useRefreshBlock) + .build() + ); + assertThat(applier, notNullValue()); + + var blocks = ClusterBlocks.builder().blocks(ClusterState.EMPTY_STATE.blocks()); + applier.apply( + blocks, + IndexMetadata.builder("test") + .settings(settings(IndexVersion.current())) + .numberOfShards(1) + .numberOfReplicas(randomIntBetween(1, 3)) + .build(), + minTransportVersion + ); + assertThat( + blocks.hasIndexBlock("test", IndexMetadata.INDEX_REFRESH_BLOCK), + is(isStateless && useRefreshBlock && minTransportVersion.onOrAfter(TransportVersions.NEW_REFRESH_CLUSTER_BLOCK)) + ); + } + private IndexTemplateMetadata addMatchingTemplate(Consumer configurator) { IndexTemplateMetadata.Builder builder = templateMetadataBuilder("template1", "te*"); configurator.accept(builder); From ad83d9b35ddc01229a5b2b5de21b122f9d1b2106 Mon Sep 17 00:00:00 2001 From: Panagiotis Bailis Date: Fri, 29 Nov 2024 14:50:01 +0200 Subject: [PATCH 115/129] Updating retriever-examples documentation to run validation tests on the provided snippets (#116643) --- docs/reference/search/rrf.asciidoc | 98 +- .../retrievers-examples.asciidoc | 1270 ++++++++++++++--- 2 files changed, 1149 insertions(+), 219 deletions(-) diff --git a/docs/reference/search/rrf.asciidoc b/docs/reference/search/rrf.asciidoc index edd3b67e3de04..a942c0162a80a 100644 --- a/docs/reference/search/rrf.asciidoc +++ b/docs/reference/search/rrf.asciidoc @@ -105,7 +105,7 @@ The `rrf` retriever does not currently support: * <> Using unsupported features as part of a search with an `rrf` retriever results in an exception. -+ + IMPORTANT: It is best to avoid providing a <> as part of the request, as RRF creates one internally that is shared by all sub-retrievers to ensure consistent results. @@ -703,3 +703,99 @@ So for the same params as above, we would now have: * `from=0, size=2` would return [`1`, `5`] with ranks `[1, 2]` * `from=2, size=2` would return an empty result set as it would fall outside the available `rank_window_size` results. + +==== Aggregations in RRF + +The `rrf` retriever supports aggregations from all specified sub-retrievers. Important notes about aggregations: + +* They operate on the complete result set from all sub-retrievers +* They are not limited by the `rank_window_size` parameter +* They process the union of all matching documents + +For example, consider the following document set: +[source,js] +---- +{ + "_id": 1, "termA": "foo", + "_id": 2, "termA": "foo", "termB": "bar", + "_id": 3, "termA": "aardvark", "termB": "bar", + "_id": 4, "termA": "foo", "termB": "bar" +} +---- +// NOTCONSOLE + +Perform a term aggregation on the `termA` field using an `rrf` retriever: +[source,js] +---- +{ + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "term": { + "termB": "bar" + } + } + } + }, + { + "standard": { + "query": { + "match_all": { } + } + } + } + ], + "rank_window_size": 1 + } + }, + "size": 1, + "aggs": { + "termA_agg": { + "terms": { + "field": "termA" + } + } + } +} +---- +// NOTCONSOLE + +The aggregation results will include *all* matching documents, regardless of `rank_window_size`. +[source, js] +---- +{ + "foo": 3, + "aardvark": 1 +} + +---- +// NOTCONSOLE + +==== Highlighting in RRF + +Using the `rrf` retriever, you can add <> to show relevant text snippets in your search results. Highlighted snippets are computed based +on the matching text queries defined on the sub-retrievers. + +IMPORTANT: Highlighting on vector fields, using either the `knn` retriever or a `knn` query, is not supported. + +A more specific example of highlighting in RRF can also be found in the <> page. + +==== Inner hits in RRF + +The `rrf` retriever supports <> functionality, allowing you to retrieve +related nested or parent/child documents alongside your main search results. Inner hits can be +specified as part of any nested sub-retriever and will be propagated to the top-level parent +retriever. Note that the inner hit computation will take place only at end of `rrf` retriever's +evaluation on the top matching documents, and not as part of the query execution of the nested +sub-retrievers. + +[IMPORTANT] +==== +When defining multiple `inner_hits` sections across sub-retrievers: + +* Each `inner_hits` section must have a unique name +* Names must be unique across all sub-retrievers in the search request +==== diff --git a/docs/reference/search/search-your-data/retrievers-examples.asciidoc b/docs/reference/search/search-your-data/retrievers-examples.asciidoc index 8cd1a4bf5ce98..ad1cc32dcee01 100644 --- a/docs/reference/search/search-your-data/retrievers-examples.asciidoc +++ b/docs/reference/search/search-your-data/retrievers-examples.asciidoc @@ -1,31 +1,16 @@ [[retrievers-examples]] -=== Retrievers examples Learn how to combine different retrievers in these hands-on examples. -To demonstrate the full functionality of retrievers, these examples require access to a <> set up using the <>. + +=== Retrievers examples [discrete] [[retrievers-examples-setup]] ==== Add example data -To begin with, we'll set up the necessary services and have them in place for later use. - -[source,js] ----- -// Setup rerank task stored as `my-rerank-model` -PUT _inference/rerank/my-rerank-model -{ - "service": "cohere", - "service_settings": { - "model_id": "rerank-english-v3.0", - "api_key": "{{COHERE_API_KEY}}" - } -} ----- -//NOTCONSOLE +To begin with, lets create the `retrievers_example` index, and add some documents to it. -Now that we have our reranking service in place, lets create the `retrievers_example` index, and add some documents to it. -[source,js] +[source,console] ---- PUT retrievers_example { @@ -49,11 +34,7 @@ PUT retrievers_example } } } ----- -//NOTCONSOLE -[source,js] ----- POST /retrievers_example/_doc/1 { "vector": [0.23, 0.67, 0.89], @@ -94,10 +75,12 @@ POST /retrievers_example/_doc/5 "topic": ["documentation", "observability", "elastic"] } +POST /retrievers_example/_refresh + ---- -//NOTCONSOLE +// TESTSETUP -Now that we also have our documents in place, let's try to run some queries using retrievers. +Now that we have our documents in place, let's try to run some queries using retrievers. [discrete] [[retrievers-examples-combining-standard-knn-retrievers-with-rrf]] @@ -112,170 +95,272 @@ To implement this in the retriever framework, we start with the top-level elemen retriever. This retriever operates on top of two other retrievers: a `knn` retriever and a `standard` retriever. Our query structure would look like this: -[source,js] +[source,console] ---- GET /retrievers_example/_search { - "retriever":{ - "rrf": { - "retrievers":[ - { - "standard":{ - "query":{ - "query_string":{ - "query": "(information retrieval) OR (artificial intelligence)", - "default_field": "text" - } - } - } - }, - { - "knn": { - "field": "vector", - "query_vector": [ - 0.23, - 0.67, - 0.89 - ], - "k": 3, - "num_candidates": 5 - } - } - ], - "rank_window_size": 10, - "rank_constant": 1 - } - }, - "_source": ["text", "topic"] + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "query_string": { + "query": "(information retrieval) OR (artificial intelligence)", + "default_field": "text" + } + } + } + }, + { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "_source": false +} +---- +// TEST + +This returns the following response based on the final rrf score for each result. + +.Example response +[%collapsible] +============== +[source,console-result] +---- +{ + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 3, + "relation": "eq" + }, + "max_score": 0.8333334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "1", + "_score": 0.8333334 + }, + { + "_index": "retrievers_example", + "_id": "2", + "_score": 0.8333334 + }, + { + "_index": "retrievers_example", + "_id": "3", + "_score": 0.25 + } + ] + } } ---- -//NOTCONSOLE +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +============== [discrete] [[retrievers-examples-collapsing-retriever-results]] ==== Example: Grouping results by year with `collapse` In our result set, we have many documents with the same `year` value. We can clean this -up using the `collapse` parameter with our retriever. This enables grouping results by -any field and returns only the highest-scoring document from each group. In this example +up using the `collapse` parameter with our retriever. This, as with the standard <> feature, +enables grouping results by any field and returns only the highest-scoring document from each group. In this example we'll collapse our results based on the `year` field. -[source,js] +[source,console] ---- GET /retrievers_example/_search { - "retriever":{ - "rrf": { - "retrievers":[ - { - "standard":{ - "query":{ - "query_string":{ - "query": "(information retrieval) OR (artificial intelligence)", - "default_field": "text" - } - } - } - }, - { - "knn": { - "field": "vector", - "query_vector": [ - 0.23, - 0.67, - 0.89 - ], - "k": 3, - "num_candidates": 5 - } - } - ], - "rank_window_size": 10, - "rank_constant": 1 - } - }, - "collapse": { - "field": "year", - "inner_hits": { - "name": "topic related documents", - "_source": ["text", "year"] - } - }, - "_source": ["text", "topic"] + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "query_string": { + "query": "(information retrieval) OR (artificial intelligence)", + "default_field": "text" + } + } + } + }, + { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "collapse": { + "field": "year", + "inner_hits": { + "name": "topic related documents", + "_source": [ + "year" + ] + } + }, + "_source": false } ---- -//NOTCONSOLE +// TEST[continued] -[discrete] -[[retrievers-examples-text-similarity-reranker-on-top-of-rrf]] -==== Example: Rerank results of an RRF retriever +This returns the following response with collapsed results. -Previously, we used a `text_similarity_reranker` retriever within an `rrf` retriever. -Because retrievers support full composability, we can also rerank the results of an -`rrf` retriever. Let's apply this to our first example. - -[source,js] +.Example response +[%collapsible] +============== +[source,console-result] ---- -GET retrievers_example/_search { - "retriever": { - "text_similarity_reranker": { - "retriever": { - "rrf": { - "retrievers": [ - { - "standard":{ - "query":{ - "query_string":{ - "query": "(information retrieval) OR (artificial intelligence)", - "default_field": "text" - } - } - } - }, - { - "knn": { - "field": "vector", - "query_vector": [ - 0.23, - 0.67, - 0.89 - ], - "k": 3, - "num_candidates": 5 - } - } - ], - "rank_window_size": 10, - "rank_constant": 1 - } - }, - "field": "text", - "inference_id": "my-rerank-model", - "inference_text": "What are the state of the art applications of AI in information retrieval?" - } - }, - "_source": ["text", "topic"] + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 3, + "relation": "eq" + }, + "max_score": 0.8333334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "1", + "_score": 0.8333334, + "fields": { + "year": [ + 2024 + ] + }, + "inner_hits": { + "topic related documents": { + "hits": { + "total": { + "value": 2, + "relation": "eq" + }, + "max_score": 0.8333334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "1", + "_score": 0.8333334, + "_source": { + "year": 2024 + } + }, + { + "_index": "retrievers_example", + "_id": "3", + "_score": 0.25, + "_source": { + "year": 2024 + } + } + ] + } + } + } + }, + { + "_index": "retrievers_example", + "_id": "2", + "_score": 0.8333334, + "fields": { + "year": [ + 2023 + ] + }, + "inner_hits": { + "topic related documents": { + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "max_score": 0.8333334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "2", + "_score": 0.8333334, + "_source": { + "year": 2023 + } + } + ] + } + } + } + } + ] + } } - ---- -//NOTCONSOLE +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +============== [discrete] -[[retrievers-examples-rrf-ranking-on-text-similarity-reranker-results]] -==== Example: RRF with semantic reranker +[[retrievers-examples-highlighting-retriever-results]] +==== Example: Highlighting results based on nested sub-retrievers -For this example, we'll replace our semantic query with the `my-rerank-model` -reranker we previously configured. Since this is a reranker, it needs an initial pool of -documents to work with. In this case, we'll filter for documents about `ai` topics. +Highlighting is now also available for nested sub-retrievers matches. For example, consider the same +`rrf` retriever as above, with a `knn` and `standard` retriever as its sub-retrievers. We can specify a `highlight` +section, as defined in <> documentation, and compute highlights for the top results. -[source,js] +[source,console] ---- GET /retrievers_example/_search { "retriever": { "rrf": { "retrievers": [ + { + "standard": { + "query": { + "query_string": { + "query": "(information retrieval) OR (artificial intelligence)", + "default_field": "text" + } + } + } + }, { "knn": { "field": "vector", @@ -287,21 +372,221 @@ GET /retrievers_example/_search "k": 3, "num_candidates": 5 } - }, + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "highlight": { + "fields": { + "text": { + "fragment_size": 150, + "number_of_fragments": 3 + } + } + }, + "_source": false +} +---- +// TEST[continued] + +This would highlight the `text` field, based on the matches produced by the `standard` retriever. The highlighted snippets +would then be included in the response as usual, i.e. under each search hit. + +.Example response +[%collapsible] +============== +[source,console-result] +---- +{ + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 3, + "relation": "eq" + }, + "max_score": 0.8333334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "1", + "_score": 0.8333334, + "highlight": { + "text": [ + "Large language models are revolutionizing information retrieval by boosting search precision, deepening contextual understanding, and reshaping user experiences" + ] + } + }, + { + "_index": "retrievers_example", + "_id": "2", + "_score": 0.8333334, + "highlight": { + "text": [ + "Artificial intelligence is transforming medicine, from advancing diagnostics and tailoring treatment plans to empowering predictive patient care for improved" + ] + } + }, + { + "_index": "retrievers_example", + "_id": "3", + "_score": 0.25 + } + ] + } +} +---- +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +============== + +[discrete] +[[retrievers-examples-inner-hits-retriever-results]] +==== Example: Computing inner hits from nested sub-retrievers + +We can also define `inner_hits` to be computed on any of the sub-retrievers, and propagate those computations to the top +level compound retriever. For example, let's create a new index with a `knn` field, nested under the `nested_field` field, +and index a couple of documents. + +[source,console] +---- +PUT retrievers_example_nested +{ + "mappings": { + "properties": { + "nested_field": { + "type": "nested", + "properties": { + "paragraph_id": { + "type": "keyword" + }, + "nested_vector": { + "type": "dense_vector", + "dims": 3, + "similarity": "l2_norm", + "index": true + } + } + }, + "topic": { + "type": "keyword" + } + } + } +} + +POST /retrievers_example_nested/_doc/1 +{ + "nested_field": [ + { + "paragraph_id": "1a", + "nested_vector": [ + -1.12, + -0.59, + 0.78 + ] + }, + { + "paragraph_id": "1b", + "nested_vector": [ + -0.12, + 1.56, + 0.42 + ] + }, + { + "paragraph_id": "1c", + "nested_vector": [ + 1, + -1, + 0 + ] + } + ], + "topic": [ + "ai" + ] +} + +POST /retrievers_example_nested/_doc/2 +{ + "nested_field": [ + { + "paragraph_id": "2a", + "nested_vector": [ + 0.23, + 1.24, + 0.65 + ] + } + ], + "topic": [ + "information_retrieval" + ] +} + +POST /retrievers_example_nested/_doc/3 +{ + "topic": [ + "ai" + ] +} + +POST /retrievers_example_nested/_refresh +---- +// TEST[continued] + +Now we can run an `rrf` retriever query and also compute <> for the `nested_field.nested_vector` +field, based on the `knn` query specified. + +[source,console] +---- +GET /retrievers_example_nested/_search +{ + "retriever": { + "rrf": { + "retrievers": [ { - "text_similarity_reranker": { - "retriever": { - "standard": { + "standard": { + "query": { + "nested": { + "path": "nested_field", + "inner_hits": { + "name": "nested_vector", + "_source": false, + "fields": [ + "nested_field.paragraph_id" + ] + }, "query": { - "term": { - "topic": "ai" + "knn": { + "field": "nested_field.nested_vector", + "query_vector": [ + 1, + 0, + 0.5 + ], + "k": 10 } } } - }, - "field": "text", - "inference_id": "my-rerank-model", - "inference_text": "Can I use generative AI to identify user intent and improve search relevance?" + } + } + }, + { + "standard": { + "query": { + "term": { + "topic": "ai" + } + } } } ], @@ -310,64 +595,184 @@ GET /retrievers_example/_search } }, "_source": [ - "text", "topic" ] } ---- -//NOTCONSOLE - -[discrete] -[[retrievers-examples-chaining-text-similarity-reranker-retrievers]] -==== Example: Chaining multiple semantic rerankers +// TEST[continued] -Full composability means we can chain together multiple retrievers of the same type. For instance, imagine we have a computationally expensive reranker that's specialized for AI content. We can rerank the results of a `text_similarity_reranker` using another `text_similarity_reranker` retriever. Each reranker can operate on different fields and/or use different inference services. +This would propagate the `inner_hits` defined for the `knn` query to the `rrf` retriever, and compute inner hits for `rrf`'s top results. -[source,js] +.Example response +[%collapsible] +============== +[source,console-result] ---- -GET retrievers_example/_search { - "retriever": { - "text_similarity_reranker": { - "retriever": { - "text_similarity_reranker": { - "retriever": { - "knn": { - "field": "vector", - "query_vector": [ - 0.23, - 0.67, - 0.89 - ], - "k": 3, - "num_candidates": 5 - } - }, - "rank_window_size": 100, - "field": "text", - "inference_id": "my-rerank-model", - "inference_text": "What are the state of the art applications of AI in information retrieval?" - } - }, - "rank_window_size": 10, - "field": "text", - "inference_id": "my-other-more-expensive-rerank-model", - "inference_text": "Applications of Large Language Models in technology and their impact on user satisfaction" - } - }, - "_source": [ - "text", - "topic" - ] + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 3, + "relation": "eq" + }, + "max_score": 1.0, + "hits": [ + { + "_index": "retrievers_example_nested", + "_id": "1", + "_score": 1.0, + "_source": { + "topic": [ + "ai" + ] + }, + "inner_hits": { + "nested_vector": { + "hits": { + "total": { + "value": 3, + "relation": "eq" + }, + "max_score": 0.44353113, + "hits": [ + { + "_index": "retrievers_example_nested", + "_id": "1", + "_nested": { + "field": "nested_field", + "offset": 2 + }, + "_score": 0.44353113, + "fields": { + "nested_field": [ + { + "paragraph_id": [ + "1c" + ] + } + ] + } + }, + { + "_index": "retrievers_example_nested", + "_id": "1", + "_nested": { + "field": "nested_field", + "offset": 1 + }, + "_score": 0.26567122, + "fields": { + "nested_field": [ + { + "paragraph_id": [ + "1b" + ] + } + ] + } + }, + { + "_index": "retrievers_example_nested", + "_id": "1", + "_nested": { + "field": "nested_field", + "offset": 0 + }, + "_score": 0.18478848, + "fields": { + "nested_field": [ + { + "paragraph_id": [ + "1a" + ] + } + ] + } + } + ] + } + } + } + }, + { + "_index": "retrievers_example_nested", + "_id": "2", + "_score": 0.33333334, + "_source": { + "topic": [ + "information_retrieval" + ] + }, + "inner_hits": { + "nested_vector": { + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "max_score": 0.32002488, + "hits": [ + { + "_index": "retrievers_example_nested", + "_id": "2", + "_nested": { + "field": "nested_field", + "offset": 0 + }, + "_score": 0.32002488, + "fields": { + "nested_field": [ + { + "paragraph_id": [ + "2a" + ] + } + ] + } + } + ] + } + } + } + }, + { + "_index": "retrievers_example_nested", + "_id": "3", + "_score": 0.33333334, + "_source": { + "topic": [ + "ai" + ] + }, + "inner_hits": { + "nested_vector": { + "hits": { + "total": { + "value": 0, + "relation": "eq" + }, + "max_score": null, + "hits": [] + } + } + } + } + ] + } } ---- -//NOTCONSOLE +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +============== - -Note that our example applies two reranking steps. First, we rerank the top 100 -documents from the `knn` search using the `my-rerank-model` reranker. Then we -pick the top 10 results and rerank them using the more fine-grained -`my-other-more-expensive-rerank-model`. +Note: if using more than one `inner_hits` we need to provide custom names for each `inner_hits` so that they +are unique across all retrievers within the request. [discrete] [[retrievers-examples-rrf-and-aggregations]] @@ -380,7 +785,7 @@ the `terms` aggregation for the `topic` field will include all results, not just from the 2 nested retrievers, i.e. all documents whose `year` field is greater than 2023, and whose `topic` field matches the term `elastic`. -[source,js] +[source,console] ---- GET retrievers_example/_search { @@ -412,10 +817,7 @@ GET retrievers_example/_search "rank_constant": 1 } }, - "_source": [ - "text", - "topic" - ], + "_source": false, "aggs": { "topics": { "terms": { @@ -425,4 +827,436 @@ GET retrievers_example/_search } } ---- -//NOTCONSOLE +// TEST[continued] + +.Example response +[%collapsible] +============== +[source, console-result] +---- +{ + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 4, + "relation": "eq" + }, + "max_score": 0.5833334, + "hits": [ + { + "_index": "retrievers_example", + "_id": "5", + "_score": 0.5833334 + }, + { + "_index": "retrievers_example", + "_id": "1", + "_score": 0.5 + }, + { + "_index": "retrievers_example", + "_id": "4", + "_score": 0.5 + }, + { + "_index": "retrievers_example", + "_id": "3", + "_score": 0.33333334 + } + ] + }, + "aggregations": { + "topics": { + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + "buckets": [ + { + "key": "ai", + "doc_count": 3 + }, + { + "key": "elastic", + "doc_count": 2 + }, + { + "key": "assistant", + "doc_count": 1 + }, + { + "key": "documentation", + "doc_count": 1 + }, + { + "key": "information_retrieval", + "doc_count": 1 + }, + { + "key": "llm", + "doc_count": 1 + }, + { + "key": "observability", + "doc_count": 1 + }, + { + "key": "security", + "doc_count": 1 + } + ] + } + } +} +---- +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +============== + +[discrete] +[[retrievers-examples-explain-multiple-rrf]] +==== Example: Explainability with multiple retrievers + +By adding `explain: true` to the request, each retriever will now provide a detailed explanation of all the steps +and calculations required to compute the final score. Composability is fully supported in the context of `explain`, and +each retriever will provide its own explanation, as shown in the example below. + +[source,console] +---- +GET /retrievers_example/_search +{ + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "term": { + "topic": "elastic" + } + } + } + }, + { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "query_string": { + "query": "(information retrieval) OR (artificial intelligence)", + "default_field": "text" + } + } + } + }, + { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "_source": false, + "size": 1, + "explain": true +} +---- +// TEST[continued] + +The output of which, albeit a bit verbose, will provide all the necessary info to assist in debugging and reason with ranking. + +.Example response +[%collapsible] +============== +[source, console-result] +---- +{ + "took": 42, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 5, + "relation": "eq" + }, + "max_score": 0.5, + "hits": [ + { + "_shard": "[retrievers_example][0]", + "_node": "jnrdZFKS3abUgWVsVdj2Vg", + "_index": "retrievers_example", + "_id": "1", + "_score": 0.5, + "_explanation": { + "value": 0.5, + "description": "rrf score: [0.5] computed for initial ranks [0, 1] with rankConstant: [1] as sum of [1 / (rank + rankConstant)] for each query", + "details": [ + { + "value": 0.0, + "description": "rrf score: [0], result not found in query at index [0]", + "details": [] + }, + { + "value": 1, + "description": "rrf score: [0.5], for rank [1] in query at index [1] computed as [1 / (1 + 1)], for matching query with score", + "details": [ + { + "value": 0.8333334, + "description": "rrf score: [0.8333334] computed for initial ranks [2, 1] with rankConstant: [1] as sum of [1 / (rank + rankConstant)] for each query", + "details": [ + { + "value": 2, + "description": "rrf score: [0.33333334], for rank [2] in query at index [0] computed as [1 / (2 + 1)], for matching query with score", + "details": [ + { + "value": 2.8129659, + "description": "sum of:", + "details": [ + { + "value": 1.4064829, + "description": "weight(text:information in 0) [PerFieldSimilarity], result of:", + "details": [ + *** + ] + }, + { + "value": 1.4064829, + "description": "weight(text:retrieval in 0) [PerFieldSimilarity], result of:", + "details": [ + *** + ] + } + ] + } + ] + }, + { + "value": 1, + "description": "rrf score: [0.5], for rank [1] in query at index [1] computed as [1 / (1 + 1)], for matching query with score", + "details": [ + { + "value": 1, + "description": "doc [0] with an original score of [1.0] is at rank [1] from the following source queries.", + "details": [ + { + "value": 1.0, + "description": "found vector with calculated similarity: 1.0", + "details": [] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + } +} +---- +// TESTRESPONSE[s/"took": 42/"took": $body.took/] +// TESTRESPONSE[s/\.\.\./$body.hits.hits.0._explanation.details.1.details.0.details.0.details.0.details.0.details.0/] +// TESTRESPONSE[s/\*\*\*/$body.hits.hits.0._explanation.details.1.details.0.details.0.details.0.details.1.details.0/] +// TESTRESPONSE[s/jnrdZFKS3abUgWVsVdj2Vg/$body.hits.hits.0._node/] +============== + +[discrete] +[[retrievers-examples-text-similarity-reranker-on-top-of-rrf]] +==== Example: Rerank results of an RRF retriever + +To demonstrate the full functionality of retrievers, the following examples also require access to a <> set up using the <>. + +In this example we'll set up a reranking service and use it with the `text_similarity_reranker` retriever to rerank our top results. + +[source,console] +---- +PUT _inference/rerank/my-rerank-model +{ + "service": "cohere", + "service_settings": { + "model_id": "rerank-english-v3.0", + "api_key": "{{COHERE_API_KEY}}" + } +} +---- +// TEST[skip: no_access_to_ml] + +Let's start by reranking the results of the `rrf` retriever in our previous example. + +[source,console] +---- +GET retrievers_example/_search +{ + "retriever": { + "text_similarity_reranker": { + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "query_string": { + "query": "(information retrieval) OR (artificial intelligence)", + "default_field": "text" + } + } + } + }, + { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "field": "text", + "inference_id": "my-rerank-model", + "inference_text": "What are the state of the art applications of AI in information retrieval?" + } + }, + "_source": false +} + +---- +// TEST[skip: no_access_to_ml] + +[discrete] +[[retrievers-examples-rrf-ranking-on-text-similarity-reranker-results]] +==== Example: RRF with semantic reranker + +For this example, we'll replace the rrf's `standard` retriever with the `text_similarity_reranker` retriever, using the +`my-rerank-model` reranker we previously configured. Since this is a reranker, it needs an initial pool of +documents to work with. In this case, we'll rerank the top `rank_window_size` documents matching the `ai` topic. + +[source,console] +---- +GET /retrievers_example/_search +{ + "retriever": { + "rrf": { + "retrievers": [ + { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + }, + { + "text_similarity_reranker": { + "retriever": { + "standard": { + "query": { + "term": { + "topic": "ai" + } + } + } + }, + "field": "text", + "inference_id": "my-rerank-model", + "inference_text": "Can I use generative AI to identify user intent and improve search relevance?" + } + } + ], + "rank_window_size": 10, + "rank_constant": 1 + } + }, + "_source": false +} +---- +// TEST[skip: no_access_to_ml] + +[discrete] +[[retrievers-examples-chaining-text-similarity-reranker-retrievers]] +==== Example: Chaining multiple semantic rerankers + +Full composability means we can chain together multiple retrievers of the same type. For instance, +imagine we have a computationally expensive reranker that's specialized for AI content. We can rerank the results of a `text_similarity_reranker` using another `text_similarity_reranker` retriever. Each reranker can operate on different fields and/or use different inference services. + +[source,console] +---- +GET retrievers_example/_search +{ + "retriever": { + "text_similarity_reranker": { + "retriever": { + "text_similarity_reranker": { + "retriever": { + "knn": { + "field": "vector", + "query_vector": [ + 0.23, + 0.67, + 0.89 + ], + "k": 3, + "num_candidates": 5 + } + }, + "rank_window_size": 100, + "field": "text", + "inference_id": "my-rerank-model", + "inference_text": "What are the state of the art applications of AI in information retrieval?" + } + }, + "rank_window_size": 10, + "field": "text", + "inference_id": "my-other-more-expensive-rerank-model", + "inference_text": "Applications of Large Language Models in technology and their impact on user satisfaction" + } + }, + "_source": false +} +---- +// TEST[skip: no_access_to_ml] + +Note that our example applies two reranking steps. First, we rerank the top 100 +documents from the `knn` search using the `my-rerank-model` reranker. Then we +pick the top 10 results and rerank them using the more fine-grained +`my-other-more-expensive-rerank-model`. From 6417e0912f2876c00f4e3b970af84875f23cd943 Mon Sep 17 00:00:00 2001 From: Dimitris Rempapis Date: Fri, 29 Nov 2024 14:53:20 +0200 Subject: [PATCH 116/129] CrossClusterIT testCancel failure (#117750) Investigate and fix test failure --- docs/changelog/117750.yaml | 6 ++++++ .../java/org/elasticsearch/search/ccs/CrossClusterIT.java | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/117750.yaml diff --git a/docs/changelog/117750.yaml b/docs/changelog/117750.yaml new file mode 100644 index 0000000000000..3ba3f1693f4df --- /dev/null +++ b/docs/changelog/117750.yaml @@ -0,0 +1,6 @@ +pr: 117750 +summary: '`CrossClusterIT` `testCancel` failure' +area: Search +type: bug +issues: + - 108061 diff --git a/server/src/internalClusterTest/java/org/elasticsearch/search/ccs/CrossClusterIT.java b/server/src/internalClusterTest/java/org/elasticsearch/search/ccs/CrossClusterIT.java index 5d2d5c917415a..cb4d0681cdb23 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/search/ccs/CrossClusterIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/search/ccs/CrossClusterIT.java @@ -63,6 +63,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -189,7 +190,6 @@ public void testProxyConnectionDisconnect() throws Exception { } } - @AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/108061") public void testCancel() throws Exception { assertAcked(client(LOCAL_CLUSTER).admin().indices().prepareCreate("demo")); indexDocs(client(LOCAL_CLUSTER), "demo"); @@ -307,7 +307,7 @@ public void testCancel() throws Exception { } }); - RuntimeException e = expectThrows(RuntimeException.class, () -> queryFuture.result()); + ExecutionException e = expectThrows(ExecutionException.class, () -> queryFuture.result()); assertNotNull(e); assertNotNull(e.getCause()); Throwable t = ExceptionsHelper.unwrap(e, TaskCancelledException.class); From e19f2b7fbb908228a9b53821e275b8ccb58e7029 Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Fri, 29 Nov 2024 17:22:37 +0400 Subject: [PATCH 117/129] Remove unsupported async_search parameters from rest-api-spec (#117626) --- .../rest-api-spec/api/async_search.submit.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json b/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json index a7a7ebe838eab..3de0dec85f547 100644 --- a/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json +++ b/rest-api-spec/src/main/resources/rest-api-spec/api/async_search.submit.json @@ -43,11 +43,6 @@ "description":"Control whether the response should be stored in the cluster if it completed within the provided [wait_for_completion] time (default: false)", "default":false }, - "keep_alive": { - "type": "time", - "description": "Update the time interval in which the results (partial or final) for this search will be available", - "default": "5d" - }, "batched_reduce_size":{ "type":"number", "description":"The number of shard results that should be reduced at once on the coordinating node. This value should be used as the granularity at which progress results will be made available.", @@ -131,11 +126,6 @@ "type":"string", "description":"Specify the node or shard the operation should be performed on (default: random)" }, - "pre_filter_shard_size":{ - "type":"number", - "default": 1, - "description":"Cannot be changed: this is to enforce the execution of a pre-filter roundtrip to retrieve statistics from each shard so that the ones that surely don’t hold any document matching the query get skipped." - }, "rest_total_hits_as_int":{ "type":"boolean", "description":"Indicates whether hits.total should be rendered as an integer or an object in the rest search response", From 60ce74a7870a9e050ddac64900c3b35682e8e355 Mon Sep 17 00:00:00 2001 From: Tommaso Teofili Date: Fri, 29 Nov 2024 15:38:12 +0100 Subject: [PATCH 118/129] mute csv test for scoring in esql for mixed cluster (#117767) --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index 96631d15f374f..f5f6b84ab8639 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -222,6 +222,9 @@ tests: - class: "org.elasticsearch.xpack.esql.qa.single_node.EsqlSpecIT" method: "test {scoring.*}" issue: https://github.com/elastic/elasticsearch/issues/117641 +- class: "org.elasticsearch.xpack.esql.qa.mixed.MixedClusterEsqlSpecIT" + method: "test {scoring.*}" + issue: https://github.com/elastic/elasticsearch/issues/117641 - class: org.elasticsearch.xpack.inference.InferenceCrudIT method: testSupportedStream issue: https://github.com/elastic/elasticsearch/issues/117745 From 5f045c05811ffd30f480d08403e3139c9686d97b Mon Sep 17 00:00:00 2001 From: Jan Kuipers <148754765+jan-elastic@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:20:39 +0100 Subject: [PATCH 119/129] One Categorize BlockHash (#117723) * Move all categorize blockhash code to one "CategorizeBlockHash". * close resources in case of failure --- .../AbstractCategorizeBlockHash.java | 132 -------- .../aggregation/blockhash/BlockHash.java | 4 +- .../blockhash/CategorizeBlockHash.java | 309 ++++++++++++++++++ .../blockhash/CategorizeRawBlockHash.java | 147 --------- .../CategorizedIntermediateBlockHash.java | 92 ------ .../blockhash/CategorizeBlockHashTests.java | 8 +- .../function/grouping/Categorize.java | 6 +- 7 files changed, 315 insertions(+), 383 deletions(-) delete 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/CategorizeBlockHash.java delete mode 100644 x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java delete mode 100644 x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java 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 deleted file mode 100644 index 0e89d77820883..0000000000000 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/AbstractCategorizeBlockHash.java +++ /dev/null @@ -1,132 +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.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.aggregation.SeenGroupIds; -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.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 { - protected static final int NULL_ORD = 0; - - // TODO: this should probably also take an emitBatchSize - private final int channel; - private final boolean outputPartial; - protected final TokenListCategorizer.CloseableTokenListCategorizer categorizer; - - /** - * Store whether we've seen any {@code null} values. - *

- * Null gets the {@link #NULL_ORD} ord. - *

- */ - protected boolean seenNull = false; - - 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(seenNull ? 0 : 1, categorizer.getCategoryCount() + 1, blockFactory); - } - - @Override - public BitArray seenGroupIds(BigArrays bigArrays) { - return new SeenGroupIds.Range(seenNull ? 0 : 1, Math.toIntExact(categorizer.getCategoryCount() + 1)).seenGroupIds(bigArrays); - } - - @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(seenNull ? 1 : 0); - } - try (BytesStreamOutput out = new BytesStreamOutput()) { - // TODO be more careful here. - out.writeBoolean(seenNull); - 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! - int positionCount = categorizer.getCategoryCount() + (seenNull ? 1 : 0); - return blockFactory.newConstantBytesRefBlockWith(out.bytes().toBytesRef(), positionCount); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private Block buildFinalBlock() { - BytesRefBuilder scratch = new BytesRefBuilder(); - - if (seenNull) { - try (BytesRefBlock.Builder result = blockFactory.newBytesRefBlockBuilder(categorizer.getCategoryCount())) { - result.appendNull(); - for (SerializableTokenListCategory category : categorizer.toCategoriesById()) { - scratch.copyChars(category.getRegex()); - result.appendBytesRef(scratch.get()); - scratch.clear(); - } - return result.build(); - } - } - - try (BytesRefVector.Builder result = blockFactory.newBytesRefVectorBuilder(categorizer.getCategoryCount())) { - 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 ea76c3bd0a0aa..30afa7ae3128d 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 @@ -180,9 +180,7 @@ public static BlockHash buildCategorizeBlockHash( 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(), analysisRegistry); + return new CategorizeBlockHash(blockFactory, groups.get(0).channel, aggregatorMode, analysisRegistry); } /** diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHash.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHash.java new file mode 100644 index 0000000000000..35c6faf84e623 --- /dev/null +++ b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeBlockHash.java @@ -0,0 +1,309 @@ +/* + * 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.apache.lucene.util.BytesRefBuilder; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +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.aggregation.AggregatorMode; +import org.elasticsearch.compute.aggregation.GroupingAggregatorFunction; +import org.elasticsearch.compute.aggregation.SeenGroupIds; +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.ReleasableIterator; +import org.elasticsearch.core.Releasables; +import org.elasticsearch.index.analysis.AnalysisRegistry; +import org.elasticsearch.xpack.core.ml.job.config.CategorizationAnalyzerConfig; +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 org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Base BlockHash implementation for {@code Categorize} grouping function. + */ +public class CategorizeBlockHash extends BlockHash { + + private static final CategorizationAnalyzerConfig ANALYZER_CONFIG = CategorizationAnalyzerConfig.buildStandardCategorizationAnalyzer( + List.of() + ); + private static final int NULL_ORD = 0; + + // TODO: this should probably also take an emitBatchSize + private final int channel; + private final AggregatorMode aggregatorMode; + private final TokenListCategorizer.CloseableTokenListCategorizer categorizer; + + private final CategorizeEvaluator evaluator; + + /** + * Store whether we've seen any {@code null} values. + *

+ * Null gets the {@link #NULL_ORD} ord. + *

+ */ + private boolean seenNull = false; + + CategorizeBlockHash(BlockFactory blockFactory, int channel, AggregatorMode aggregatorMode, AnalysisRegistry analysisRegistry) { + super(blockFactory); + + this.channel = channel; + this.aggregatorMode = aggregatorMode; + + this.categorizer = new TokenListCategorizer.CloseableTokenListCategorizer( + new CategorizationBytesRefHash(new BytesRefHash(2048, blockFactory.bigArrays())), + CategorizationPartOfSpeechDictionary.getInstance(), + 0.70f + ); + + if (aggregatorMode.isInputPartial() == false) { + CategorizationAnalyzer analyzer; + try { + Objects.requireNonNull(analysisRegistry); + analyzer = new CategorizationAnalyzer(analysisRegistry, ANALYZER_CONFIG); + } catch (Exception e) { + categorizer.close(); + throw new RuntimeException(e); + } + this.evaluator = new CategorizeEvaluator(analyzer); + } else { + this.evaluator = null; + } + } + + @Override + public void add(Page page, GroupingAggregatorFunction.AddInput addInput) { + if (aggregatorMode.isInputPartial() == false) { + addInitial(page, addInput); + } else { + addIntermediate(page, addInput); + } + } + + @Override + public Block[] getKeys() { + return new Block[] { aggregatorMode.isOutputPartial() ? buildIntermediateBlock() : buildFinalBlock() }; + } + + @Override + public IntVector nonEmpty() { + return IntVector.range(seenNull ? 0 : 1, categorizer.getCategoryCount() + 1, blockFactory); + } + + @Override + public BitArray seenGroupIds(BigArrays bigArrays) { + return new SeenGroupIds.Range(seenNull ? 0 : 1, Math.toIntExact(categorizer.getCategoryCount() + 1)).seenGroupIds(bigArrays); + } + + @Override + public final ReleasableIterator lookup(Page page, ByteSizeValue targetBlockSize) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + Releasables.close(evaluator, categorizer); + } + + /** + * Adds initial (raw) input to the state. + */ + private void addInitial(Page page, GroupingAggregatorFunction.AddInput addInput) { + try (IntBlock result = (IntBlock) evaluator.eval(page.getBlock(channel))) { + addInput.add(0, result); + } + } + + /** + * Adds intermediate state to the state. + */ + private void addIntermediate(Page page, GroupingAggregatorFunction.AddInput addInput) { + if (page.getPositionCount() == 0) { + return; + } + BytesRefBlock categorizerState = page.getBlock(channel); + if (categorizerState.areAllValuesNull()) { + seenNull = true; + try (var newIds = blockFactory.newConstantIntVector(NULL_ORD, 1)) { + addInput.add(0, newIds); + } + return; + } + + Map idMap = readIntermediate(categorizerState.getBytesRef(0, new BytesRef())); + try (IntBlock.Builder newIdsBuilder = blockFactory.newIntBlockBuilder(idMap.size())) { + int fromId = idMap.containsKey(0) ? 0 : 1; + int toId = fromId + idMap.size(); + for (int i = fromId; i < toId; 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()) { + if (in.readBoolean()) { + seenNull = true; + idMap.put(NULL_ORD, NULL_ORD); + } + int count = in.readVInt(); + for (int oldCategoryId = 0; oldCategoryId < count; oldCategoryId++) { + int newCategoryId = categorizer.mergeWireCategory(new SerializableTokenListCategory(in)).getId(); + // +1 because the 0 ordinal is reserved for null + idMap.put(oldCategoryId + 1, newCategoryId + 1); + } + return idMap; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 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(seenNull ? 1 : 0); + } + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeBoolean(seenNull); + 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! + int positionCount = categorizer.getCategoryCount() + (seenNull ? 1 : 0); + return blockFactory.newConstantBytesRefBlockWith(out.bytes().toBytesRef(), positionCount); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Block buildFinalBlock() { + BytesRefBuilder scratch = new BytesRefBuilder(); + + if (seenNull) { + try (BytesRefBlock.Builder result = blockFactory.newBytesRefBlockBuilder(categorizer.getCategoryCount())) { + result.appendNull(); + for (SerializableTokenListCategory category : categorizer.toCategoriesById()) { + scratch.copyChars(category.getRegex()); + result.appendBytesRef(scratch.get()); + scratch.clear(); + } + return result.build(); + } + } + + try (BytesRefVector.Builder result = blockFactory.newBytesRefVectorBuilder(categorizer.getCategoryCount())) { + for (SerializableTokenListCategory category : categorizer.toCategoriesById()) { + scratch.copyChars(category.getRegex()); + result.appendBytesRef(scratch.get()); + scratch.clear(); + } + return result.build().asBlock(); + } + } + + /** + * Similar implementation to an Evaluator. + */ + private final class CategorizeEvaluator implements Releasable { + private final CategorizationAnalyzer analyzer; + + CategorizeEvaluator(CategorizationAnalyzer analyzer) { + this.analyzer = analyzer; + } + + 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(); + } + + 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)) { + seenNull = true; + result.appendInt(NULL_ORD); + 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(); + } + } + + 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(); + } + } + + int process(BytesRef v) { + var category = categorizer.computeCategory(v.utf8ToString(), analyzer); + if (category == null) { + seenNull = true; + return NULL_ORD; + } + return category.getId() + 1; + } + + @Override + public void close() { + analyzer.close(); + } + } +} 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 deleted file mode 100644 index 47dd7f650dffa..0000000000000 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizeRawBlockHash.java +++ /dev/null @@ -1,147 +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.compute.aggregation.blockhash; - -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.AnalysisRegistry; -import org.elasticsearch.xpack.core.ml.job.config.CategorizationAnalyzerConfig; -import org.elasticsearch.xpack.ml.aggs.categorization.TokenListCategorizer; -import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer; - -import java.io.IOException; -import java.util.List; - -/** - * 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 static final CategorizationAnalyzerConfig ANALYZER_CONFIG = CategorizationAnalyzerConfig.buildStandardCategorizationAnalyzer( - List.of() - ); - - private final CategorizeEvaluator evaluator; - - CategorizeRawBlockHash(int channel, BlockFactory blockFactory, boolean outputPartial, AnalysisRegistry analysisRegistry) { - super(blockFactory, channel, outputPartial); - - CategorizationAnalyzer analyzer; - try { - analyzer = new CategorizationAnalyzer(analysisRegistry, ANALYZER_CONFIG); - } catch (IOException e) { - categorizer.close(); - throw new RuntimeException(e); - } - - 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 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)) { - seenNull = true; - result.appendInt(NULL_ORD); - 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) { - var category = categorizer.computeCategory(v.utf8ToString(), analyzer); - if (category == null) { - seenNull = true; - return NULL_ORD; - } - return category.getId() + 1; - } - - @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 deleted file mode 100644 index c774d3b26049d..0000000000000 --- a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/blockhash/CategorizedIntermediateBlockHash.java +++ /dev/null @@ -1,92 +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.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()); - if (categorizerState.areAllValuesNull()) { - seenNull = true; - try (var newIds = blockFactory.newConstantIntVector(NULL_ORD, 1)) { - addInput.add(0, newIds); - } - return; - } - - Map idMap = readIntermediate(categorizerState.getBytesRef(0, new BytesRef())); - try (IntBlock.Builder newIdsBuilder = blockFactory.newIntBlockBuilder(idMap.size())) { - int fromId = idMap.containsKey(0) ? 0 : 1; - int toId = fromId + idMap.size(); - for (int i = fromId; i < toId; 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()) { - if (in.readBoolean()) { - seenNull = true; - idMap.put(NULL_ORD, NULL_ORD); - } - int count = in.readVInt(); - for (int oldCategoryId = 0; oldCategoryId < count; oldCategoryId++) { - int newCategoryId = categorizer.mergeWireCategory(new SerializableTokenListCategory(in)).getId(); - // +1 because the 0 ordinal is reserved for null - idMap.put(oldCategoryId + 1, newCategoryId + 1); - } - return idMap; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public void close() { - categorizer.close(); - } -} 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 index 8a3c723557151..3c47e85a4a9c8 100644 --- 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 @@ -95,7 +95,7 @@ public void testCategorizeRaw() { page = new Page(builder.build()); } - try (BlockHash hash = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry)) { + try (BlockHash hash = new CategorizeBlockHash(blockFactory, 0, AggregatorMode.INITIAL, analysisRegistry)) { hash.add(page, new GroupingAggregatorFunction.AddInput() { @Override public void add(int positionOffset, IntBlock groupIds) { @@ -168,8 +168,8 @@ public void testCategorizeIntermediate() { // Fill intermediatePages with the intermediate state from the raw hashes try ( - BlockHash rawHash1 = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry); - BlockHash rawHash2 = new CategorizeRawBlockHash(0, blockFactory, true, analysisRegistry); + BlockHash rawHash1 = new CategorizeBlockHash(blockFactory, 0, AggregatorMode.INITIAL, analysisRegistry); + BlockHash rawHash2 = new CategorizeBlockHash(blockFactory, 0, AggregatorMode.INITIAL, analysisRegistry); ) { rawHash1.add(page1, new GroupingAggregatorFunction.AddInput() { @Override @@ -226,7 +226,7 @@ public void close() { page2.releaseBlocks(); } - try (BlockHash intermediateHash = new CategorizedIntermediateBlockHash(0, blockFactory, true)) { + try (BlockHash intermediateHash = new CategorizeBlockHash(blockFactory, 0, AggregatorMode.INTERMEDIATE, null)) { intermediateHash.add(intermediatePage1, new GroupingAggregatorFunction.AddInput() { @Override public void add(int positionOffset, IntBlock groupIds) { 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 31b603ecef889..63b5073c2217a 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 @@ -32,12 +32,8 @@ * This function has no evaluators, as it works like an aggregation (Accumulates values, stores intermediate states, etc). *

*

- * For the implementation, see: + * For the implementation, see {@link org.elasticsearch.compute.aggregation.blockhash.CategorizeBlockHash} *

- *
    - *
  • {@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( From 64107e0a0b032c0ee1ed319f0d6bfefce23def9a Mon Sep 17 00:00:00 2001 From: Alexander Spies Date: Fri, 29 Nov 2024 17:34:05 +0100 Subject: [PATCH 120/129] Compute output of LookupJoinExec dynamically (#117763) LookupJoinExec should not assume its output but instead compute it from - Its input fields from the left - The fields added from the lookup index Currently, LookupJoinExec's output is determined when the logical plan is mapped to a physical one, and thereafter the output cannot be changed anymore. This makes it impossible to have late materialization of fields from the left hand side via field extractions, because we are forced to extract *all* fields before the LookupJoinExec, otherwise we do not achieve the prescribed output. Avoid that by tracking only which fields the LookupJoinExec will add from the lookup index instead of tracking the whole output (that was only correct for the logical plan). **Note:** While this PR is a refactoring for the current functionality, it should unblock @craigtaverner 's ongoing work related to field extractions and getting multiple LOOKUP JOIN queries to work correctly without adding hacks. --- .../xpack/esql/ccq/MultiClusterSpecIT.java | 4 +- .../src/main/resources/lookup-join.csv-spec | 10 +-- .../xpack/esql/action/EsqlCapabilities.java | 2 +- .../optimizer/PhysicalOptimizerRules.java | 32 --------- .../physical/local/InsertFieldExtraction.java | 4 +- .../xpack/esql/plan/logical/join/Join.java | 31 +++++---- .../esql/plan/physical/LookupJoinExec.java | 65 ++++++++----------- .../esql/planner/LocalExecutionPlanner.java | 6 +- .../esql/planner/mapper/LocalMapper.java | 10 +-- .../xpack/esql/planner/mapper/Mapper.java | 10 +-- .../elasticsearch/xpack/esql/CsvTests.java | 2 +- 11 files changed, 60 insertions(+), 116 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 8f4522573f880..af5eadc7358a2 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_V2; +import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.JOIN_LOOKUP_V3; 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_V2.capabilityName())); + assumeFalse("LOOKUP JOIN not yet supported in CCS", testCase.requiredCapabilities.contains(JOIN_LOOKUP_V3.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 11786fb905c60..5de353978b307 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 @@ -5,7 +5,7 @@ //TODO: this sometimes returns null instead of the looked up value (likely related to the execution order) basicOnTheDataNode-Ignore -required_capability: join_lookup_v2 +required_capability: join_lookup_v3 FROM employees | EVAL language_code = languages @@ -22,7 +22,7 @@ emp_no:integer | language_code:integer | language_name:keyword ; basicRow-Ignore -required_capability: join_lookup +required_capability: join_lookup_v3 ROW language_code = 1 | LOOKUP JOIN languages_lookup ON language_code @@ -33,7 +33,7 @@ language_code:keyword | language_name:keyword ; basicOnTheCoordinator -required_capability: join_lookup_v2 +required_capability: join_lookup_v3 FROM employees | SORT emp_no @@ -51,7 +51,7 @@ emp_no:integer | language_code:integer | language_name:keyword //TODO: this sometimes returns null instead of the looked up value (likely related to the execution order) subsequentEvalOnTheDataNode-Ignore -required_capability: join_lookup_v2 +required_capability: join_lookup_v3 FROM employees | EVAL language_code = languages @@ -69,7 +69,7 @@ emp_no:integer | language_code:integer | language_name:keyword | language_code_x ; subsequentEvalOnTheCoordinator -required_capability: join_lookup_v2 +required_capability: join_lookup_v3 FROM employees | SORT emp_no 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 373be23cdf847..dc3329a906741 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 @@ -521,7 +521,7 @@ public enum Cap { /** * LOOKUP JOIN */ - JOIN_LOOKUP_V2(Build.current().isSnapshot()), + JOIN_LOOKUP_V3(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/optimizer/PhysicalOptimizerRules.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/PhysicalOptimizerRules.java index 482a89b50c865..ee192c2420da8 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/PhysicalOptimizerRules.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/PhysicalOptimizerRules.java @@ -7,8 +7,6 @@ package org.elasticsearch.xpack.esql.optimizer; -import org.elasticsearch.xpack.esql.core.expression.Expression; -import org.elasticsearch.xpack.esql.core.util.ReflectionUtils; import org.elasticsearch.xpack.esql.optimizer.rules.logical.OptimizerRules.TransformDirection; import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan; import org.elasticsearch.xpack.esql.rule.ParameterizedRule; @@ -62,34 +60,4 @@ public final PhysicalPlan apply(PhysicalPlan plan) { protected abstract PhysicalPlan rule(SubPlan plan); } - - public abstract static class OptimizerExpressionRule extends Rule { - - private final TransformDirection direction; - // overriding type token which returns the correct class but does an uncheck cast to LogicalPlan due to its generic bound - // a proper solution is to wrap the Expression rule into a Plan rule but that would affect the rule declaration - // so instead this is hacked here - private final Class expressionTypeToken = ReflectionUtils.detectSuperTypeForRuleLike(getClass()); - - public OptimizerExpressionRule(TransformDirection direction) { - this.direction = direction; - } - - @Override - public final PhysicalPlan apply(PhysicalPlan plan) { - return direction == TransformDirection.DOWN - ? plan.transformExpressionsDown(expressionTypeToken, this::rule) - : plan.transformExpressionsUp(expressionTypeToken, this::rule); - } - - protected PhysicalPlan rule(PhysicalPlan plan) { - return plan; - } - - protected abstract Expression rule(E e); - - public Class expressionToken() { - return expressionTypeToken; - } - } } 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 72573821dfeb8..cafe3726f92ac 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 @@ -104,15 +104,15 @@ private static Set missingAttributes(PhysicalPlan p) { var missing = new LinkedHashSet(); var inputSet = p.inputSet(); - // FIXME: the extractors should work on the right side as well + // TODO: We need to extract whatever fields are missing from the left hand side. // skip the lookup join since the right side is always materialized and a projection if (p instanceof LookupJoinExec join) { - // collect fields used in the join condition return Collections.emptySet(); } var input = inputSet; // collect field attributes used inside expressions + // TODO: Rather than going over all expressions manually, this should just call .references() p.forEachExpression(TypedAttribute.class, f -> { if (f instanceof FieldAttribute || f instanceof MetadataAttribute) { if (input.contains(f) == false) { 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 dd6b3ea3455f7..6af29fb23b3bb 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 @@ -11,7 +11,7 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.xpack.esql.core.expression.Attribute; -import org.elasticsearch.xpack.esql.core.expression.NamedExpression; +import org.elasticsearch.xpack.esql.core.expression.AttributeSet; 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,12 +23,9 @@ import java.util.ArrayList; import java.util.List; 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; public class Join extends BinaryPlan { public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(LogicalPlan.class, "Join", Join::new); @@ -100,6 +97,19 @@ public List output() { return lazyOutput; } + public List rightOutputFields() { + AttributeSet leftInputs = left().outputSet(); + + List rightOutputFields = new ArrayList<>(); + for (Attribute attr : output()) { + if (leftInputs.contains(attr) == false) { + rightOutputFields.add(attr); + } + } + + return rightOutputFields; + } + /** * Combine the two lists of attributes into one. * In case of (name) conflicts, specify which sides wins, that is overrides the other column - the left or the right. @@ -108,18 +118,11 @@ 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 except for match fields, which we preserve from the left - List rightOutputWithoutMatchFields = rightOutput.stream() - .filter(attr -> matchFieldNames.contains(attr.name()) == false) - .toList(); + // right side becomes nullable and overrides left except for join keys, which we preserve from the left + AttributeSet rightKeys = new AttributeSet(config.rightFields()); + List rightOutputWithoutMatchFields = rightOutput.stream().filter(attr -> rightKeys.contains(attr) == false).toList(); output = mergeOutputAttributes(rightOutputWithoutMatchFields, leftOutput); - } else if (RIGHT.equals(joinType)) { - List leftOutputWithoutMatchFields = leftOutput.stream() - .filter(attr -> matchFieldNames.contains(attr.name()) == false) - .toList(); - output = mergeOutputAttributes(leftOutputWithoutMatchFields, rightOutput); } else { throw new IllegalArgumentException(joinType.joinName() + " unsupported"); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/LookupJoinExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/LookupJoinExec.java index e01451ceaecac..2d3caa27da4cd 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/LookupJoinExec.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/LookupJoinExec.java @@ -19,7 +19,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -30,43 +29,43 @@ public class LookupJoinExec extends BinaryExec implements EstimatesRowSize { LookupJoinExec::new ); - private final List matchFields; private final List leftFields; private final List rightFields; - private final List output; - private List lazyAddedFields; + /** + * These cannot be computed from the left + right outputs, because + * {@link org.elasticsearch.xpack.esql.optimizer.rules.physical.local.ReplaceSourceAttributes} will replace the {@link EsSourceExec} on + * the right hand side by a {@link EsQueryExec}, and thus lose the information of which fields we'll get from the lookup index. + */ + private final List addedFields; + private List lazyOutput; public LookupJoinExec( Source source, PhysicalPlan left, PhysicalPlan lookup, - List matchFields, List leftFields, List rightFields, - List output + List addedFields ) { super(source, left, lookup); - this.matchFields = matchFields; this.leftFields = leftFields; this.rightFields = rightFields; - this.output = output; + this.addedFields = addedFields; } private LookupJoinExec(StreamInput in) throws IOException { super(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(PhysicalPlan.class), in.readNamedWriteable(PhysicalPlan.class)); - this.matchFields = in.readNamedWriteableCollectionAsList(Attribute.class); this.leftFields = in.readNamedWriteableCollectionAsList(Attribute.class); this.rightFields = in.readNamedWriteableCollectionAsList(Attribute.class); - this.output = in.readNamedWriteableCollectionAsList(Attribute.class); + this.addedFields = in.readNamedWriteableCollectionAsList(Attribute.class); } @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); - out.writeNamedWriteableCollection(matchFields); out.writeNamedWriteableCollection(leftFields); out.writeNamedWriteableCollection(rightFields); - out.writeNamedWriteableCollection(output); + out.writeNamedWriteableCollection(addedFields); } @Override @@ -78,10 +77,6 @@ public PhysicalPlan lookup() { return right(); } - public List matchFields() { - return matchFields; - } - public List leftFields() { return leftFields; } @@ -91,29 +86,26 @@ public List rightFields() { } public List addedFields() { - if (lazyAddedFields == null) { - AttributeSet set = outputSet(); - set.removeAll(left().output()); - for (Attribute m : matchFields) { - set.removeIf(a -> a.name().equals(m.name())); + return addedFields; + } + + @Override + public List output() { + if (lazyOutput == null) { + lazyOutput = new ArrayList<>(left().output()); + for (Attribute attr : addedFields) { + lazyOutput.add(attr); } - lazyAddedFields = new ArrayList<>(set); - lazyAddedFields.sort(Comparator.comparing(Attribute::name)); } - return lazyAddedFields; + return lazyOutput; } @Override public PhysicalPlan estimateRowSize(State state) { - state.add(false, output); + state.add(false, output()); return this; } - @Override - public List output() { - return output; - } - @Override public AttributeSet inputSet() { // TODO: this is a hack since the right side is always materialized - instead this should @@ -129,12 +121,12 @@ protected AttributeSet computeReferences() { @Override public LookupJoinExec replaceChildren(PhysicalPlan left, PhysicalPlan right) { - return new LookupJoinExec(source(), left, right, matchFields, leftFields, rightFields, output); + return new LookupJoinExec(source(), left, right, leftFields, rightFields, addedFields); } @Override protected NodeInfo info() { - return NodeInfo.create(this, LookupJoinExec::new, left(), right(), matchFields, leftFields, rightFields, output); + return NodeInfo.create(this, LookupJoinExec::new, left(), right(), leftFields, rightFields, addedFields); } @Override @@ -148,15 +140,12 @@ public boolean equals(Object o) { if (super.equals(o) == false) { return false; } - LookupJoinExec hash = (LookupJoinExec) o; - return matchFields.equals(hash.matchFields) - && leftFields.equals(hash.leftFields) - && rightFields.equals(hash.rightFields) - && output.equals(hash.output); + LookupJoinExec other = (LookupJoinExec) o; + return leftFields.equals(other.leftFields) && rightFields.equals(other.rightFields) && addedFields.equals(other.addedFields); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), matchFields, leftFields, rightFields, output); + return Objects.hash(super.hashCode(), leftFields, rightFields, addedFields); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java index 1ffc652e54337..a8afaa4d8119b 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java @@ -583,8 +583,8 @@ private PhysicalOperation planLookupJoin(LookupJoinExec join, LocalExecutionPlan if (localSourceExec.indexMode() != IndexMode.LOOKUP) { throw new IllegalArgumentException("can't plan [" + join + "]"); } - List matchFields = new ArrayList<>(join.matchFields().size()); - for (Attribute m : join.matchFields()) { + List matchFields = new ArrayList<>(join.leftFields().size()); + for (Attribute m : join.leftFields()) { Layout.ChannelAndType t = source.layout.get(m.id()); if (t == null) { throw new IllegalArgumentException("can't plan [" + join + "][" + m + "]"); @@ -604,7 +604,7 @@ private PhysicalOperation planLookupJoin(LookupJoinExec join, LocalExecutionPlan lookupFromIndexService, matchFields.getFirst().type(), localSourceExec.index().name(), - join.matchFields().getFirst().name(), + join.leftFields().getFirst().name(), join.addedFields().stream().map(f -> (NamedExpression) f).toList(), join.source() ), diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/LocalMapper.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/LocalMapper.java index fc52f2d5a9d23..f95ae0e0783e5 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/LocalMapper.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/LocalMapper.java @@ -120,15 +120,7 @@ private PhysicalPlan mapBinary(BinaryPlan binary) { ); } if (right instanceof EsSourceExec source && source.indexMode() == IndexMode.LOOKUP) { - return new LookupJoinExec( - join.source(), - left, - right, - config.matchFields(), - config.leftFields(), - config.rightFields(), - join.output() - ); + return new LookupJoinExec(join.source(), left, right, config.leftFields(), config.rightFields(), join.rightOutputFields()); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/Mapper.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/Mapper.java index 23e6f4fb91d18..8a4325ed84b2a 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/Mapper.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/Mapper.java @@ -207,15 +207,7 @@ private PhysicalPlan mapBinary(BinaryPlan bp) { if (right instanceof FragmentExec fragment && fragment.fragment() instanceof EsRelation relation && relation.indexMode() == IndexMode.LOOKUP) { - return new LookupJoinExec( - join.source(), - left, - right, - config.matchFields(), - config.leftFields(), - config.rightFields(), - join.output() - ); + return new LookupJoinExec(join.source(), left, right, config.leftFields(), config.rightFields(), join.rightOutputFields()); } } 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 6763988eac638..df974a88a4c57 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_V2.capabilityName()) + testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP_V3.capabilityName()) ); if (Build.current().isSnapshot()) { assertThat( From 39481e912f10f9ce4ca85176b1bee9a9b97c43f6 Mon Sep 17 00:00:00 2001 From: Mikhail Berezovskiy Date: Fri, 29 Nov 2024 09:40:31 -0800 Subject: [PATCH 121/129] trash derived buffers (#117744) --- .../transport/netty4/NettyAllocator.java | 43 -- .../transport/netty4/TrashingByteBuf.java | 536 ++++++++++++++++++ .../transport/netty4/NettyAllocatorTests.java | 1 - 3 files changed, 536 insertions(+), 44 deletions(-) create mode 100644 modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/TrashingByteBuf.java diff --git a/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/NettyAllocator.java b/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/NettyAllocator.java index 1eb7e13889338..e8bd5514947d6 100644 --- a/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/NettyAllocator.java +++ b/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/NettyAllocator.java @@ -362,49 +362,6 @@ public ByteBufAllocator getDelegate() { } } - static class TrashingByteBuf extends WrappedByteBuf { - - private boolean trashed = false; - - protected TrashingByteBuf(ByteBuf buf) { - super(buf); - } - - @Override - public boolean release() { - if (refCnt() == 1) { - // see [NOTE on racy trashContent() calls] - trashContent(); - } - return super.release(); - } - - @Override - public boolean release(int decrement) { - if (refCnt() == decrement && refCnt() > 0) { - // see [NOTE on racy trashContent() calls] - trashContent(); - } - return super.release(decrement); - } - - // [NOTE on racy trashContent() calls]: We trash the buffer content _before_ reducing the ref - // count to zero, which looks racy because in principle a concurrent caller could come along - // and successfully retain() this buffer to keep it alive after it's been trashed. Such a - // caller would sometimes get an IllegalReferenceCountException ofc but that's something it - // could handle - see for instance org.elasticsearch.transport.netty4.Netty4Utils.ByteBufRefCounted.tryIncRef. - // Yet in practice this should never happen, we only ever retain() these buffers while we - // know them to be alive (i.e. via RefCounted#mustIncRef or its moral equivalents) so it'd - // be a bug for a caller to retain() a buffer whose ref count is heading to zero and whose - // contents we've already decided to trash. - private void trashContent() { - if (trashed == false) { - trashed = true; - TrashingByteBufAllocator.trashBuffer(buf); - } - } - } - static class TrashingCompositeByteBuf extends CompositeByteBuf { TrashingCompositeByteBuf(ByteBufAllocator alloc, boolean direct, int maxNumComponents) { diff --git a/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/TrashingByteBuf.java b/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/TrashingByteBuf.java new file mode 100644 index 0000000000000..ead0d595f0105 --- /dev/null +++ b/modules/transport-netty4/src/main/java/org/elasticsearch/transport/netty4/TrashingByteBuf.java @@ -0,0 +1,536 @@ +/* + * 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.transport.netty4; + +import io.netty.buffer.ByteBuf; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +class TrashingByteBuf extends WrappedByteBuf { + + private boolean trashed = false; + + protected TrashingByteBuf(ByteBuf buf) { + super(buf); + } + + static TrashingByteBuf newBuf(ByteBuf buf) { + return new TrashingByteBuf(buf); + } + + @Override + public boolean release() { + if (refCnt() == 1) { + // see [NOTE on racy trashContent() calls] + trashContent(); + } + return super.release(); + } + + @Override + public boolean release(int decrement) { + if (refCnt() == decrement && refCnt() > 0) { + // see [NOTE on racy trashContent() calls] + trashContent(); + } + return super.release(decrement); + } + + // [NOTE on racy trashContent() calls]: We trash the buffer content _before_ reducing the ref + // count to zero, which looks racy because in principle a concurrent caller could come along + // and successfully retain() this buffer to keep it alive after it's been trashed. Such a + // caller would sometimes get an IllegalReferenceCountException ofc but that's something it + // could handle - see for instance org.elasticsearch.transport.netty4.Netty4Utils.ByteBufRefCounted.tryIncRef. + // Yet in practice this should never happen, we only ever retain() these buffers while we + // know them to be alive (i.e. via RefCounted#mustIncRef or its moral equivalents) so it'd + // be a bug for a caller to retain() a buffer whose ref count is heading to zero and whose + // contents we've already decided to trash. + private void trashContent() { + if (trashed == false) { + trashed = true; + NettyAllocator.TrashingByteBufAllocator.trashBuffer(buf); + } + } + + @Override + public ByteBuf capacity(int newCapacity) { + super.capacity(newCapacity); + return this; + } + + @Override + public ByteBuf order(ByteOrder endianness) { + return newBuf(super.order(endianness)); + } + + @Override + public ByteBuf asReadOnly() { + return newBuf(super.asReadOnly()); + } + + @Override + public ByteBuf setIndex(int readerIndex, int writerIndex) { + super.setIndex(readerIndex, writerIndex); + return this; + } + + @Override + public ByteBuf discardReadBytes() { + super.discardReadBytes(); + return this; + } + + @Override + public ByteBuf discardSomeReadBytes() { + super.discardSomeReadBytes(); + return this; + } + + @Override + public ByteBuf ensureWritable(int minWritableBytes) { + super.ensureWritable(minWritableBytes); + return this; + } + + @Override + public ByteBuf getBytes(int index, ByteBuf dst) { + super.getBytes(index, dst); + return this; + } + + @Override + public ByteBuf getBytes(int index, ByteBuf dst, int length) { + super.getBytes(index, dst, length); + return this; + } + + @Override + public ByteBuf getBytes(int index, ByteBuf dst, int dstIndex, int length) { + super.getBytes(index, dst, dstIndex, length); + return this; + } + + @Override + public ByteBuf getBytes(int index, byte[] dst) { + super.getBytes(index, dst); + return this; + } + + @Override + public ByteBuf getBytes(int index, byte[] dst, int dstIndex, int length) { + super.getBytes(index, dst, dstIndex, length); + return this; + } + + @Override + public ByteBuf getBytes(int index, ByteBuffer dst) { + super.getBytes(index, dst); + return this; + } + + @Override + public ByteBuf getBytes(int index, OutputStream out, int length) throws IOException { + super.getBytes(index, out, length); + return this; + } + + @Override + public ByteBuf setBoolean(int index, boolean value) { + super.setBoolean(index, value); + return this; + } + + @Override + public ByteBuf setByte(int index, int value) { + super.setByte(index, value); + return this; + } + + @Override + public ByteBuf setShort(int index, int value) { + super.setShort(index, value); + return this; + } + + @Override + public ByteBuf setShortLE(int index, int value) { + super.setShortLE(index, value); + return this; + } + + @Override + public ByteBuf setMedium(int index, int value) { + super.setMedium(index, value); + return this; + } + + @Override + public ByteBuf setMediumLE(int index, int value) { + super.setMediumLE(index, value); + return this; + } + + @Override + public ByteBuf setInt(int index, int value) { + super.setInt(index, value); + return this; + } + + @Override + public ByteBuf setIntLE(int index, int value) { + super.setIntLE(index, value); + return this; + } + + @Override + public ByteBuf setLong(int index, long value) { + super.setLong(index, value); + return this; + } + + @Override + public ByteBuf setLongLE(int index, long value) { + super.setLongLE(index, value); + return this; + } + + @Override + public ByteBuf setChar(int index, int value) { + super.setChar(index, value); + return this; + } + + @Override + public ByteBuf setFloat(int index, float value) { + super.setFloat(index, value); + return this; + } + + @Override + public ByteBuf setDouble(int index, double value) { + super.setDouble(index, value); + return this; + } + + @Override + public ByteBuf setBytes(int index, ByteBuf src) { + super.setBytes(index, src); + return this; + } + + @Override + public ByteBuf setBytes(int index, ByteBuf src, int length) { + super.setBytes(index, src, length); + return this; + } + + @Override + public ByteBuf setBytes(int index, ByteBuf src, int srcIndex, int length) { + super.setBytes(index, src, srcIndex, length); + return this; + } + + @Override + public ByteBuf setBytes(int index, byte[] src) { + super.setBytes(index, src); + return this; + } + + @Override + public ByteBuf setBytes(int index, byte[] src, int srcIndex, int length) { + super.setBytes(index, src, srcIndex, length); + return this; + } + + @Override + public ByteBuf setBytes(int index, ByteBuffer src) { + super.setBytes(index, src); + return this; + } + + @Override + public ByteBuf readBytes(int length) { + return newBuf(super.readBytes(length)); + } + + @Override + public ByteBuf readSlice(int length) { + return newBuf(super.readSlice(length)); + } + + @Override + public ByteBuf readRetainedSlice(int length) { + return newBuf(super.readRetainedSlice(length)); + } + + @Override + public ByteBuf readBytes(ByteBuf dst) { + super.readBytes(dst); + return this; + } + + @Override + public ByteBuf readBytes(ByteBuf dst, int length) { + super.readBytes(dst, length); + return this; + } + + @Override + public ByteBuf readBytes(ByteBuf dst, int dstIndex, int length) { + super.readBytes(dst, dstIndex, length); + return this; + } + + @Override + public ByteBuf readBytes(byte[] dst) { + super.readBytes(dst); + return this; + } + + @Override + public ByteBuf readBytes(ByteBuffer dst) { + super.readBytes(dst); + return this; + } + + @Override + public ByteBuf readBytes(byte[] dst, int dstIndex, int length) { + super.readBytes(dst, dstIndex, length); + return this; + } + + @Override + public ByteBuf readBytes(OutputStream out, int length) throws IOException { + super.readBytes(out, length); + return this; + } + + @Override + public ByteBuf skipBytes(int length) { + super.skipBytes(length); + return this; + } + + @Override + public ByteBuf writeBoolean(boolean value) { + super.writeBoolean(value); + return this; + } + + @Override + public ByteBuf writeByte(int value) { + super.writeByte(value); + return this; + } + + @Override + public ByteBuf writeShort(int value) { + super.writeShort(value); + return this; + } + + @Override + public ByteBuf writeShortLE(int value) { + super.writeShortLE(value); + return this; + } + + @Override + public ByteBuf writeMedium(int value) { + super.writeMedium(value); + return this; + } + + @Override + public ByteBuf writeMediumLE(int value) { + super.writeMediumLE(value); + return this; + } + + @Override + public ByteBuf writeInt(int value) { + super.writeInt(value); + return this; + + } + + @Override + public ByteBuf writeIntLE(int value) { + super.writeIntLE(value); + return this; + } + + @Override + public ByteBuf writeLong(long value) { + super.writeLong(value); + return this; + } + + @Override + public ByteBuf writeLongLE(long value) { + super.writeLongLE(value); + return this; + } + + @Override + public ByteBuf writeChar(int value) { + super.writeChar(value); + return this; + } + + @Override + public ByteBuf writeFloat(float value) { + super.writeFloat(value); + return this; + } + + @Override + public ByteBuf writeDouble(double value) { + super.writeDouble(value); + return this; + } + + @Override + public ByteBuf writeBytes(ByteBuf src) { + super.writeBytes(src); + return this; + } + + @Override + public ByteBuf writeBytes(ByteBuf src, int length) { + super.writeBytes(src, length); + return this; + } + + @Override + public ByteBuf writeBytes(ByteBuf src, int srcIndex, int length) { + super.writeBytes(src, srcIndex, length); + return this; + } + + @Override + public ByteBuf writeBytes(byte[] src) { + super.writeBytes(src); + return this; + } + + @Override + public ByteBuf writeBytes(byte[] src, int srcIndex, int length) { + super.writeBytes(src, srcIndex, length); + return this; + } + + @Override + public ByteBuf writeBytes(ByteBuffer src) { + super.writeBytes(src); + return this; + } + + @Override + public ByteBuf writeZero(int length) { + super.writeZero(length); + return this; + } + + @Override + public ByteBuf copy() { + return newBuf(super.copy()); + } + + @Override + public ByteBuf copy(int index, int length) { + return newBuf(super.copy(index, length)); + } + + @Override + public ByteBuf slice() { + return newBuf(super.slice()); + } + + @Override + public ByteBuf retainedSlice() { + return newBuf(super.retainedSlice()); + } + + @Override + public ByteBuf slice(int index, int length) { + return newBuf(super.slice(index, length)); + } + + @Override + public ByteBuf retainedSlice(int index, int length) { + return newBuf(super.retainedSlice(index, length)); + } + + @Override + public ByteBuf duplicate() { + return newBuf(super.duplicate()); + } + + @Override + public ByteBuf retainedDuplicate() { + return newBuf(super.retainedDuplicate()); + } + + @Override + public ByteBuf retain(int increment) { + super.retain(increment); + return this; + } + + @Override + public ByteBuf touch(Object hint) { + super.touch(hint); + return this; + } + + @Override + public ByteBuf retain() { + super.retain(); + return this; + } + + @Override + public ByteBuf touch() { + super.touch(); + return this; + } + + @Override + public ByteBuf setFloatLE(int index, float value) { + return super.setFloatLE(index, value); + } + + @Override + public ByteBuf setDoubleLE(int index, double value) { + super.setDoubleLE(index, value); + return this; + } + + @Override + public ByteBuf writeFloatLE(float value) { + super.writeFloatLE(value); + return this; + } + + @Override + public ByteBuf writeDoubleLE(double value) { + super.writeDoubleLE(value); + return this; + } + + @Override + public ByteBuf asByteBuf() { + return this; + } +} diff --git a/modules/transport-netty4/src/test/java/org/elasticsearch/transport/netty4/NettyAllocatorTests.java b/modules/transport-netty4/src/test/java/org/elasticsearch/transport/netty4/NettyAllocatorTests.java index a76eb9fa4875b..b9e9b667e72fe 100644 --- a/modules/transport-netty4/src/test/java/org/elasticsearch/transport/netty4/NettyAllocatorTests.java +++ b/modules/transport-netty4/src/test/java/org/elasticsearch/transport/netty4/NettyAllocatorTests.java @@ -20,7 +20,6 @@ import java.nio.ByteBuffer; import java.util.List; -import static org.elasticsearch.transport.netty4.NettyAllocator.TrashingByteBuf; import static org.elasticsearch.transport.netty4.NettyAllocator.TrashingByteBufAllocator; public class NettyAllocatorTests extends ESTestCase { From 0b764adbc19a99ee14d88b96f5f99002fabc19cb Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Sat, 30 Nov 2024 08:29:46 +1100 Subject: [PATCH 122/129] Mute org.elasticsearch.search.ccs.CrossClusterIT testCancel #108061 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index f5f6b84ab8639..b82e95ea26890 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -231,6 +231,9 @@ tests: - class: org.elasticsearch.xpack.esql.ccq.MultiClusterSpecIT method: test {scoring.QstrWithFieldAndScoringSortedEval} issue: https://github.com/elastic/elasticsearch/issues/117751 +- class: org.elasticsearch.search.ccs.CrossClusterIT + method: testCancel + issue: https://github.com/elastic/elasticsearch/issues/108061 # Examples: # From c74c06daee0583562c82597b19178268b9f415e5 Mon Sep 17 00:00:00 2001 From: Nick Tindall Date: Sat, 30 Nov 2024 11:33:20 +1100 Subject: [PATCH 123/129] Deduplicate Range header parsing (#117304) --- ...CloudStorageBlobContainerRetriesTests.java | 7 +-- .../java/fixture/azure/AzureHttpHandler.java | 23 ++++---- .../gcs/GoogleCloudStorageHttpHandler.java | 17 +++--- test/fixtures/s3-fixture/build.gradle | 2 +- .../main/java/fixture/s3/S3HttpHandler.java | 20 +++---- .../src/main/java/fixture/url/URLFixture.java | 16 +++--- .../AbstractBlobContainerRetriesTestCase.java | 24 ++++----- .../test/fixture/HttpHeaderParser.java | 42 +++++++++++++++ .../http/HttpHeaderParserTests.java | 53 +++++++++++++++++++ 9 files changed, 141 insertions(+), 63 deletions(-) create mode 100644 test/framework/src/main/java/org/elasticsearch/test/fixture/HttpHeaderParser.java create mode 100644 test/framework/src/test/java/org/elasticsearch/http/HttpHeaderParserTests.java diff --git a/modules/repository-gcs/src/test/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainerRetriesTests.java b/modules/repository-gcs/src/test/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainerRetriesTests.java index 110c31b212ea1..a53ec71f66376 100644 --- a/modules/repository-gcs/src/test/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainerRetriesTests.java +++ b/modules/repository-gcs/src/test/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainerRetriesTests.java @@ -41,6 +41,7 @@ import org.elasticsearch.repositories.blobstore.ESMockAPIBasedRepositoryIntegTestCase; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestUtils; +import org.elasticsearch.test.fixture.HttpHeaderParser; import org.threeten.bp.Duration; import java.io.IOException; @@ -177,9 +178,9 @@ public void testReadLargeBlobWithRetries() throws Exception { httpServer.createContext(downloadStorageEndpoint(blobContainer, "large_blob_retries"), exchange -> { Streams.readFully(exchange.getRequestBody()); exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); - final Tuple range = getRange(exchange); - final int offset = Math.toIntExact(range.v1()); - final byte[] chunk = Arrays.copyOfRange(bytes, offset, Math.toIntExact(Math.min(range.v2() + 1, bytes.length))); + final HttpHeaderParser.Range range = getRange(exchange); + final int offset = Math.toIntExact(range.start()); + final byte[] chunk = Arrays.copyOfRange(bytes, offset, Math.toIntExact(Math.min(range.end() + 1, bytes.length))); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), chunk.length); if (randomBoolean() && countDown.decrementAndGet() >= 0) { exchange.getResponseBody().write(chunk, 0, chunk.length - 1); diff --git a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java index 904f4581ad2c9..cb7c700376a1a 100644 --- a/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java +++ b/test/fixtures/azure-fixture/src/main/java/fixture/azure/AzureHttpHandler.java @@ -22,6 +22,7 @@ import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestUtils; +import org.elasticsearch.test.fixture.HttpHeaderParser; import org.elasticsearch.xcontent.XContentBuilder; import org.elasticsearch.xcontent.XContentType; @@ -42,8 +43,6 @@ import java.util.Set; import java.util.UUID; import java.util.function.Predicate; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static fixture.azure.MockAzureBlobStore.failTestWithAssertionError; import static org.elasticsearch.repositories.azure.AzureFixtureHelper.assertValidBlockId; @@ -54,7 +53,6 @@ @SuppressForbidden(reason = "Uses a HttpServer to emulate an Azure endpoint") public class AzureHttpHandler implements HttpHandler { private static final Logger logger = LogManager.getLogger(AzureHttpHandler.class); - private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$"); static final String X_MS_LEASE_ID = "x-ms-lease-id"; static final String X_MS_PROPOSED_LEASE_ID = "x-ms-proposed-lease-id"; static final String X_MS_LEASE_DURATION = "x-ms-lease-duration"; @@ -232,29 +230,26 @@ public void handle(final HttpExchange exchange) throws IOException { final BytesReference responseContent; final RestStatus successStatus; // see Constants.HeaderConstants.STORAGE_RANGE_HEADER - final String range = exchange.getRequestHeaders().getFirst("x-ms-range"); - if (range != null) { - final Matcher matcher = RANGE_HEADER_PATTERN.matcher(range); - if (matcher.matches() == false) { + final String rangeHeader = exchange.getRequestHeaders().getFirst("x-ms-range"); + if (rangeHeader != null) { + final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader); + if (range == null) { throw new MockAzureBlobStore.BadRequestException( "InvalidHeaderValue", - "Range header does not match expected format: " + range + "Range header does not match expected format: " + rangeHeader ); } - final long start = Long.parseLong(matcher.group(1)); - final long end = Long.parseLong(matcher.group(2)); - final BytesReference blobContents = blob.getContents(); - if (blobContents.length() <= start) { + if (blobContents.length() <= range.start()) { exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.sendResponseHeaders(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), -1); return; } responseContent = blobContents.slice( - Math.toIntExact(start), - Math.toIntExact(Math.min(end - start + 1, blobContents.length() - start)) + Math.toIntExact(range.start()), + Math.toIntExact(Math.min(range.end() - range.start() + 1, blobContents.length() - range.start())) ); successStatus = RestStatus.PARTIAL_CONTENT; } else { diff --git a/test/fixtures/gcs-fixture/src/main/java/fixture/gcs/GoogleCloudStorageHttpHandler.java b/test/fixtures/gcs-fixture/src/main/java/fixture/gcs/GoogleCloudStorageHttpHandler.java index 51e3185623360..f6b52a32a9a1d 100644 --- a/test/fixtures/gcs-fixture/src/main/java/fixture/gcs/GoogleCloudStorageHttpHandler.java +++ b/test/fixtures/gcs-fixture/src/main/java/fixture/gcs/GoogleCloudStorageHttpHandler.java @@ -24,6 +24,7 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestUtils; +import org.elasticsearch.test.fixture.HttpHeaderParser; import java.io.BufferedReader; import java.io.IOException; @@ -58,8 +59,6 @@ public class GoogleCloudStorageHttpHandler implements HttpHandler { private static final Logger logger = LogManager.getLogger(GoogleCloudStorageHttpHandler.class); - private static final Pattern RANGE_MATCHER = Pattern.compile("bytes=([0-9]*)-([0-9]*)"); - private final ConcurrentMap blobs; private final String bucket; @@ -131,19 +130,19 @@ public void handle(final HttpExchange exchange) throws IOException { // Download Object https://cloud.google.com/storage/docs/request-body BytesReference blob = blobs.get(exchange.getRequestURI().getPath().replace("/download/storage/v1/b/" + bucket + "/o/", "")); if (blob != null) { - final String range = exchange.getRequestHeaders().getFirst("Range"); + final String rangeHeader = exchange.getRequestHeaders().getFirst("Range"); final long offset; final long end; - if (range == null) { + if (rangeHeader == null) { offset = 0L; end = blob.length() - 1; } else { - Matcher matcher = RANGE_MATCHER.matcher(range); - if (matcher.find() == false) { - throw new AssertionError("Range bytes header does not match expected format: " + range); + final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader); + if (range == null) { + throw new AssertionError("Range bytes header does not match expected format: " + rangeHeader); } - offset = Long.parseLong(matcher.group(1)); - end = Long.parseLong(matcher.group(2)); + offset = range.start(); + end = range.end(); } if (offset >= blob.length()) { diff --git a/test/fixtures/s3-fixture/build.gradle b/test/fixtures/s3-fixture/build.gradle index d628800497293..e4c35464608a8 100644 --- a/test/fixtures/s3-fixture/build.gradle +++ b/test/fixtures/s3-fixture/build.gradle @@ -15,5 +15,5 @@ dependencies { api("junit:junit:${versions.junit}") { transitive = false } - testImplementation project(':test:framework') + implementation project(':test:framework') } diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java index 56d3454aa5544..bfc0428731c56 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java @@ -28,6 +28,7 @@ import org.elasticsearch.logging.Logger; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestUtils; +import org.elasticsearch.test.fixture.HttpHeaderParser; import java.io.IOException; import java.io.InputStreamReader; @@ -269,8 +270,8 @@ public void handle(final HttpExchange exchange) throws IOException { exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), -1); return; } - final String range = exchange.getRequestHeaders().getFirst("Range"); - if (range == null) { + final String rangeHeader = exchange.getRequestHeaders().getFirst("Range"); + if (rangeHeader == null) { exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length()); blob.writeTo(exchange.getResponseBody()); @@ -281,17 +282,12 @@ public void handle(final HttpExchange exchange) throws IOException { // requests with a header value like "Range: bytes=start-end" where both {@code start} and {@code end} are always defined // (sometimes to very high value for {@code end}). It would be too tedious to fully support the RFC so S3HttpHandler only // supports when both {@code start} and {@code end} are defined to match the SDK behavior. - final Matcher matcher = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$").matcher(range); - if (matcher.matches() == false) { - throw new AssertionError("Bytes range does not match expected pattern: " + range); - } - var groupStart = matcher.group(1); - var groupEnd = matcher.group(2); - if (groupStart == null || groupEnd == null) { - throw new AssertionError("Bytes range does not match expected pattern: " + range); + final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader); + if (range == null) { + throw new AssertionError("Bytes range does not match expected pattern: " + rangeHeader); } - long start = Long.parseLong(groupStart); - long end = Long.parseLong(groupEnd); + long start = range.start(); + long end = range.end(); if (end < start) { exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length()); diff --git a/test/fixtures/url-fixture/src/main/java/fixture/url/URLFixture.java b/test/fixtures/url-fixture/src/main/java/fixture/url/URLFixture.java index 4c3159fc3c849..860f6ff141689 100644 --- a/test/fixtures/url-fixture/src/main/java/fixture/url/URLFixture.java +++ b/test/fixtures/url-fixture/src/main/java/fixture/url/URLFixture.java @@ -10,6 +10,7 @@ import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.fixture.AbstractHttpFixture; +import org.elasticsearch.test.fixture.HttpHeaderParser; import org.junit.rules.TemporaryFolder; import org.junit.rules.TestRule; @@ -21,15 +22,12 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * This {@link URLFixture} exposes a filesystem directory over HTTP. It is used in repository-url * integration tests to expose a directory created by a regular FS repository. */ public class URLFixture extends AbstractHttpFixture implements TestRule { - private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(\\d+)-(\\d+)$"); private final TemporaryFolder temporaryFolder; private Path repositoryDir; @@ -60,19 +58,19 @@ private AbstractHttpFixture.Response handleGetRequest(Request request) throws IO if (normalizedPath.startsWith(normalizedRepositoryDir)) { if (Files.exists(normalizedPath) && Files.isReadable(normalizedPath) && Files.isRegularFile(normalizedPath)) { - final String range = request.getHeader("Range"); + final String rangeHeader = request.getHeader("Range"); final Map headers = new HashMap<>(contentType("application/octet-stream")); - if (range == null) { + if (rangeHeader == null) { byte[] content = Files.readAllBytes(normalizedPath); headers.put("Content-Length", String.valueOf(content.length)); return new Response(RestStatus.OK.getStatus(), headers, content); } else { - final Matcher matcher = RANGE_PATTERN.matcher(range); - if (matcher.matches() == false) { + final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader); + if (range == null) { return new Response(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE); } else { - long start = Long.parseLong(matcher.group(1)); - long end = Long.parseLong(matcher.group(2)); + long start = range.start(); + long end = range.end(); long rangeLength = end - start + 1; final long fileSize = Files.size(normalizedPath); if (start >= fileSize || start > end || rangeLength > fileSize) { diff --git a/test/framework/src/main/java/org/elasticsearch/repositories/blobstore/AbstractBlobContainerRetriesTestCase.java b/test/framework/src/main/java/org/elasticsearch/repositories/blobstore/AbstractBlobContainerRetriesTestCase.java index 12094b31a049d..17768c54b2eaf 100644 --- a/test/framework/src/main/java/org/elasticsearch/repositories/blobstore/AbstractBlobContainerRetriesTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/repositories/blobstore/AbstractBlobContainerRetriesTestCase.java @@ -23,9 +23,9 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.core.TimeValue; -import org.elasticsearch.core.Tuple; import org.elasticsearch.mocksocket.MockHttpServer; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.fixture.HttpHeaderParser; import org.junit.After; import org.junit.Before; @@ -40,8 +40,6 @@ import java.util.OptionalInt; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static org.elasticsearch.repositories.blobstore.BlobStoreTestUtil.randomPurpose; import static org.elasticsearch.test.NeverMatcher.never; @@ -371,28 +369,24 @@ protected static byte[] randomBlobContent(int minSize) { return randomByteArrayOfLength(randomIntBetween(minSize, frequently() ? 512 : 1 << 20)); // rarely up to 1mb } - private static final Pattern RANGE_PATTERN = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$"); - - protected static Tuple getRange(HttpExchange exchange) { + protected static HttpHeaderParser.Range getRange(HttpExchange exchange) { final String rangeHeader = exchange.getRequestHeaders().getFirst("Range"); if (rangeHeader == null) { - return Tuple.tuple(0L, MAX_RANGE_VAL); + return new HttpHeaderParser.Range(0L, MAX_RANGE_VAL); } - final Matcher matcher = RANGE_PATTERN.matcher(rangeHeader); - assertTrue(rangeHeader + " matches expected pattern", matcher.matches()); - long rangeStart = Long.parseLong(matcher.group(1)); - long rangeEnd = Long.parseLong(matcher.group(2)); - assertThat(rangeStart, lessThanOrEqualTo(rangeEnd)); - return Tuple.tuple(rangeStart, rangeEnd); + final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader); + assertNotNull(rangeHeader + " matches expected pattern", range); + assertThat(range.start(), lessThanOrEqualTo(range.end())); + return range; } protected static int getRangeStart(HttpExchange exchange) { - return Math.toIntExact(getRange(exchange).v1()); + return Math.toIntExact(getRange(exchange).start()); } protected static OptionalInt getRangeEnd(HttpExchange exchange) { - final long rangeEnd = getRange(exchange).v2(); + final long rangeEnd = getRange(exchange).end(); if (rangeEnd == MAX_RANGE_VAL) { return OptionalInt.empty(); } diff --git a/test/framework/src/main/java/org/elasticsearch/test/fixture/HttpHeaderParser.java b/test/framework/src/main/java/org/elasticsearch/test/fixture/HttpHeaderParser.java new file mode 100644 index 0000000000000..7018e5e259584 --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/test/fixture/HttpHeaderParser.java @@ -0,0 +1,42 @@ +/* + * 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.test.fixture; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public enum HttpHeaderParser { + ; + + private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("bytes=([0-9]+)-([0-9]+)"); + + /** + * Parse a "Range" header + * + * Note: only a single bounded range is supported (e.g. Range: bytes={range_start}-{range_end}) + * + * @see MDN: Range header + * @param rangeHeaderValue The header value as a string + * @return a {@link Range} instance representing the parsed value, or null if the header is malformed + */ + public static Range parseRangeHeader(String rangeHeaderValue) { + final Matcher matcher = RANGE_HEADER_PATTERN.matcher(rangeHeaderValue); + if (matcher.matches()) { + try { + return new Range(Long.parseLong(matcher.group(1)), Long.parseLong(matcher.group(2))); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + public record Range(long start, long end) {} +} diff --git a/test/framework/src/test/java/org/elasticsearch/http/HttpHeaderParserTests.java b/test/framework/src/test/java/org/elasticsearch/http/HttpHeaderParserTests.java new file mode 100644 index 0000000000000..e025e7770ea4c --- /dev/null +++ b/test/framework/src/test/java/org/elasticsearch/http/HttpHeaderParserTests.java @@ -0,0 +1,53 @@ +/* + * 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.http; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.fixture.HttpHeaderParser; + +import java.math.BigInteger; + +public class HttpHeaderParserTests extends ESTestCase { + + public void testParseRangeHeader() { + final long start = randomLongBetween(0, 10_000); + final long end = randomLongBetween(start, start + 10_000); + assertEquals(new HttpHeaderParser.Range(start, end), HttpHeaderParser.parseRangeHeader("bytes=" + start + "-" + end)); + } + + public void testParseRangeHeaderInvalidLong() { + final BigInteger longOverflow = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE).add(randomBigInteger()); + assertNull(HttpHeaderParser.parseRangeHeader("bytes=123-" + longOverflow)); + assertNull(HttpHeaderParser.parseRangeHeader("bytes=" + longOverflow + "-123")); + } + + public void testParseRangeHeaderMultipleRangesNotMatched() { + assertNull( + HttpHeaderParser.parseRangeHeader( + Strings.format( + "bytes=%d-%d,%d-%d", + randomIntBetween(0, 99), + randomIntBetween(100, 199), + randomIntBetween(200, 299), + randomIntBetween(300, 399) + ) + ) + ); + } + + public void testParseRangeHeaderEndlessRangeNotMatched() { + assertNull(HttpHeaderParser.parseRangeHeader(Strings.format("bytes=%d-", randomLongBetween(0, Long.MAX_VALUE)))); + } + + public void testParseRangeHeaderSuffixLengthNotMatched() { + assertNull(HttpHeaderParser.parseRangeHeader(Strings.format("bytes=-%d", randomLongBetween(0, Long.MAX_VALUE)))); + } +} From c77f09e436563fa312db791a9ea4c8ac5d97a623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenzo=20Dematt=C3=A9?= Date: Sat, 30 Nov 2024 09:38:40 +0100 Subject: [PATCH 124/129] [Entitlements] Refactor InstrumenterImpl tests (#117688) Following up https://github.com/elastic/elasticsearch/pull/117332#discussion_r1856803255, I refactored `InstrumenterImpl` tests, splitting them into 2 suites: - `SyntheticInstrumenterImplTests`, which tests the mechanics of instrumentation using ad-hoc test cases. This should see little change now that we have our Instrumenter working as intended - `InstrumenterImplTests`, which is back to its original intent to make sure (1) the right arguments make it all the way to the check methods, and (2) if the check method throws, that exception correctly bubbles up through the instrumented method. The PR also includes a little change to `InstrumenterImpl` construction to clean it up a bit and make it more testable. --- .../impl/InstrumentationServiceImpl.java | 28 +- .../impl/InstrumenterImpl.java | 61 +-- .../impl/InstrumentationServiceImplTests.java | 42 +- .../impl/InstrumenterTests.java | 378 ++++------------- .../impl/SyntheticInstrumenterTests.java | 383 ++++++++++++++++++ .../instrumentation/impl/TestException.java | 12 + .../instrumentation/impl/TestLoader.java | 20 + .../instrumentation/impl/TestMethodUtils.java | 81 ++++ .../EntitlementInitialization.java | 8 +- .../{CheckerMethod.java => CheckMethod.java} | 4 +- .../InstrumentationService.java | 10 +- 11 files changed, 646 insertions(+), 381 deletions(-) create mode 100644 libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/SyntheticInstrumenterTests.java create mode 100644 libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestException.java create mode 100644 libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestLoader.java create mode 100644 libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestMethodUtils.java rename libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/{CheckerMethod.java => CheckMethod.java} (82%) diff --git a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java index 16bd04e60c5e3..9e23d2c0412c3 100644 --- a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java +++ b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java @@ -9,7 +9,7 @@ package org.elasticsearch.entitlement.instrumentation.impl; -import org.elasticsearch.entitlement.instrumentation.CheckerMethod; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; import org.elasticsearch.entitlement.instrumentation.InstrumentationService; import org.elasticsearch.entitlement.instrumentation.Instrumenter; import org.elasticsearch.entitlement.instrumentation.MethodKey; @@ -20,37 +20,23 @@ import org.objectweb.asm.Type; import java.io.IOException; -import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.stream.Stream; public class InstrumentationServiceImpl implements InstrumentationService { @Override - public Instrumenter newInstrumenter(String classNameSuffix, Map instrumentationMethods) { - return new InstrumenterImpl(classNameSuffix, instrumentationMethods); - } - - /** - * @return a {@link MethodKey} suitable for looking up the given {@code targetMethod} in the entitlements trampoline - */ - public MethodKey methodKeyForTarget(Method targetMethod) { - Type actualType = Type.getMethodType(Type.getMethodDescriptor(targetMethod)); - return new MethodKey( - Type.getInternalName(targetMethod.getDeclaringClass()), - targetMethod.getName(), - Stream.of(actualType.getArgumentTypes()).map(Type::getInternalName).toList() - ); + public Instrumenter newInstrumenter(Map checkMethods) { + return InstrumenterImpl.create(checkMethods); } @Override - public Map lookupMethodsToInstrument(String entitlementCheckerClassName) throws ClassNotFoundException, + public Map lookupMethodsToInstrument(String entitlementCheckerClassName) throws ClassNotFoundException, IOException { - var methodsToInstrument = new HashMap(); + var methodsToInstrument = new HashMap(); var checkerClass = Class.forName(entitlementCheckerClassName); var classFileInfo = InstrumenterImpl.getClassFileInfo(checkerClass); ClassReader reader = new ClassReader(classFileInfo.bytecodes()); @@ -69,9 +55,9 @@ public MethodVisitor visitMethod( var methodToInstrument = parseCheckerMethodSignature(checkerMethodName, checkerMethodArgumentTypes); var checkerParameterDescriptors = Arrays.stream(checkerMethodArgumentTypes).map(Type::getDescriptor).toList(); - var checkerMethod = new CheckerMethod(Type.getInternalName(checkerClass), checkerMethodName, checkerParameterDescriptors); + var checkMethod = new CheckMethod(Type.getInternalName(checkerClass), checkerMethodName, checkerParameterDescriptors); - methodsToInstrument.put(methodToInstrument, checkerMethod); + methodsToInstrument.put(methodToInstrument, checkMethod); return mv; } diff --git a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java index 4d762dc997383..57e30c01c5c28 100644 --- a/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java +++ b/libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java @@ -9,7 +9,7 @@ package org.elasticsearch.entitlement.instrumentation.impl; -import org.elasticsearch.entitlement.instrumentation.CheckerMethod; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; import org.elasticsearch.entitlement.instrumentation.Instrumenter; import org.elasticsearch.entitlement.instrumentation.MethodKey; import org.objectweb.asm.AnnotationVisitor; @@ -37,9 +37,28 @@ public class InstrumenterImpl implements Instrumenter { - private static final String checkerClassDescriptor; - private static final String handleClass; - static { + private final String getCheckerClassMethodDescriptor; + private final String handleClass; + + /** + * To avoid class name collisions during testing without an agent to replace classes in-place. + */ + private final String classNameSuffix; + private final Map checkMethods; + + InstrumenterImpl( + String handleClass, + String getCheckerClassMethodDescriptor, + String classNameSuffix, + Map checkMethods + ) { + this.handleClass = handleClass; + this.getCheckerClassMethodDescriptor = getCheckerClassMethodDescriptor; + this.classNameSuffix = classNameSuffix; + this.checkMethods = checkMethods; + } + + static String getCheckerClassName() { int javaVersion = Runtime.version().feature(); final String classNamePrefix; if (javaVersion >= 23) { @@ -47,20 +66,14 @@ public class InstrumenterImpl implements Instrumenter { } else { classNamePrefix = ""; } - String checkerClass = "org/elasticsearch/entitlement/bridge/" + classNamePrefix + "EntitlementChecker"; - handleClass = checkerClass + "Handle"; - checkerClassDescriptor = Type.getObjectType(checkerClass).getDescriptor(); + return "org/elasticsearch/entitlement/bridge/" + classNamePrefix + "EntitlementChecker"; } - /** - * To avoid class name collisions during testing without an agent to replace classes in-place. - */ - private final String classNameSuffix; - private final Map instrumentationMethods; - - public InstrumenterImpl(String classNameSuffix, Map instrumentationMethods) { - this.classNameSuffix = classNameSuffix; - this.instrumentationMethods = instrumentationMethods; + public static InstrumenterImpl create(Map checkMethods) { + String checkerClass = getCheckerClassName(); + String handleClass = checkerClass + "Handle"; + String getCheckerClassMethodDescriptor = Type.getMethodDescriptor(Type.getObjectType(checkerClass)); + return new InstrumenterImpl(handleClass, getCheckerClassMethodDescriptor, "", checkMethods); } public ClassFileInfo instrumentClassFile(Class clazz) throws IOException { @@ -156,7 +169,7 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, Str boolean isStatic = (access & ACC_STATIC) != 0; boolean isCtor = "".equals(name); var key = new MethodKey(className, name, Stream.of(Type.getArgumentTypes(descriptor)).map(Type::getInternalName).toList()); - var instrumentationMethod = instrumentationMethods.get(key); + var instrumentationMethod = checkMethods.get(key); if (instrumentationMethod != null) { // LOGGER.debug("Will instrument method {}", key); return new EntitlementMethodVisitor(Opcodes.ASM9, mv, isStatic, isCtor, descriptor, instrumentationMethod); @@ -190,7 +203,7 @@ class EntitlementMethodVisitor extends MethodVisitor { private final boolean instrumentedMethodIsStatic; private final boolean instrumentedMethodIsCtor; private final String instrumentedMethodDescriptor; - private final CheckerMethod instrumentationMethod; + private final CheckMethod checkMethod; private boolean hasCallerSensitiveAnnotation = false; EntitlementMethodVisitor( @@ -199,13 +212,13 @@ class EntitlementMethodVisitor extends MethodVisitor { boolean instrumentedMethodIsStatic, boolean instrumentedMethodIsCtor, String instrumentedMethodDescriptor, - CheckerMethod instrumentationMethod + CheckMethod checkMethod ) { super(api, methodVisitor); this.instrumentedMethodIsStatic = instrumentedMethodIsStatic; this.instrumentedMethodIsCtor = instrumentedMethodIsCtor; this.instrumentedMethodDescriptor = instrumentedMethodDescriptor; - this.instrumentationMethod = instrumentationMethod; + this.checkMethod = checkMethod; } @Override @@ -278,11 +291,11 @@ private void forwardIncomingArguments() { private void invokeInstrumentationMethod() { mv.visitMethodInsn( INVOKEINTERFACE, - instrumentationMethod.className(), - instrumentationMethod.methodName(), + checkMethod.className(), + checkMethod.methodName(), Type.getMethodDescriptor( Type.VOID_TYPE, - instrumentationMethod.parameterDescriptors().stream().map(Type::getType).toArray(Type[]::new) + checkMethod.parameterDescriptors().stream().map(Type::getType).toArray(Type[]::new) ), true ); @@ -290,7 +303,7 @@ private void invokeInstrumentationMethod() { } protected void pushEntitlementChecker(MethodVisitor mv) { - mv.visitMethodInsn(INVOKESTATIC, handleClass, "instance", "()" + checkerClassDescriptor, false); + mv.visitMethodInsn(INVOKESTATIC, handleClass, "instance", getCheckerClassMethodDescriptor, false); } public record ClassFileInfo(String fileName, byte[] bytecodes) {} diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java index 5eee0bf27d1df..9ccb72637d463 100644 --- a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests.java @@ -9,7 +9,7 @@ package org.elasticsearch.entitlement.instrumentation.impl; -import org.elasticsearch.entitlement.instrumentation.CheckerMethod; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; import org.elasticsearch.entitlement.instrumentation.InstrumentationService; import org.elasticsearch.entitlement.instrumentation.MethodKey; import org.elasticsearch.test.ESTestCase; @@ -52,15 +52,15 @@ interface TestCheckerCtors { } public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundException { - Map methodsMap = instrumentationService.lookupMethodsToInstrument(TestChecker.class.getName()); + Map checkMethods = instrumentationService.lookupMethodsToInstrument(TestChecker.class.getName()); - assertThat(methodsMap, aMapWithSize(3)); + assertThat(checkMethods, aMapWithSize(3)); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo(new MethodKey("org/example/TestTargetClass", "staticMethod", List.of("I", "java/lang/String", "java/lang/Object"))), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestChecker", "check$org_example_TestTargetClass$staticMethod", List.of("Ljava/lang/Class;", "I", "Ljava/lang/String;", "Ljava/lang/Object;") @@ -69,7 +69,7 @@ public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundE ) ); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo( new MethodKey( @@ -79,7 +79,7 @@ public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundE ) ), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestChecker", "check$$instanceMethodNoArgs", List.of( @@ -91,7 +91,7 @@ public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundE ) ); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo( new MethodKey( @@ -101,7 +101,7 @@ public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundE ) ), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestChecker", "check$$instanceMethodWithArgs", List.of( @@ -117,15 +117,15 @@ public void testInstrumentationTargetLookup() throws IOException, ClassNotFoundE } public void testInstrumentationTargetLookupWithOverloads() throws IOException, ClassNotFoundException { - Map methodsMap = instrumentationService.lookupMethodsToInstrument(TestCheckerOverloads.class.getName()); + Map checkMethods = instrumentationService.lookupMethodsToInstrument(TestCheckerOverloads.class.getName()); - assertThat(methodsMap, aMapWithSize(2)); + assertThat(checkMethods, aMapWithSize(2)); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo(new MethodKey("org/example/TestTargetClass", "staticMethodWithOverload", List.of("I", "java/lang/String"))), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerOverloads", "check$org_example_TestTargetClass$staticMethodWithOverload", List.of("Ljava/lang/Class;", "I", "Ljava/lang/String;") @@ -134,11 +134,11 @@ public void testInstrumentationTargetLookupWithOverloads() throws IOException, C ) ); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo(new MethodKey("org/example/TestTargetClass", "staticMethodWithOverload", List.of("I", "I"))), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerOverloads", "check$org_example_TestTargetClass$staticMethodWithOverload", List.of("Ljava/lang/Class;", "I", "I") @@ -149,15 +149,15 @@ public void testInstrumentationTargetLookupWithOverloads() throws IOException, C } public void testInstrumentationTargetLookupWithCtors() throws IOException, ClassNotFoundException { - Map methodsMap = instrumentationService.lookupMethodsToInstrument(TestCheckerCtors.class.getName()); + Map checkMethods = instrumentationService.lookupMethodsToInstrument(TestCheckerCtors.class.getName()); - assertThat(methodsMap, aMapWithSize(2)); + assertThat(checkMethods, aMapWithSize(2)); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo(new MethodKey("org/example/TestTargetClass", "", List.of("I", "java/lang/String"))), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerCtors", "check$org_example_TestTargetClass$", List.of("Ljava/lang/Class;", "I", "Ljava/lang/String;") @@ -166,11 +166,11 @@ public void testInstrumentationTargetLookupWithCtors() throws IOException, Class ) ); assertThat( - methodsMap, + checkMethods, hasEntry( equalTo(new MethodKey("org/example/TestTargetClass", "", List.of())), equalTo( - new CheckerMethod( + new CheckMethod( "org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImplTests$TestCheckerCtors", "check$org_example_TestTargetClass$", List.of("Ljava/lang/Class;") diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java index 40f0162d2eaa2..c8e1b26d1fc52 100644 --- a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java @@ -9,10 +9,8 @@ package org.elasticsearch.entitlement.instrumentation.impl; -import org.elasticsearch.common.Strings; import org.elasticsearch.entitlement.bridge.EntitlementChecker; -import org.elasticsearch.entitlement.instrumentation.CheckerMethod; -import org.elasticsearch.entitlement.instrumentation.InstrumentationService; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; import org.elasticsearch.entitlement.instrumentation.MethodKey; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; @@ -23,16 +21,21 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.net.URLStreamHandlerFactory; -import java.util.Arrays; import java.util.List; import java.util.Map; import static org.elasticsearch.entitlement.instrumentation.impl.ASMUtils.bytecode2text; -import static org.elasticsearch.entitlement.instrumentation.impl.InstrumenterImpl.getClassFileInfo; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.callStaticMethod; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.getCheckMethod; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.methodKeyForConstructor; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.methodKeyForTarget; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; -import static org.hamcrest.Matchers.startsWith; import static org.objectweb.asm.Opcodes.INVOKESTATIC; /** @@ -42,7 +45,6 @@ */ @ESTestCase.WithoutSecurityManager public class InstrumenterTests extends ESTestCase { - final InstrumentationService instrumentationService = new InstrumentationServiceImpl(); static volatile TestEntitlementChecker testChecker; @@ -59,12 +61,7 @@ public void initialize() { * Contains all the virtual methods from {@link ClassToInstrument}, * allowing this test to call them on the dynamically loaded instrumented class. */ - public interface Testable { - // This method is here to demonstrate Instrumenter does not get confused by overloads - void someMethod(int arg); - - void someMethod(int arg, String anotherArg); - } + public interface Testable {} /** * This is a placeholder for real class library methods. @@ -78,41 +75,24 @@ public static class ClassToInstrument implements Testable { public ClassToInstrument() {} - public ClassToInstrument(int arg) {} + // URLClassLoader ctor + public ClassToInstrument(URL[] urls) {} public static void systemExit(int status) { assertEquals(123, status); } - - public static void anotherSystemExit(int status) { - assertEquals(123, status); - } - - public void someMethod(int arg) {} - - public void someMethod(int arg, String anotherArg) {} - - public static void someStaticMethod(int arg) {} - - public static void someStaticMethod(int arg, String anotherArg) {} } - static final class TestException extends RuntimeException {} + private static final String SAMPLE_NAME = "TEST"; - /** - * Interface to test specific, "synthetic" cases (e.g. overloaded methods, overloaded constructors, etc.) that - * may be not present/may be difficult to find or not clear in the production EntitlementChecker interface - */ - public interface MockEntitlementChecker extends EntitlementChecker { - void checkSomeStaticMethod(Class clazz, int arg); - - void checkSomeStaticMethod(Class clazz, int arg, String anotherArg); - - void checkSomeInstanceMethod(Class clazz, Testable that, int arg, String anotherArg); + private static final URL SAMPLE_URL = createSampleUrl(); - void checkCtor(Class clazz); - - void checkCtor(Class clazz, int arg); + private static URL createSampleUrl() { + try { + return URI.create("file:/test/example").toURL(); + } catch (MalformedURLException e) { + return null; + } } /** @@ -122,7 +102,7 @@ public interface MockEntitlementChecker extends EntitlementChecker { * just to demonstrate that the injected bytecodes succeed in calling these methods. * It also asserts that the arguments are correct. */ - public static class TestEntitlementChecker implements MockEntitlementChecker { + public static class TestEntitlementChecker implements EntitlementChecker { /** * This allows us to test that the instrumentation is correct in both cases: * if the check throws, and if it doesn't. @@ -130,104 +110,84 @@ public static class TestEntitlementChecker implements MockEntitlementChecker { volatile boolean isActive; int checkSystemExitCallCount = 0; - int checkSomeStaticMethodIntCallCount = 0; - int checkSomeStaticMethodIntStringCallCount = 0; - int checkSomeInstanceMethodCallCount = 0; - - int checkCtorCallCount = 0; - int checkCtorIntCallCount = 0; + int checkURLClassLoaderCallCount = 0; @Override public void check$java_lang_System$exit(Class callerClass, int status) { checkSystemExitCallCount++; - assertSame(InstrumenterTests.class, callerClass); + assertSame(TestMethodUtils.class, callerClass); assertEquals(123, status); throwIfActive(); } @Override - public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls) {} - - @Override - public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent) {} - - @Override - public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {} - - @Override - public void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent) {} - - @Override - public void check$java_net_URLClassLoader$( - Class callerClass, - String name, - URL[] urls, - ClassLoader parent, - URLStreamHandlerFactory factory - ) {} - - private void throwIfActive() { - if (isActive) { - throw new TestException(); - } - } - - @Override - public void checkSomeStaticMethod(Class callerClass, int arg) { - checkSomeStaticMethodIntCallCount++; + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls) { + checkURLClassLoaderCallCount++; assertSame(InstrumenterTests.class, callerClass); - assertEquals(123, arg); + assertThat(urls, arrayContaining(SAMPLE_URL)); throwIfActive(); } @Override - public void checkSomeStaticMethod(Class callerClass, int arg, String anotherArg) { - checkSomeStaticMethodIntStringCallCount++; + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent) { + checkURLClassLoaderCallCount++; assertSame(InstrumenterTests.class, callerClass); - assertEquals(123, arg); - assertEquals("abc", anotherArg); + assertThat(urls, arrayContaining(SAMPLE_URL)); + assertThat(parent, equalTo(ClassLoader.getSystemClassLoader())); throwIfActive(); } @Override - public void checkSomeInstanceMethod(Class callerClass, Testable that, int arg, String anotherArg) { - checkSomeInstanceMethodCallCount++; + public void check$java_net_URLClassLoader$(Class callerClass, URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) { + checkURLClassLoaderCallCount++; assertSame(InstrumenterTests.class, callerClass); - assertThat( - that.getClass().getName(), - startsWith("org.elasticsearch.entitlement.instrumentation.impl.InstrumenterTests$ClassToInstrument") - ); - assertEquals(123, arg); - assertEquals("def", anotherArg); + assertThat(urls, arrayContaining(SAMPLE_URL)); + assertThat(parent, equalTo(ClassLoader.getSystemClassLoader())); throwIfActive(); } @Override - public void checkCtor(Class callerClass) { - checkCtorCallCount++; + public void check$java_net_URLClassLoader$(Class callerClass, String name, URL[] urls, ClassLoader parent) { + checkURLClassLoaderCallCount++; assertSame(InstrumenterTests.class, callerClass); + assertThat(name, equalTo(SAMPLE_NAME)); + assertThat(urls, arrayContaining(SAMPLE_URL)); + assertThat(parent, equalTo(ClassLoader.getSystemClassLoader())); throwIfActive(); } @Override - public void checkCtor(Class callerClass, int arg) { - checkCtorIntCallCount++; + public void check$java_net_URLClassLoader$( + Class callerClass, + String name, + URL[] urls, + ClassLoader parent, + URLStreamHandlerFactory factory + ) { + checkURLClassLoaderCallCount++; assertSame(InstrumenterTests.class, callerClass); - assertEquals(123, arg); + assertThat(name, equalTo(SAMPLE_NAME)); + assertThat(urls, arrayContaining(SAMPLE_URL)); + assertThat(parent, equalTo(ClassLoader.getSystemClassLoader())); throwIfActive(); } + + private void throwIfActive() { + if (isActive) { + throw new TestException(); + } + } } - public void testClassIsInstrumented() throws Exception { + public void testSystemExitIsInstrumented() throws Exception { var classToInstrument = ClassToInstrument.class; - CheckerMethod checkerMethod = getCheckerMethod(EntitlementChecker.class, "check$java_lang_System$exit", Class.class, int.class); - Map methods = Map.of( - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("systemExit", int.class)), - checkerMethod + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("systemExit", int.class)), + getCheckMethod(EntitlementChecker.class, "check$java_lang_System$exit", Class.class, int.class) ); - var instrumenter = createInstrumenter(methods); + var instrumenter = createInstrumenter(checkMethods); byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); @@ -251,86 +211,15 @@ public void testClassIsInstrumented() throws Exception { assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123)); } - public void testClassIsNotInstrumentedTwice() throws Exception { - var classToInstrument = ClassToInstrument.class; - - CheckerMethod checkerMethod = getCheckerMethod(EntitlementChecker.class, "check$java_lang_System$exit", Class.class, int.class); - Map methods = Map.of( - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("systemExit", int.class)), - checkerMethod - ); - - var instrumenter = createInstrumenter(methods); - - InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument); - var internalClassName = Type.getInternalName(classToInstrument); - - byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes()); - byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode); - - logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode))); - logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode))); - - Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( - classToInstrument.getName() + "_NEW_NEW", - instrumentedTwiceBytecode - ); - - getTestEntitlementChecker().isActive = true; - getTestEntitlementChecker().checkSystemExitCallCount = 0; - - assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123)); - assertEquals(1, getTestEntitlementChecker().checkSystemExitCallCount); - } - - public void testClassAllMethodsAreInstrumentedFirstPass() throws Exception { + public void testURLClassLoaderIsInstrumented() throws Exception { var classToInstrument = ClassToInstrument.class; - CheckerMethod checkerMethod = getCheckerMethod(EntitlementChecker.class, "check$java_lang_System$exit", Class.class, int.class); - Map methods = Map.of( - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("systemExit", int.class)), - checkerMethod, - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("anotherSystemExit", int.class)), - checkerMethod + Map checkMethods = Map.of( + methodKeyForConstructor(classToInstrument, List.of(Type.getInternalName(URL[].class))), + getCheckMethod(EntitlementChecker.class, "check$java_net_URLClassLoader$", Class.class, URL[].class) ); - var instrumenter = createInstrumenter(methods); - - InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument); - var internalClassName = Type.getInternalName(classToInstrument); - - byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes()); - byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode); - - logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode))); - logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode))); - - Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( - classToInstrument.getName() + "_NEW_NEW", - instrumentedTwiceBytecode - ); - - getTestEntitlementChecker().isActive = true; - getTestEntitlementChecker().checkSystemExitCallCount = 0; - - assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123)); - assertEquals(1, getTestEntitlementChecker().checkSystemExitCallCount); - - assertThrows(TestException.class, () -> callStaticMethod(newClass, "anotherSystemExit", 123)); - assertEquals(2, getTestEntitlementChecker().checkSystemExitCallCount); - } - - public void testInstrumenterWorksWithOverloads() throws Exception { - var classToInstrument = ClassToInstrument.class; - - Map methods = Map.of( - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class)), - getCheckerMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class), - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class, String.class)), - getCheckerMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class, String.class) - ); - - var instrumenter = createInstrumenter(methods); + var instrumenter = createInstrumenter(checkMethods); byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); @@ -343,80 +232,19 @@ public void testInstrumenterWorksWithOverloads() throws Exception { newBytecode ); - getTestEntitlementChecker().isActive = true; - - // After checking is activated, everything should throw - assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); - assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123, "abc")); - - assertEquals(1, getTestEntitlementChecker().checkSomeStaticMethodIntCallCount); - assertEquals(1, getTestEntitlementChecker().checkSomeStaticMethodIntStringCallCount); - } - - public void testInstrumenterWorksWithInstanceMethodsAndOverloads() throws Exception { - var classToInstrument = ClassToInstrument.class; - - Map methods = Map.of( - instrumentationService.methodKeyForTarget(classToInstrument.getMethod("someMethod", int.class, String.class)), - getCheckerMethod(MockEntitlementChecker.class, "checkSomeInstanceMethod", Class.class, Testable.class, int.class, String.class) - ); - - var instrumenter = createInstrumenter(methods); - - byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); - - if (logger.isTraceEnabled()) { - logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); - } + getTestEntitlementChecker().isActive = false; - Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( - classToInstrument.getName() + "_NEW", - newBytecode - ); + // Before checking is active, nothing should throw + newClass.getConstructor(URL[].class).newInstance((Object) new URL[] { SAMPLE_URL }); getTestEntitlementChecker().isActive = true; - Testable testTargetClass = (Testable) (newClass.getConstructor().newInstance()); - - // This overload is not instrumented, so it will not throw - testTargetClass.someMethod(123); - assertThrows(TestException.class, () -> testTargetClass.someMethod(123, "def")); - - assertEquals(1, getTestEntitlementChecker().checkSomeInstanceMethodCallCount); - } - - public void testInstrumenterWorksWithConstructors() throws Exception { - var classToInstrument = ClassToInstrument.class; - - Map methods = Map.of( - new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of()), - getCheckerMethod(MockEntitlementChecker.class, "checkCtor", Class.class), - new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of("I")), - getCheckerMethod(MockEntitlementChecker.class, "checkCtor", Class.class, int.class) - ); - - var instrumenter = createInstrumenter(methods); - - byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); - - if (logger.isTraceEnabled()) { - logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); - } - - Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( - classToInstrument.getName() + "_NEW", - newBytecode + // After checking is activated, everything should throw + var exception = assertThrows( + InvocationTargetException.class, + () -> newClass.getConstructor(URL[].class).newInstance((Object) new URL[] { SAMPLE_URL }) ); - - getTestEntitlementChecker().isActive = true; - - var ex = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor().newInstance()); - assertThat(ex.getCause(), instanceOf(TestException.class)); - var ex2 = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor(int.class).newInstance(123)); - assertThat(ex2.getCause(), instanceOf(TestException.class)); - - assertEquals(1, getTestEntitlementChecker().checkCtorCallCount); - assertEquals(1, getTestEntitlementChecker().checkCtorIntCallCount); + assertThat(exception.getCause(), instanceOf(TestException.class)); } /** This test doesn't replace classToInstrument in-place but instead loads a separate @@ -425,9 +253,10 @@ public void testInstrumenterWorksWithConstructors() throws Exception { * MethodKey and instrumentationMethod with slightly different signatures (using the common interface * Testable) which is not what would happen when it's run by the agent. */ - private InstrumenterImpl createInstrumenter(Map methods) throws NoSuchMethodException { + private InstrumenterImpl createInstrumenter(Map checkMethods) throws NoSuchMethodException { Method getter = InstrumenterTests.class.getMethod("getTestEntitlementChecker"); - return new InstrumenterImpl("_NEW", methods) { + + return new InstrumenterImpl(null, null, "_NEW", checkMethods) { /** * We're not testing the bridge library here. * Just call our own getter instead. @@ -445,58 +274,5 @@ protected void pushEntitlementChecker(MethodVisitor mv) { }; } - private static CheckerMethod getCheckerMethod(Class clazz, String methodName, Class... parameterTypes) - throws NoSuchMethodException { - var method = clazz.getMethod(methodName, parameterTypes); - return new CheckerMethod( - Type.getInternalName(clazz), - method.getName(), - Arrays.stream(Type.getArgumentTypes(method)).map(Type::getDescriptor).toList() - ); - } - - /** - * Calling a static method of a dynamically loaded class is significantly more cumbersome - * than calling a virtual method. - */ - private static void callStaticMethod(Class c, String methodName, int arg) throws NoSuchMethodException, IllegalAccessException { - try { - c.getMethod(methodName, int.class).invoke(null, arg); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof TestException n) { - // Sometimes we're expecting this one! - throw n; - } else { - throw new AssertionError(cause); - } - } - } - - private static void callStaticMethod(Class c, String methodName, int arg1, String arg2) throws NoSuchMethodException, - IllegalAccessException { - try { - c.getMethod(methodName, int.class, String.class).invoke(null, arg1, arg2); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof TestException n) { - // Sometimes we're expecting this one! - throw n; - } else { - throw new AssertionError(cause); - } - } - } - - static class TestLoader extends ClassLoader { - TestLoader(ClassLoader parent) { - super(parent); - } - - public Class defineClassFromBytes(String name, byte[] bytes) { - return defineClass(name, bytes, 0, bytes.length); - } - } - private static final Logger logger = LogManager.getLogger(InstrumenterTests.class); } diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/SyntheticInstrumenterTests.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/SyntheticInstrumenterTests.java new file mode 100644 index 0000000000000..8e0409971ba61 --- /dev/null +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/SyntheticInstrumenterTests.java @@ -0,0 +1,383 @@ +/* + * 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.entitlement.instrumentation.impl; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; +import org.elasticsearch.entitlement.instrumentation.MethodKey; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; +import org.elasticsearch.test.ESTestCase; +import org.objectweb.asm.Type; + +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.entitlement.instrumentation.impl.ASMUtils.bytecode2text; +import static org.elasticsearch.entitlement.instrumentation.impl.InstrumenterImpl.getClassFileInfo; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.callStaticMethod; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.getCheckMethod; +import static org.elasticsearch.entitlement.instrumentation.impl.TestMethodUtils.methodKeyForTarget; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.startsWith; + +/** + * This tests {@link InstrumenterImpl} with some ad-hoc instrumented method and checker methods, to allow us to check + * some ad-hoc test cases (e.g. overloaded methods, overloaded targets, multiple instrumentation, etc.) + */ +@ESTestCase.WithoutSecurityManager +public class SyntheticInstrumenterTests extends ESTestCase { + private static final Logger logger = LogManager.getLogger(SyntheticInstrumenterTests.class); + + /** + * Contains all the virtual methods from {@link TestClassToInstrument}, + * allowing this test to call them on the dynamically loaded instrumented class. + */ + public interface Testable { + // This method is here to demonstrate Instrumenter does not get confused by overloads + void someMethod(int arg); + + void someMethod(int arg, String anotherArg); + } + + /** + * This is a placeholder for real class library methods. + * Without the java agent, we can't instrument the real methods, so we instrument this instead. + *

+ * Methods of this class must have the same signature and the same static/virtual condition as the corresponding real method. + * They should assert that the arguments came through correctly. + * They must not throw {@link TestException}. + */ + public static class TestClassToInstrument implements Testable { + + public TestClassToInstrument() {} + + public TestClassToInstrument(int arg) {} + + public void someMethod(int arg) {} + + public void someMethod(int arg, String anotherArg) {} + + public static void someStaticMethod(int arg) {} + + public static void someStaticMethod(int arg, String anotherArg) {} + + public static void anotherStaticMethod(int arg) {} + } + + /** + * Interface to test specific, "synthetic" cases (e.g. overloaded methods, overloaded constructors, etc.) that + * may be not present/may be difficult to find or not clear in the production EntitlementChecker interface + */ + public interface MockEntitlementChecker { + void checkSomeStaticMethod(Class clazz, int arg); + + void checkSomeStaticMethod(Class clazz, int arg, String anotherArg); + + void checkSomeInstanceMethod(Class clazz, Testable that, int arg, String anotherArg); + + void checkCtor(Class clazz); + + void checkCtor(Class clazz, int arg); + } + + public static class TestEntitlementCheckerHolder { + static TestEntitlementChecker checkerInstance = new TestEntitlementChecker(); + + public static MockEntitlementChecker instance() { + return checkerInstance; + } + } + + public static class TestEntitlementChecker implements MockEntitlementChecker { + /** + * This allows us to test that the instrumentation is correct in both cases: + * if the check throws, and if it doesn't. + */ + volatile boolean isActive; + + int checkSomeStaticMethodIntCallCount = 0; + int checkSomeStaticMethodIntStringCallCount = 0; + int checkSomeInstanceMethodCallCount = 0; + + int checkCtorCallCount = 0; + int checkCtorIntCallCount = 0; + + private void throwIfActive() { + if (isActive) { + throw new TestException(); + } + } + + @Override + public void checkSomeStaticMethod(Class callerClass, int arg) { + checkSomeStaticMethodIntCallCount++; + assertSame(TestMethodUtils.class, callerClass); + assertEquals(123, arg); + throwIfActive(); + } + + @Override + public void checkSomeStaticMethod(Class callerClass, int arg, String anotherArg) { + checkSomeStaticMethodIntStringCallCount++; + assertSame(TestMethodUtils.class, callerClass); + assertEquals(123, arg); + assertEquals("abc", anotherArg); + throwIfActive(); + } + + @Override + public void checkSomeInstanceMethod(Class callerClass, Testable that, int arg, String anotherArg) { + checkSomeInstanceMethodCallCount++; + assertSame(SyntheticInstrumenterTests.class, callerClass); + assertThat( + that.getClass().getName(), + startsWith("org.elasticsearch.entitlement.instrumentation.impl.SyntheticInstrumenterTests$TestClassToInstrument") + ); + assertEquals(123, arg); + assertEquals("def", anotherArg); + throwIfActive(); + } + + @Override + public void checkCtor(Class callerClass) { + checkCtorCallCount++; + assertSame(SyntheticInstrumenterTests.class, callerClass); + throwIfActive(); + } + + @Override + public void checkCtor(Class callerClass, int arg) { + checkCtorIntCallCount++; + assertSame(SyntheticInstrumenterTests.class, callerClass); + assertEquals(123, arg); + throwIfActive(); + } + } + + public void testClassIsInstrumented() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + CheckMethod checkMethod = getCheckMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class); + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class)), + checkMethod + ); + + var instrumenter = createInstrumenter(checkMethods); + + byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); + + if (logger.isTraceEnabled()) { + logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); + } + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW", + newBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = false; + + // Before checking is active, nothing should throw + callStaticMethod(newClass, "someStaticMethod", 123); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + + // After checking is activated, everything should throw + assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); + } + + public void testClassIsNotInstrumentedTwice() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + CheckMethod checkMethod = getCheckMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class); + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class)), + checkMethod + ); + + var instrumenter = createInstrumenter(checkMethods); + + InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument); + var internalClassName = Type.getInternalName(classToInstrument); + + byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes()); + byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode); + + logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode))); + logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode))); + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW_NEW", + instrumentedTwiceBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount = 0; + + assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount); + } + + public void testClassAllMethodsAreInstrumentedFirstPass() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + CheckMethod checkMethod = getCheckMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class); + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class)), + checkMethod, + methodKeyForTarget(classToInstrument.getMethod("anotherStaticMethod", int.class)), + checkMethod + ); + + var instrumenter = createInstrumenter(checkMethods); + + InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument); + var internalClassName = Type.getInternalName(classToInstrument); + + byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes()); + byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode); + + logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode))); + logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode))); + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW_NEW", + instrumentedTwiceBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount = 0; + + assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount); + + assertThrows(TestException.class, () -> callStaticMethod(newClass, "anotherStaticMethod", 123)); + assertEquals(2, TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount); + } + + public void testInstrumenterWorksWithOverloads() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class)), + getCheckMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class), + methodKeyForTarget(classToInstrument.getMethod("someStaticMethod", int.class, String.class)), + getCheckMethod(MockEntitlementChecker.class, "checkSomeStaticMethod", Class.class, int.class, String.class) + ); + + var instrumenter = createInstrumenter(checkMethods); + + byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); + + if (logger.isTraceEnabled()) { + logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); + } + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW", + newBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount = 0; + TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntStringCallCount = 0; + + // After checking is activated, everything should throw + assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123)); + assertThrows(TestException.class, () -> callStaticMethod(newClass, "someStaticMethod", 123, "abc")); + + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntCallCount); + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkSomeStaticMethodIntStringCallCount); + } + + public void testInstrumenterWorksWithInstanceMethodsAndOverloads() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + Map checkMethods = Map.of( + methodKeyForTarget(classToInstrument.getMethod("someMethod", int.class, String.class)), + getCheckMethod(MockEntitlementChecker.class, "checkSomeInstanceMethod", Class.class, Testable.class, int.class, String.class) + ); + + var instrumenter = createInstrumenter(checkMethods); + + byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); + + if (logger.isTraceEnabled()) { + logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); + } + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW", + newBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + TestEntitlementCheckerHolder.checkerInstance.checkSomeInstanceMethodCallCount = 0; + + Testable testTargetClass = (Testable) (newClass.getConstructor().newInstance()); + + // This overload is not instrumented, so it will not throw + testTargetClass.someMethod(123); + assertThrows(TestException.class, () -> testTargetClass.someMethod(123, "def")); + + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkSomeInstanceMethodCallCount); + } + + public void testInstrumenterWorksWithConstructors() throws Exception { + var classToInstrument = TestClassToInstrument.class; + + Map checkMethods = Map.of( + new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of()), + getCheckMethod(MockEntitlementChecker.class, "checkCtor", Class.class), + new MethodKey(classToInstrument.getName().replace('.', '/'), "", List.of("I")), + getCheckMethod(MockEntitlementChecker.class, "checkCtor", Class.class, int.class) + ); + + var instrumenter = createInstrumenter(checkMethods); + + byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes(); + + if (logger.isTraceEnabled()) { + logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode)); + } + + Class newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes( + classToInstrument.getName() + "_NEW", + newBytecode + ); + + TestEntitlementCheckerHolder.checkerInstance.isActive = true; + + var ex = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor().newInstance()); + assertThat(ex.getCause(), instanceOf(TestException.class)); + var ex2 = assertThrows(InvocationTargetException.class, () -> newClass.getConstructor(int.class).newInstance(123)); + assertThat(ex2.getCause(), instanceOf(TestException.class)); + + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkCtorCallCount); + assertEquals(1, TestEntitlementCheckerHolder.checkerInstance.checkCtorIntCallCount); + } + + /** This test doesn't replace classToInstrument in-place but instead loads a separate + * class with the same class name plus a "_NEW" suffix (classToInstrument.class.getName() + "_NEW") + * that contains the instrumentation. Because of this, we need to configure the Transformer to use a + * MethodKey and instrumentationMethod with slightly different signatures (using the common interface + * Testable) which is not what would happen when it's run by the agent. + */ + private InstrumenterImpl createInstrumenter(Map checkMethods) { + String checkerClass = Type.getInternalName(SyntheticInstrumenterTests.MockEntitlementChecker.class); + String handleClass = Type.getInternalName(SyntheticInstrumenterTests.TestEntitlementCheckerHolder.class); + String getCheckerClassMethodDescriptor = Type.getMethodDescriptor(Type.getObjectType(checkerClass)); + + return new InstrumenterImpl(handleClass, getCheckerClassMethodDescriptor, "_NEW", checkMethods); + } +} diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestException.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestException.java new file mode 100644 index 0000000000000..5e308e5bd4a98 --- /dev/null +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestException.java @@ -0,0 +1,12 @@ +/* + * 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.entitlement.instrumentation.impl; + +final class TestException extends RuntimeException {} diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestLoader.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestLoader.java new file mode 100644 index 0000000000000..9eb8e9328ecba --- /dev/null +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestLoader.java @@ -0,0 +1,20 @@ +/* + * 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.entitlement.instrumentation.impl; + +class TestLoader extends ClassLoader { + TestLoader(ClassLoader parent) { + super(parent); + } + + public Class defineClassFromBytes(String name, byte[] bytes) { + return defineClass(name, bytes, 0, bytes.length); + } +} diff --git a/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestMethodUtils.java b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestMethodUtils.java new file mode 100644 index 0000000000000..de7822fea926e --- /dev/null +++ b/libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/TestMethodUtils.java @@ -0,0 +1,81 @@ +/* + * 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.entitlement.instrumentation.impl; + +import org.elasticsearch.entitlement.instrumentation.CheckMethod; +import org.elasticsearch.entitlement.instrumentation.MethodKey; +import org.objectweb.asm.Type; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +class TestMethodUtils { + + /** + * @return a {@link MethodKey} suitable for looking up the given {@code targetMethod} in the entitlements trampoline + */ + static MethodKey methodKeyForTarget(Method targetMethod) { + Type actualType = Type.getMethodType(Type.getMethodDescriptor(targetMethod)); + return new MethodKey( + Type.getInternalName(targetMethod.getDeclaringClass()), + targetMethod.getName(), + Stream.of(actualType.getArgumentTypes()).map(Type::getInternalName).toList() + ); + } + + static MethodKey methodKeyForConstructor(Class classToInstrument, List params) { + return new MethodKey(classToInstrument.getName().replace('.', '/'), "", params); + } + + static CheckMethod getCheckMethod(Class clazz, String methodName, Class... parameterTypes) throws NoSuchMethodException { + var method = clazz.getMethod(methodName, parameterTypes); + return new CheckMethod( + Type.getInternalName(clazz), + method.getName(), + Arrays.stream(Type.getArgumentTypes(method)).map(Type::getDescriptor).toList() + ); + } + + /** + * Calling a static method of a dynamically loaded class is significantly more cumbersome + * than calling a virtual method. + */ + static void callStaticMethod(Class c, String methodName, int arg) throws NoSuchMethodException, IllegalAccessException { + try { + c.getMethod(methodName, int.class).invoke(null, arg); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof TestException n) { + // Sometimes we're expecting this one! + throw n; + } else { + throw new AssertionError(cause); + } + } + } + + static void callStaticMethod(Class c, String methodName, int arg1, String arg2) throws NoSuchMethodException, + IllegalAccessException { + try { + c.getMethod(methodName, int.class, String.class).invoke(null, arg1, arg2); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof TestException n) { + // Sometimes we're expecting this one! + throw n; + } else { + throw new AssertionError(cause); + } + } + } +} diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java index 1f87e067e04f1..0ffab5f93969f 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java @@ -13,7 +13,7 @@ import org.elasticsearch.core.internal.provider.ProviderLocator; import org.elasticsearch.entitlement.bootstrap.EntitlementBootstrap; import org.elasticsearch.entitlement.bridge.EntitlementChecker; -import org.elasticsearch.entitlement.instrumentation.CheckerMethod; +import org.elasticsearch.entitlement.instrumentation.CheckMethod; import org.elasticsearch.entitlement.instrumentation.InstrumentationService; import org.elasticsearch.entitlement.instrumentation.MethodKey; import org.elasticsearch.entitlement.instrumentation.Transformer; @@ -63,13 +63,13 @@ public static EntitlementChecker checker() { public static void initialize(Instrumentation inst) throws Exception { manager = initChecker(); - Map methodMap = INSTRUMENTER_FACTORY.lookupMethodsToInstrument( + Map checkMethods = INSTRUMENTER_FACTORY.lookupMethodsToInstrument( "org.elasticsearch.entitlement.bridge.EntitlementChecker" ); - var classesToTransform = methodMap.keySet().stream().map(MethodKey::className).collect(Collectors.toSet()); + var classesToTransform = checkMethods.keySet().stream().map(MethodKey::className).collect(Collectors.toSet()); - inst.addTransformer(new Transformer(INSTRUMENTER_FACTORY.newInstrumenter("", methodMap), classesToTransform), true); + inst.addTransformer(new Transformer(INSTRUMENTER_FACTORY.newInstrumenter(checkMethods), classesToTransform), true); // TODO: should we limit this array somehow? var classesToRetransform = classesToTransform.stream().map(EntitlementInitialization::internalNameToClass).toArray(Class[]::new); inst.retransformClasses(classesToRetransform); diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckerMethod.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckMethod.java similarity index 82% rename from libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckerMethod.java rename to libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckMethod.java index c20a75a61a608..384d455c7a34b 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckerMethod.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/CheckMethod.java @@ -12,7 +12,7 @@ import java.util.List; /** - * A structure to use as a representation of the checker method the instrumentation will inject. + * A structure to use as a representation of the checkXxx method the instrumentation will inject. * * @param className the "internal name" of the class: includes the package info, but with periods replaced by slashes * @param methodName the checker method name @@ -20,4 +20,4 @@ * type descriptors) * for methodName parameters. */ -public record CheckerMethod(String className, String methodName, List parameterDescriptors) {} +public record CheckMethod(String className, String methodName, List parameterDescriptors) {} diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java index 12316bfb043c5..d0331d756d2b2 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java @@ -10,19 +10,13 @@ package org.elasticsearch.entitlement.instrumentation; import java.io.IOException; -import java.lang.reflect.Method; import java.util.Map; /** * The SPI service entry point for instrumentation. */ public interface InstrumentationService { - Instrumenter newInstrumenter(String classNameSuffix, Map instrumentationMethods); + Instrumenter newInstrumenter(Map checkMethods); - /** - * @return a {@link MethodKey} suitable for looking up the given {@code targetMethod} in the entitlements trampoline - */ - MethodKey methodKeyForTarget(Method targetMethod); - - Map lookupMethodsToInstrument(String entitlementCheckerClassName) throws ClassNotFoundException, IOException; + Map lookupMethodsToInstrument(String entitlementCheckerClassName) throws ClassNotFoundException, IOException; } From deb838c027ecd83bc34fd487566571c61bfcd8be Mon Sep 17 00:00:00 2001 From: elasticsearchmachine <58790826+elasticsearchmachine@users.noreply.github.com> Date: Sun, 1 Dec 2024 01:25:39 +1100 Subject: [PATCH 125/129] Mute org.elasticsearch.xpack.esql.action.CrossClustersCancellationIT testCancel #117568 --- muted-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/muted-tests.yml b/muted-tests.yml index b82e95ea26890..d5e2dbd84cb4a 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -234,6 +234,9 @@ tests: - class: org.elasticsearch.search.ccs.CrossClusterIT method: testCancel issue: https://github.com/elastic/elasticsearch/issues/108061 +- class: org.elasticsearch.xpack.esql.action.CrossClustersCancellationIT + method: testCancel + issue: https://github.com/elastic/elasticsearch/issues/117568 # Examples: # From 31cb0f658a8b3239bb38dd190e1efeb79062b2f9 Mon Sep 17 00:00:00 2001 From: Rene Groeschke Date: Sat, 30 Nov 2024 23:32:18 +0100 Subject: [PATCH 126/129] [Build] Replace usage of deprecated develocity system prop (#117793) see https://buildkite.com/elastic/elasticsearch-intake/builds/13680#019374ed-096e-4965-8651-1b3fd26dd9c2/79-392 --- .buildkite/pipelines/intake.template.yml | 16 ++++++++-------- .buildkite/pipelines/intake.yml | 16 ++++++++-------- .../pipelines/lucene-snapshot/run-tests.yml | 16 ++++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.buildkite/pipelines/intake.template.yml b/.buildkite/pipelines/intake.template.yml index 57412bbe908bc..9d7cf3c7e0083 100644 --- a/.buildkite/pipelines/intake.template.yml +++ b/.buildkite/pipelines/intake.template.yml @@ -1,6 +1,6 @@ steps: - label: sanity-check - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files precommit + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints precommit timeout_in_minutes: 300 agents: provider: gcp @@ -9,7 +9,7 @@ steps: buildDirectory: /dev/shm/bk - wait - label: part1 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart1 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart1 timeout_in_minutes: 300 agents: provider: gcp @@ -17,7 +17,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part2 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart2 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart2 timeout_in_minutes: 300 agents: provider: gcp @@ -25,7 +25,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part3 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart3 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart3 timeout_in_minutes: 300 agents: provider: gcp @@ -33,7 +33,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part4 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart4 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart4 timeout_in_minutes: 300 agents: provider: gcp @@ -41,7 +41,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part5 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart5 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart5 timeout_in_minutes: 300 agents: provider: gcp @@ -51,7 +51,7 @@ steps: - group: bwc-snapshots steps: - label: "{{matrix.BWC_VERSION}} / bwc-snapshots" - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files v$$BWC_VERSION#bwcTest + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints v$$BWC_VERSION#bwcTest timeout_in_minutes: 300 matrix: setup: @@ -64,7 +64,7 @@ steps: env: BWC_VERSION: "{{matrix.BWC_VERSION}}" - label: rest-compat - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkRestCompat + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkRestCompat timeout_in_minutes: 300 agents: provider: gcp diff --git a/.buildkite/pipelines/intake.yml b/.buildkite/pipelines/intake.yml index 5be5990cfb203..6c8b8edfcbac1 100644 --- a/.buildkite/pipelines/intake.yml +++ b/.buildkite/pipelines/intake.yml @@ -1,7 +1,7 @@ # This file is auto-generated. See .buildkite/pipelines/intake.template.yml steps: - label: sanity-check - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files precommit + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints precommit timeout_in_minutes: 300 agents: provider: gcp @@ -10,7 +10,7 @@ steps: buildDirectory: /dev/shm/bk - wait - label: part1 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart1 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart1 timeout_in_minutes: 300 agents: provider: gcp @@ -18,7 +18,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part2 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart2 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart2 timeout_in_minutes: 300 agents: provider: gcp @@ -26,7 +26,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part3 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart3 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart3 timeout_in_minutes: 300 agents: provider: gcp @@ -34,7 +34,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part4 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart4 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart4 timeout_in_minutes: 300 agents: provider: gcp @@ -42,7 +42,7 @@ steps: machineType: n1-standard-32 buildDirectory: /dev/shm/bk - label: part5 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart5 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart5 timeout_in_minutes: 300 agents: provider: gcp @@ -52,7 +52,7 @@ steps: - group: bwc-snapshots steps: - label: "{{matrix.BWC_VERSION}} / bwc-snapshots" - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files v$$BWC_VERSION#bwcTest + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints v$$BWC_VERSION#bwcTest timeout_in_minutes: 300 matrix: setup: @@ -65,7 +65,7 @@ steps: env: BWC_VERSION: "{{matrix.BWC_VERSION}}" - label: rest-compat - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkRestCompat + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkRestCompat timeout_in_minutes: 300 agents: provider: gcp diff --git a/.buildkite/pipelines/lucene-snapshot/run-tests.yml b/.buildkite/pipelines/lucene-snapshot/run-tests.yml index f7293e051467c..ddc63419a2e2f 100644 --- a/.buildkite/pipelines/lucene-snapshot/run-tests.yml +++ b/.buildkite/pipelines/lucene-snapshot/run-tests.yml @@ -1,6 +1,6 @@ steps: - label: sanity-check - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files precommit + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints precommit timeout_in_minutes: 300 agents: provider: gcp @@ -9,7 +9,7 @@ steps: buildDirectory: /dev/shm/bk - wait: null - label: part1 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart1 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart1 timeout_in_minutes: 300 agents: provider: gcp @@ -17,7 +17,7 @@ steps: machineType: custom-32-98304 buildDirectory: /dev/shm/bk - label: part2 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart2 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart2 timeout_in_minutes: 300 agents: provider: gcp @@ -25,7 +25,7 @@ steps: machineType: custom-32-98304 buildDirectory: /dev/shm/bk - label: part3 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart3 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart3 timeout_in_minutes: 300 agents: provider: gcp @@ -33,7 +33,7 @@ steps: machineType: custom-32-98304 buildDirectory: /dev/shm/bk - label: part4 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart4 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart4 timeout_in_minutes: 300 agents: provider: gcp @@ -41,7 +41,7 @@ steps: machineType: custom-32-98304 buildDirectory: /dev/shm/bk - label: part5 - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkPart5 + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkPart5 timeout_in_minutes: 300 agents: provider: gcp @@ -51,7 +51,7 @@ steps: - group: bwc-snapshots steps: - label: "{{matrix.BWC_VERSION}} / bwc-snapshots" - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files v$$BWC_VERSION#bwcTest + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints v$$BWC_VERSION#bwcTest timeout_in_minutes: 300 matrix: setup: @@ -66,7 +66,7 @@ steps: env: BWC_VERSION: "{{matrix.BWC_VERSION}}" - label: rest-compat - command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-task-input-files checkRestCompat + command: .ci/scripts/run-gradle.sh -Dbwc.checkout.align=true -Dorg.elasticsearch.build.cache.push=true -Dignore.tests.seed -Dscan.capture-file-fingerprints checkRestCompat timeout_in_minutes: 300 agents: provider: gcp From bda415b7fdf4a73091a198339e5f1660c1378029 Mon Sep 17 00:00:00 2001 From: Nhat Nguyen Date: Sat, 30 Nov 2024 20:09:08 -0800 Subject: [PATCH 127/129] Fix CCS cancellation test (#117790) We should have checked that all drivers were canceled, not cancellable (which is always true), before unblocking the compute tasks. Closes #117568 --- muted-tests.yml | 3 -- .../action/CrossClustersCancellationIT.java | 29 ++++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/muted-tests.yml b/muted-tests.yml index d5e2dbd84cb4a..b82e95ea26890 100644 --- a/muted-tests.yml +++ b/muted-tests.yml @@ -234,9 +234,6 @@ tests: - class: org.elasticsearch.search.ccs.CrossClusterIT method: testCancel issue: https://github.com/elastic/elasticsearch/issues/108061 -- class: org.elasticsearch.xpack.esql.action.CrossClustersCancellationIT - method: testCancel - issue: https://github.com/elastic/elasticsearch/issues/117568 # Examples: # diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersCancellationIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersCancellationIT.java index c426e0f528eab..5ffc92636b272 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersCancellationIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/CrossClustersCancellationIT.java @@ -179,19 +179,22 @@ public void testCancel() throws Exception { }); var cancelRequest = new CancelTasksRequest().setTargetTaskId(rootTasks.get(0).taskId()).setReason("proxy timeout"); client().execute(TransportCancelTasksAction.TYPE, cancelRequest); - assertBusy(() -> { - List drivers = client(REMOTE_CLUSTER).admin() - .cluster() - .prepareListTasks() - .setActions(DriverTaskRunner.ACTION_NAME) - .get() - .getTasks(); - assertThat(drivers.size(), greaterThanOrEqualTo(1)); - for (TaskInfo driver : drivers) { - assertTrue(driver.cancellable()); - } - }); - PauseFieldPlugin.allowEmitting.countDown(); + try { + assertBusy(() -> { + List drivers = client(REMOTE_CLUSTER).admin() + .cluster() + .prepareListTasks() + .setActions(DriverTaskRunner.ACTION_NAME) + .get() + .getTasks(); + assertThat(drivers.size(), greaterThanOrEqualTo(1)); + for (TaskInfo driver : drivers) { + assertTrue(driver.cancelled()); + } + }); + } finally { + PauseFieldPlugin.allowEmitting.countDown(); + } Exception error = expectThrows(Exception.class, requestFuture::actionGet); assertThat(error.getMessage(), containsString("proxy timeout")); } From 5025f6cd3d9ba7b008ff9bdca91c1a466b36a2e6 Mon Sep 17 00:00:00 2001 From: Armin Braun Date: Sun, 1 Dec 2024 10:10:56 +0100 Subject: [PATCH 128/129] Lazy compute description in ReplicationRequest.createTask (#117783) These can at times be quite long strings, no need to materialize unless requested. This is showing up as allocating needless heap of O(GB) in some benchmarks during indexing needlessly. --- .../action/support/replication/ReplicationRequest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/action/support/replication/ReplicationRequest.java b/server/src/main/java/org/elasticsearch/action/support/replication/ReplicationRequest.java index 530f22f4bed53..debc64914a171 100644 --- a/server/src/main/java/org/elasticsearch/action/support/replication/ReplicationRequest.java +++ b/server/src/main/java/org/elasticsearch/action/support/replication/ReplicationRequest.java @@ -210,7 +210,12 @@ public void writeThin(StreamOutput out) throws IOException { @Override public Task createTask(long id, String type, String action, TaskId parentTaskId, Map headers) { - return new ReplicationTask(id, type, action, getDescription(), parentTaskId, headers); + return new ReplicationTask(id, type, action, "", parentTaskId, headers) { + @Override + public String getDescription() { + return ReplicationRequest.this.getDescription(); + } + }; } @Override From 3e7159d9e97e2d1645e5d5bc56fb98c653186b9f Mon Sep 17 00:00:00 2001 From: Rene Groeschke Date: Sun, 1 Dec 2024 21:33:27 +0100 Subject: [PATCH 129/129] [Build] Fix cacheability of discovery-azure-classic (#117806) Also update cache validation scripts --- .buildkite/scripts/gradle-build-cache-validation.sh | 7 +++---- plugins/discovery-azure-classic/build.gradle | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.buildkite/scripts/gradle-build-cache-validation.sh b/.buildkite/scripts/gradle-build-cache-validation.sh index 75dc9b264b8bc..3c5021e436e4a 100755 --- a/.buildkite/scripts/gradle-build-cache-validation.sh +++ b/.buildkite/scripts/gradle-build-cache-validation.sh @@ -2,18 +2,17 @@ set -euo pipefail -VALIDATION_SCRIPTS_VERSION=2.5.1 +VALIDATION_SCRIPTS_VERSION=2.7.1 GRADLE_ENTERPRISE_ACCESS_KEY=$(vault kv get -field=value secret/ci/elastic-elasticsearch/gradle-enterprise-api-key) export GRADLE_ENTERPRISE_ACCESS_KEY - -curl -s -L -O https://github.com/gradle/gradle-enterprise-build-validation-scripts/releases/download/v$VALIDATION_SCRIPTS_VERSION/gradle-enterprise-gradle-build-validation-$VALIDATION_SCRIPTS_VERSION.zip && unzip -q -o gradle-enterprise-gradle-build-validation-$VALIDATION_SCRIPTS_VERSION.zip +curl -s -L -O https://github.com/gradle/gradle-enterprise-build-validation-scripts/releases/download/v$VALIDATION_SCRIPTS_VERSION/develocity-gradle-build-validation-$VALIDATION_SCRIPTS_VERSION.zip && unzip -q -o develocity-gradle-build-validation-$VALIDATION_SCRIPTS_VERSION.zip # Create a temporary file tmpOutputFile=$(mktemp) trap "rm $tmpOutputFile" EXIT set +e -gradle-enterprise-gradle-build-validation/03-validate-local-build-caching-different-locations.sh -r https://github.com/elastic/elasticsearch.git -b $BUILDKITE_BRANCH --gradle-enterprise-server https://gradle-enterprise.elastic.co -t precommit --fail-if-not-fully-cacheable | tee $tmpOutputFile +develocity-gradle-build-validation/03-validate-local-build-caching-different-locations.sh -r https://github.com/elastic/elasticsearch.git -b $BUILDKITE_BRANCH --develocity-server https://gradle-enterprise.elastic.co -t precommit --fail-if-not-fully-cacheable | tee $tmpOutputFile # Capture the return value retval=$? set -e diff --git a/plugins/discovery-azure-classic/build.gradle b/plugins/discovery-azure-classic/build.gradle index 3ec2ec531ae92..9549236775bfe 100644 --- a/plugins/discovery-azure-classic/build.gradle +++ b/plugins/discovery-azure-classic/build.gradle @@ -65,9 +65,10 @@ TaskProvider createKey = tasks.register("createKey", LoggedExec) { outputs.file(keystore).withPropertyName('keystoreFile') executable = "${buildParams.runtimeJavaHome.get()}/bin/keytool" getStandardInput().set('FirstName LastName\nUnit\nOrganization\nCity\nState\nNL\nyes\n\n') + String keystorePath = projectDir.toPath().relativize(keystore.toPath()).toString() args '-genkey', '-alias', 'test-node', - '-keystore', keystore, + '-keystore', keystorePath, '-keyalg', 'RSA', '-keysize', '2048', '-validity', '712',

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 rw#|S*(tnD9!bT;hn3+St!FuvB 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