This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new c00ecb1c5c0 [improvement](external) Support Elasticsearch 9.x node
discovery (#69651)
c00ecb1c5c0 is described below
commit c00ecb1c5c016f3823dae48a87213e36cdf8d05d
Author: Gabriel <[email protected]>
AuthorDate: Tue Oct 27 14:07:28 2026 +0800
[improvement](external) Support Elasticsearch 9.x node discovery (#69651)
### What problem does this PR solve?
Related PR: #69650 (reader regression coverage)
With `nodes_discovery=true`, ES metadata loading follows
`EsMetadataFetcher.fetchShards -> EsConnectorRestClient.getHttpNodes ->
EsNodeInfo -> EsMajorVersion.parse`. The parser accepts only majors
through 8, so a 9.x node makes discovery fail with an
unsupported-version exception. The configured-seed path with discovery
disabled does not parse node versions and is not affected in the same
way.
Add the explicit ES 9 major, comparisons and latest-supported bound
while continuing to reject unvalidated future majors with a clear
diagnostic. Add an HTTP unit test containing mixed 8.19/9.0 nodes, IPv6
routing and a node without HTTP, plus an opt-in live compatibility suite
rather than relying on version parsing alone.
The live suite creates isolated temporary indices and checks the
production connector's `_nodes/http`, `_mapping`, `_search_shards`,
index listing and metadata fetching with discovery enabled/disabled. It
also verifies authenticated source/docvalue scroll requests, missing
values, pagination, scroll cleanup and invalid-credential failures.
Enable it with `DORIS_ES9_TEST_URL`, `DORIS_ES9_TEST_USER` and
`DORIS_ES9_TEST_PASSWORD`.
### Release note
Allow Elasticsearch 9.x nodes during ES catalog node discovery.
### Check List (For Author)
- Test
- [x] Version parsing and mixed-node tests reproduce the unsupported-9.x
failure before the change.
- [x] All 124 ES connector tests pass with live authenticated
Elasticsearch 9.0.0 and 9.5.5, including the three opt-in compatibility
tests on each version.
- [x] Default unit-test execution passes with the live suite disabled
when no endpoint is configured.
- [x] FE Checkstyle for the ES module and its reactor dependencies.
- [x] The BE ES reader also reads the authenticated three-row fixture
through source/docvalue scans with batch size 1 on both versions, using
the reader coverage change in the related ES regression PR.
- [ ] Full Doris SQL end-to-end validation against ES 9; the live tests
above validate connector metadata and HTTP reader compatibility.
- Behavior changed:
- [x] Yes: discovery accepts ES 9 nodes; later unverified majors remain
rejected.
- Does this need documentation?
- [x] No new catalog properties or configuration are introduced.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../apache/doris/connector/es/EsMajorVersion.java | 8 +-
.../doris/connector/es/Es9CompatibilityTest.java | 163 +++++++++++++++++++++
.../connector/es/EsConnectorRestClientTest.java | 31 ++++
.../doris/connector/es/EsMajorVersionTest.java | 26 +++-
4 files changed, 224 insertions(+), 4 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMajorVersion.java
b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMajorVersion.java
index 853ea01c841..be2e776196d 100644
---
a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMajorVersion.java
+++
b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMajorVersion.java
@@ -32,7 +32,8 @@ public class EsMajorVersion {
public static final EsMajorVersion V_6_X = new EsMajorVersion((byte) 6,
"6.x");
public static final EsMajorVersion V_7_X = new EsMajorVersion((byte) 7,
"7.x");
public static final EsMajorVersion V_8_X = new EsMajorVersion((byte) 8,
"8.x");
- public static final EsMajorVersion LATEST = V_8_X;
+ public static final EsMajorVersion V_9_X = new EsMajorVersion((byte) 9,
"9.x");
+ public static final EsMajorVersion LATEST = V_9_X;
public final byte major;
private final String version;
@@ -88,6 +89,11 @@ public class EsMajorVersion {
if (version.startsWith("8.")) {
return new EsMajorVersion((byte) 8, version);
}
+ // Node discovery parses every node during rolling upgrades; ES 9 uses
the same typeless
+ // mapping and HTTP scan APIs as ES 8. Keep future, unverified majors
explicitly rejected.
+ if (version.startsWith("9.")) {
+ return new EsMajorVersion((byte) 9, version);
+ }
throw new DorisConnectorException(
"Unsupported/Unknown ES Cluster version [" + version + "]. "
+ "Highest supported version is [" + LATEST.version +
"].");
diff --git
a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/Es9CompatibilityTest.java
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/Es9CompatibilityTest.java
new file mode 100644
index 00000000000..0b97f55e672
--- /dev/null
+++
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/Es9CompatibilityTest.java
@@ -0,0 +1,163 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.es;
+
+import org.apache.doris.connector.spi.DorisConnectorException;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Opt-in live compatibility checks. Set DORIS_ES9_TEST_URL,
DORIS_ES9_TEST_USER and
+ * DORIS_ES9_TEST_PASSWORD to an ES 9 instance with permission to create a
temporary test index.
+ */
+@EnabledIfEnvironmentVariable(named = "DORIS_ES9_TEST_URL", matches = ".+")
+class Es9CompatibilityTest {
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private final HttpClient http =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+ private final String endpoint =
System.getenv("DORIS_ES9_TEST_URL").replaceAll("/+$", "");
+ private final String user =
System.getenv().getOrDefault("DORIS_ES9_TEST_USER", "elastic");
+ private final String password = System.getenv("DORIS_ES9_TEST_PASSWORD");
+ private final String index = "doris_es9_compat_" +
UUID.randomUUID().toString().replace("-", "");
+
+ @BeforeEach
+ void createFixture() throws Exception {
+ Assertions.assertNotNull(password, "DORIS_ES9_TEST_PASSWORD is
required");
+ Assertions.assertTrue(jsonRequest("GET", "/",
"").path("version").path("number").asText().startsWith("9."));
+ jsonRequest("PUT", "/" + index,
"{\"settings\":{\"number_of_shards\":2,\"number_of_replicas\":0},"
+ +
"\"mappings\":{\"properties\":{\"id\":{\"type\":\"integer\"},"
+ + "\"name\":{\"type\":\"keyword\"}}}}");
+ StringBuilder bulk = new StringBuilder();
+ for (int id = 1; id <= 3; id++) {
+ bulk.append("{\"index\":{\"_id\":\"").append(id).append("\"}}\n");
+ bulk.append("{\"id\":").append(id).append(",\"name\":")
+ .append(id == 3 ? "null" : id == 1 ? "\"alpha\"" :
"\"beta\"").append("}\n");
+ }
+ Assertions.assertFalse(jsonRequest("POST", "/" + index +
"/_bulk?refresh=true", bulk.toString())
+ .path("errors").asBoolean());
+ }
+
+ @AfterEach
+ void removeFixture() throws Exception {
+ HttpResponse<String> response = request("DELETE", "/" + index, "");
+ Assertions.assertTrue(response.statusCode() == 200 ||
response.statusCode() == 404);
+ }
+
+ @Test
+ void metadataDiscoveryAndSeedRoutingAcceptNine() {
+ EsConnectorRestClient client = client(password);
+ Assertions.assertTrue(client.existIndex(index));
+ Assertions.assertFalse(client.getHttpNodes().isEmpty());
+ Assertions.assertTrue(client.listTable(false).contains(index));
+ Assertions.assertEquals(2,
client.searchShards(index).getShardRoutings().size());
+ Assertions.assertEquals(2, EsTypeMapping.parseMapping(index,
client.getMapping(index), false).size());
+ for (boolean discover : new boolean[]{false, true}) {
+ EsMetadataState state = new EsMetadataState(index, null,
Arrays.asList("id", "name"),
+ discover, new String[]{endpoint});
+ EsMetadataState fetched = new EsMetadataFetcher(client, state,
null).fetch();
+ Assertions.assertEquals(2,
fetched.getShardPartitions().getShardRoutings().size());
+ Assertions.assertEquals(Map.of("id", "id", "name", "name"),
+ fetched.getFieldContext().getDocValueFieldsContext());
+
fetched.getShardPartitions().getShardRoutings().values().forEach(routes ->
routes.forEach(route -> {
+ Assertions.assertNotNull(route.getHttpHost());
+ Assertions.assertTrue(route.getHttpPort() > 0);
+ }));
+ }
+ }
+
+ @Test
+ void sourceAndDocvalueScrollsReturnAllRowsAndCanBeCleared() throws
Exception {
+ for (boolean docvalues : new boolean[]{false, true}) {
+ String projection = docvalues ?
"\"_source\":false,\"docvalue_fields\":[\"id\",\"name\"]"
+ : "\"_source\":[\"id\",\"name\"]";
+ JsonNode response = jsonRequest("POST", "/" + index +
"/_search?scroll=1m",
+
"{\"size\":1,\"sort\":[\"_doc\"],\"query\":{\"match_all\":{}}," + projection +
"}");
+ String scrollId = response.path("_scroll_id").asText();
+ Set<Integer> ids = new HashSet<>();
+ int pages = 0;
+ try {
+ while (!response.path("hits").path("hits").isEmpty()) {
+ for (JsonNode hit : response.path("hits").path("hits")) {
+ JsonNode row = hit.path(docvalues ? "fields" :
"_source");
+ int id = (docvalues ? row.path("id").path(0) :
row.path("id")).asInt();
+ Assertions.assertTrue(ids.add(id));
+ JsonNode name = docvalues ? row.path("name").path(0) :
row.path("name");
+ if (id == 3) {
+ Assertions.assertTrue(name.isNull() ||
name.isMissingNode());
+ } else {
+ Assertions.assertEquals(id == 1 ? "alpha" :
"beta", name.asText());
+ }
+ }
+ pages++;
+ response = jsonRequest("POST", "/_search/scroll",
JSON.writeValueAsString(
+ Map.of("scroll", "1m", "scroll_id", scrollId)));
+ scrollId = response.path("_scroll_id").asText();
+ }
+ Assertions.assertEquals(Set.of(1, 2, 3), ids);
+ Assertions.assertEquals(3, pages);
+ } finally {
+ Assertions.assertTrue(jsonRequest("DELETE", "/_search/scroll",
+ JSON.writeValueAsString(Map.of("scroll_id",
scrollId))).path("succeeded").asBoolean());
+ }
+ }
+ }
+
+ @Test
+ void invalidCredentialsFailInsteadOfHidingIndex() {
+ Assertions.assertThrows(DorisConnectorException.class, () ->
client(password + "-invalid").existIndex(index));
+ }
+
+ private EsConnectorRestClient client(String secret) {
+ return new EsConnectorRestClient(new String[]{endpoint}, user, secret,
false, null);
+ }
+
+ private JsonNode jsonRequest(String method, String path, String body)
throws Exception {
+ HttpResponse<String> response = request(method, path, body);
+ Assertions.assertTrue(response.statusCode() >= 200 &&
response.statusCode() < 300,
+ () -> method + " " + path + " returned HTTP " +
response.statusCode());
+ return JSON.readTree(response.body());
+ }
+
+ private HttpResponse<String> request(String method, String path, String
body) throws Exception {
+ String credentials = Base64.getEncoder().encodeToString((user + ":" +
password)
+ .getBytes(StandardCharsets.UTF_8));
+ HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint +
path)).timeout(Duration.ofSeconds(30))
+ .header("Authorization", "Basic " +
credentials).header("Content-Type", "application/json")
+ .method(method,
HttpRequest.BodyPublishers.ofString(body)).build();
+ return http.send(request, HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorRestClientTest.java
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorRestClientTest.java
index d1354c93744..874386a7b64 100644
---
a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorRestClientTest.java
+++
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorRestClientTest.java
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
@@ -38,6 +40,35 @@ import java.util.concurrent.atomic.AtomicInteger;
*/
public class EsConnectorRestClientTest {
+ @Test
+ void mixedEightAndNineNodesAreDiscoveredDuringUpgrade() throws Exception {
+ String nodes = "{\"nodes\":{"
+ +
"\"eight\":{\"version\":\"8.19.0\",\"name\":\"eight\",\"roles\":[\"data\",\"ingest\"],"
+ + "\"http\":{\"publish_address\":\"127.0.0.1:9200\"}},"
+ +
"\"nine\":{\"version\":\"9.0.0\",\"name\":\"nine\",\"roles\":[\"data\"],"
+ + "\"http\":{\"publish_address\":\"node/[::1]:9201\"}},"
+ +
"\"no-http\":{\"version\":\"9.0.0\",\"name\":\"master\",\"roles\":[\"master\"]}}}";
+ byte[] body = nodes.getBytes(StandardCharsets.UTF_8);
+ HttpServer server = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/_nodes/http", exchange -> {
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.start();
+ try {
+ EsConnectorRestClient client = new EsConnectorRestClient(
+ new String[]{"http://127.0.0.1:" +
server.getAddress().getPort()}, null, null, false, null);
+ Map<String, EsNodeInfo> discovered = client.getHttpNodes();
+ Assertions.assertEquals(Set.of("eight", "nine"),
discovered.keySet());
+ Assertions.assertEquals("127.0.0.1:9200",
discovered.get("eight").getPublishAddress());
+ Assertions.assertEquals("[::1]:9201",
discovered.get("nine").getPublishAddress());
+ Assertions.assertTrue(discovered.get("nine").isData());
+ } finally {
+ server.stop(0);
+ }
+ }
+
@Test
public void indexAbsenceRequiresNotFoundResponse() throws Exception {
AtomicInteger status = new AtomicInteger(200);
diff --git
a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsMajorVersionTest.java
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsMajorVersionTest.java
index 038c879afc1..667aa56cfe7 100644
---
a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsMajorVersionTest.java
+++
b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsMajorVersionTest.java
@@ -39,6 +39,23 @@ class EsMajorVersionTest {
Assertions.assertEquals(8, v.major);
}
+ @Test
+ void testParseVersion9() {
+ for (String version : new String[]{"9.0.0", "9.5.5"}) {
+ EsMajorVersion parsed = EsMajorVersion.parse(version);
+ Assertions.assertEquals(9, parsed.major);
+ Assertions.assertEquals(version, parsed.toString());
+ Assertions.assertTrue(parsed.after(EsMajorVersion.V_8_X));
+ Assertions.assertTrue(parsed.onOrAfter(EsMajorVersion.V_8_X));
+ Assertions.assertTrue(EsMajorVersion.V_8_X.before(parsed));
+ Assertions.assertTrue(EsMajorVersion.V_8_X.onOrBefore(parsed));
+ Assertions.assertFalse(parsed.before(EsMajorVersion.V_8_X));
+ }
+ Assertions.assertEquals(EsMajorVersion.parse("9.0.0"),
EsMajorVersion.parse("9.5.5"));
+ Assertions.assertEquals(EsMajorVersion.parse("9.0.0").hashCode(),
+ EsMajorVersion.parse("9.5.5").hashCode());
+ }
+
@Test
void testParseVersion6() {
EsMajorVersion v = EsMajorVersion.parse("6.8.23");
@@ -61,7 +78,7 @@ class EsMajorVersionTest {
@Test
void testParseUnsupportedVersionThrows() {
Assertions.assertThrows(DorisConnectorException.class,
- () -> EsMajorVersion.parse("9.0.0"));
+ () -> EsMajorVersion.parse("10.0.0"));
Assertions.assertThrows(DorisConnectorException.class,
() -> EsMajorVersion.parse("3.0.0"));
}
@@ -123,7 +140,10 @@ class EsMajorVersionTest {
}
@Test
- void testLatestIsV8() {
- Assertions.assertEquals(EsMajorVersion.V_8_X, EsMajorVersion.LATEST);
+ void testLatestIsV9() {
+ Assertions.assertEquals(EsMajorVersion.parse("9.0.0"),
EsMajorVersion.LATEST);
+ DorisConnectorException failure =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> EsMajorVersion.parse("10.0.0"));
+ Assertions.assertTrue(failure.getMessage().contains("Highest supported
version is [9.x]"));
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]