} containing such URIs.
+ *
+ * All the URIs must specify the same protocol.
+ *
+ * Setting this property at the same time as {@link #HOSTS} or {@link #PROTOCOL} will lead to an exception being thrown on startup.
+ *
+ * Defaults to {@code http://localhost:9200}, unless {@link #HOSTS} or {@link #PROTOCOL} are set, in which case they take precedence.
+ */
+ public static final String URIS = "uris";
+
+ /**
+ * Property for specifying the path prefix prepended to the request end point.
+ * Use the path prefix if your Elasticsearch instance is located at a specific context path.
+ *
+ * Defaults to {@link Defaults#PATH_PREFIX}.
+ */
+ public static final String PATH_PREFIX = "path_prefix";
+
+ /**
+ * The username to send when connecting to the Elasticsearch servers (HTTP authentication).
+ *
+ * Expects a String.
+ *
+ * Defaults to no username (anonymous access).
+ */
+ public static final String USERNAME = "username";
+
+ /**
+ * The password to send when connecting to the Elasticsearch servers (HTTP authentication).
+ *
+ * Expects a String.
+ *
+ * Defaults to no username (anonymous access).
+ */
+ public static final String PASSWORD = "password";
+
+ /**
+ * Default values for the different settings if no values are given.
+ */
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+
+ public static final List HOSTS = Collections.singletonList( "localhost:9200" );
+ public static final String PROTOCOL = "http";
+ public static final String PATH_PREFIX = "";
+ }
+}
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/cfg/spi/ElasticsearchBackendClientSpiSettings.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/cfg/spi/ElasticsearchBackendClientSpiSettings.java
new file mode 100644
index 00000000000..6cdf9e7d899
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/cfg/spi/ElasticsearchBackendClientSpiSettings.java
@@ -0,0 +1,20 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.common.cfg.spi;
+
+import org.hibernate.search.util.common.annotation.Incubating;
+
+/**
+ * Implementation-related settings.
+ */
+@Incubating
+public final class ElasticsearchBackendClientSpiSettings {
+
+ private ElasticsearchBackendClientSpiSettings() {
+ }
+
+ public static final String CLIENT_FACTORY = "client_factory";
+
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/GsonProvider.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/GsonProvider.java
similarity index 54%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/GsonProvider.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/GsonProvider.java
index c6b767be61f..a0b2fc2a2bf 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/GsonProvider.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/GsonProvider.java
@@ -2,15 +2,11 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.gson.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.gson.spi;
import java.util.Set;
import java.util.function.Supplier;
-import org.hibernate.search.backend.elasticsearch.lowlevel.index.mapping.impl.RootTypeMapping;
-import org.hibernate.search.backend.elasticsearch.lowlevel.index.settings.impl.IndexSettings;
-import org.hibernate.search.util.common.impl.CollectionHelper;
-
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
@@ -21,24 +17,18 @@
public final class GsonProvider {
/*
- * See https://github.com/google/gson/issues/764.
- *
- * Only composite type adapters (referring to another type adapter)
- * should be affected by this bug, so we'll list the corresponding types here.
- * Maybe it's even narrower, and only type adapters that indirectly refer to themselves are affected,
- * but I'm not entirely sure about that.
- *
- * Note we only need to list "root" types:
- * all types they refer to will also have their type adapter initialized.
+ * https://github.com/google/gson/issues/764 is supposedly fixed.
+ * Hence, we may not need the workaround anymore.
+ * This version will be used in the test so we'll notice if things are still broken, for the main code
+ * the workaround is in place through the GsonProviderHelper.
*/
- private static final Set> TYPES_CAUSING_GSON_CONCURRENT_INITIALIZATION_BUG =
- CollectionHelper.asImmutableSet(
- TypeToken.get( IndexSettings.class ),
- TypeToken.get( RootTypeMapping.class )
- );
-
public static GsonProvider create(Supplier builderBaseSupplier, boolean logPrettyPrinting) {
- return new GsonProvider( builderBaseSupplier, logPrettyPrinting );
+ return create( builderBaseSupplier, logPrettyPrinting, Set.of() );
+ }
+
+ public static GsonProvider create(Supplier builderBaseSupplier, boolean logPrettyPrinting,
+ Set> typeTokensToInit) {
+ return new GsonProvider( builderBaseSupplier, logPrettyPrinting, typeTokensToInit );
}
private final Gson gson;
@@ -47,16 +37,17 @@ public static GsonProvider create(Supplier builderBaseSupplier, boo
private final JsonLogHelper logHelper;
- private GsonProvider(Supplier builderBaseSupplier, boolean logPrettyPrinting) {
+ private GsonProvider(Supplier builderBaseSupplier, boolean logPrettyPrinting,
+ Set> typeTokensToInit) {
// Null serialization needs to be enabled to index null fields
gson = builderBaseSupplier.get()
.serializeNulls()
.create();
- initializeTypeAdapters( gson );
+ initializeTypeAdapters( gson, typeTokensToInit );
gsonNoSerializeNulls = builderBaseSupplier.get()
.create();
- initializeTypeAdapters( gsonNoSerializeNulls );
+ initializeTypeAdapters( gsonNoSerializeNulls, typeTokensToInit );
logHelper = JsonLogHelper.create( builderBaseSupplier.get(), logPrettyPrinting );
}
@@ -81,8 +72,8 @@ public JsonLogHelper getLogHelper() {
* We just initialize every adapter known to cause problems before we make the Gson object
* available to multiple threads.
*/
- private static void initializeTypeAdapters(Gson gson) {
- for ( TypeToken> typeToken : TYPES_CAUSING_GSON_CONCURRENT_INITIALIZATION_BUG ) {
+ private static void initializeTypeAdapters(Gson gson, Set> typeTokensToInit) {
+ for ( TypeToken> typeToken : typeTokensToInit ) {
gson.getAdapter( typeToken );
}
}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/JsonLogHelper.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/JsonLogHelper.java
similarity index 96%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/JsonLogHelper.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/JsonLogHelper.java
index 40c22c826e4..028aac011ea 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/gson/spi/JsonLogHelper.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/gson/spi/JsonLogHelper.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.gson.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.gson.spi;
import java.io.PrintWriter;
import java.io.StringWriter;
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientCommonLog.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientCommonLog.java
new file mode 100644
index 00000000000..3d45c776b4e
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientCommonLog.java
@@ -0,0 +1,57 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
+
+import static org.jboss.logging.Logger.Level.TRACE;
+
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
+import org.hibernate.search.util.common.logging.impl.MessageConstants;
+
+import org.jboss.logging.annotations.LogMessage;
+import org.jboss.logging.annotations.Message;
+import org.jboss.logging.annotations.MessageLogger;
+import org.jboss.logging.annotations.ValidIdRange;
+import org.jboss.logging.annotations.ValidIdRanges;
+
+@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+@ValidIdRanges({
+ @ValidIdRange(min = MessageConstants.BACKEND_ES_CLIENT_ID_RANGE_MIN,
+ max = MessageConstants.BACKEND_ES_CLIENT_ID_RANGE_MAX),
+ // Exceptions for legacy messages from Search 5 (engine module)
+ @ValidIdRange(min = 35, max = 35),
+})
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ +
+ "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
+public interface ElasticsearchClientCommonLog
+ extends ElasticsearchRequestLog, ElasticsearchClientLog {
+
+ // -----------------------------------
+ // Pre-existing messages from Search 5 (ES module)
+ // DO NOT ADD ANY NEW MESSAGES HERE
+ // -----------------------------------
+ int ID_OFFSET_LEGACY_ES = MessageConstants.BACKEND_ES_ID_RANGE_MIN;
+
+ // -----------------------------------
+ // New (old) messages from Search 6 onwards up until Search 8.2
+ // -----------------------------------
+ int ID_BACKEND_OFFSET = MessageConstants.BACKEND_ES_ID_RANGE_MIN + 500;
+
+ // -----------------------------------
+ // New messages from Search 8.2 onwards
+ // -----------------------------------
+ int ID_OFFSET = MessageConstants.BACKEND_ES_CLIENT_ID_RANGE_MIN;
+
+ /**
+ * Only here as a way to track the highest "already used id".
+ * When adding a new exception or log message use this id and bump the one
+ * here to the next value.
+ */
+ @LogMessage(level = TRACE)
+ @Message(id = ID_OFFSET + 2, value = "")
+ void nextLoggerIdForConvenience();
+}
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientConfigurationLog.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientConfigurationLog.java
new file mode 100644
index 00000000000..8816d58356a
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientConfigurationLog.java
@@ -0,0 +1,101 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
+
+import static org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientCommonLog.ID_BACKEND_OFFSET;
+import static org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientCommonLog.ID_OFFSET;
+import static org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientCommonLog.ID_OFFSET_LEGACY_ES;
+
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+
+import org.hibernate.search.util.common.SearchException;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
+import org.hibernate.search.util.common.logging.CategorizedLogger;
+import org.hibernate.search.util.common.logging.impl.LoggerFactory;
+import org.hibernate.search.util.common.logging.impl.MessageConstants;
+
+import org.jboss.logging.Logger;
+import org.jboss.logging.annotations.Cause;
+import org.jboss.logging.annotations.LogMessage;
+import org.jboss.logging.annotations.Message;
+import org.jboss.logging.annotations.MessageLogger;
+
+@CategorizedLogger(
+ category = ElasticsearchClientConfigurationLog.CATEGORY_NAME,
+ description = """
+ Logs related to the Elasticsearch-specific backend configuration.
+ """
+)
+@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ +
+ "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
+public interface ElasticsearchClientConfigurationLog {
+ String CATEGORY_NAME = "org.hibernate.search.configuration.elasticsearch.client";
+
+ ElasticsearchClientConfigurationLog INSTANCE =
+ LoggerFactory.make( ElasticsearchClientConfigurationLog.class, CATEGORY_NAME, MethodHandles.lookup() );
+
+ // -----------------------------------
+ // Pre-existing messages from Search 5 (engine module)
+ // DO NOT ADD ANY NEW MESSAGES HERE
+ // -----------------------------------
+
+ @Message(id = ID_OFFSET_LEGACY_ES + 22, value = "Invalid index status: '%1$s'."
+ + " Valid statuses are: %2$s.")
+ SearchException invalidIndexStatus(String invalidRepresentation, List validRepresentations);
+
+ @LogMessage(level = Logger.Level.WARN)
+ @Message(id = ID_OFFSET_LEGACY_ES + 73,
+ value = "Hibernate Search will connect to Elasticsearch with authentication over plain HTTP (not HTTPS)."
+ + " The password will be sent in clear text over the network.")
+ void usingPasswordOverHttp();
+
+ // -----------------------------------
+ // New messages from Search 6 onwards
+ // -----------------------------------
+
+ @Message(id = ID_BACKEND_OFFSET + 89, value = "Invalid host/port: '%1$s'."
+ + " The host/port string must use the format 'host:port', for example 'mycompany.com:9200'"
+ + " The URI scheme ('http://', 'https://') must not be included.")
+ SearchException invalidHostAndPort(String hostAndPort, @Cause Exception e);
+
+ @Message(id = ID_BACKEND_OFFSET + 126, value = "Invalid target hosts configuration:"
+ + " both the 'uris' property and the 'protocol' property are set."
+ + " Uris: '%1$s'. Protocol: '%2$s'."
+ + " Either set the protocol and hosts simultaneously using the 'uris' property,"
+ + " or set them separately using the 'protocol' property and the 'hosts' property.")
+ SearchException uriAndProtocol(List uris, String protocol);
+
+ @Message(id = ID_BACKEND_OFFSET + 127, value = "Invalid target hosts configuration:"
+ + " both the 'uris' property and the 'hosts' property are set."
+ + " Uris: '%1$s'. Hosts: '%2$s'."
+ + " Either set the protocol and hosts simultaneously using the 'uris' property,"
+ + " or set them separately using the 'protocol' property and the 'hosts' property.")
+ SearchException uriAndHosts(List uris, List hosts);
+
+ @Message(id = ID_BACKEND_OFFSET + 128,
+ value = "Invalid target hosts configuration: the 'uris' use different protocols (http, https)."
+ + " All URIs must use the same protocol. Uris: '%1$s'.")
+ SearchException differentProtocolsOnUris(List uris);
+
+ @Message(id = ID_BACKEND_OFFSET + 129,
+ value = "Invalid target hosts configuration: the list of hosts must not be empty.")
+ SearchException emptyListOfHosts();
+
+ @Message(id = ID_BACKEND_OFFSET + 130,
+ value = "Invalid target hosts configuration: the list of URIs must not be empty.")
+ SearchException emptyListOfUris();
+
+ // -----------------------------------
+ // New messages from Search 8.2 onwards
+ // -----------------------------------
+
+ @Message(id = ID_OFFSET + 1, value = "Invalid uri: '%1$s'. Reason: %2$s")
+ SearchException invalidUri(String uri, String reason, @Cause Exception e);
+
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchClientLog.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientLog.java
similarity index 78%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchClientLog.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientLog.java
index 1fc7681d47a..9eee10e7c6e 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchClientLog.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchClientLog.java
@@ -2,23 +2,25 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.logging.impl;
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
-import static org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchLog.ID_OFFSET;
-import static org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchLog.ID_OFFSET_LEGACY_ES;
+import static org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientCommonLog.ID_BACKEND_OFFSET;
+import static org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientCommonLog.ID_OFFSET_LEGACY_ES;
import java.lang.invoke.MethodHandles;
import java.time.Duration;
import java.util.Set;
import java.util.regex.Pattern;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchRequest;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchResponse;
-import org.hibernate.search.backend.elasticsearch.util.spi.URLEncodedString;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchResponse;
+import org.hibernate.search.backend.elasticsearch.client.common.util.spi.URLEncodedString;
import org.hibernate.search.util.common.AssertionFailure;
import org.hibernate.search.util.common.SearchException;
import org.hibernate.search.util.common.SearchTimeoutException;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
import org.hibernate.search.util.common.logging.CategorizedLogger;
+import org.hibernate.search.util.common.logging.impl.ClassFormatter;
import org.hibernate.search.util.common.logging.impl.DurationInSecondsAndFractionsFormatter;
import org.hibernate.search.util.common.logging.impl.LoggerFactory;
import org.hibernate.search.util.common.logging.impl.MessageConstants;
@@ -41,6 +43,10 @@
"""
)
@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ +
+ "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
public interface ElasticsearchClientLog {
String CATEGORY_NAME = "org.hibernate.search.elasticsearch.client";
@@ -103,12 +109,6 @@ SearchException unexpectedIndexStatus(URLEncodedString indexName, String expecte
value = "Opened Elasticsearch index '%1$s' automatically.")
void openedIndex(Object indexName);
- @LogMessage(level = Logger.Level.WARN)
- @Message(id = ID_OFFSET_LEGACY_ES + 73,
- value = "Hibernate Search will connect to Elasticsearch with authentication over plain HTTP (not HTTPS)."
- + " The password will be sent in clear text over the network.")
- void usingPasswordOverHttp();
-
@Message(id = ID_OFFSET_LEGACY_ES + 89,
value = "Unable to parse Elasticsearch response. Status code was '%1$d', status phrase was '%2$s'."
+ " Nested exception: %3$s")
@@ -122,49 +122,58 @@ SearchException failedToParseElasticsearchResponse(int statusCode, String status
// -----------------------------------
// New messages from Search 6 onwards
// -----------------------------------
- @Message(id = ID_OFFSET + 25,
+
+ @Message(id = ID_BACKEND_OFFSET + 18,
+ value = "Invalid requested type for client: '%1$s'."
+ + " The Elasticsearch low-level client can only be unwrapped to '%2$s'.")
+ SearchException clientUnwrappingWithUnknownType(@FormatWith(ClassFormatter.class) Class> requestedClass,
+ @FormatWith(ClassFormatter.class) Class> actualClass);
+
+ @Message(id = ID_BACKEND_OFFSET + 25,
value = "Invalid field reference for this document element:"
+ " this document element has path '%1$s', but the referenced field has a parent with path '%2$s'.")
SearchException invalidFieldForDocumentElement(String expectedPath, String actualPath);
- @Message(id = ID_OFFSET + 26,
+ @Message(id = ID_BACKEND_OFFSET + 26,
value = "Missing data in the Elasticsearch response.")
AssertionFailure elasticsearchResponseMissingData();
- @Message(id = ID_OFFSET + 31,
+ @Message(id = ID_BACKEND_OFFSET + 31,
value = "Unable to resolve index name '%1$s' to an entity type: %2$s")
SearchException elasticsearchResponseUnknownIndexName(String elasticsearchIndexName, String causeMessage,
@Cause Exception e);
- @Message(id = ID_OFFSET + 44, value = "Unable to shut down the Elasticsearch client: %1$s")
+ @Message(id = ID_BACKEND_OFFSET + 44, value = "Unable to shut down the Elasticsearch client: %1$s")
SearchException unableToShutdownClient(String causeMessage, @Cause Exception cause);
- @Message(id = ID_OFFSET + 88, value = "Call to the bulk REST API failed: %1$s")
+ @Message(id = ID_BACKEND_OFFSET + 88, value = "Call to the bulk REST API failed: %1$s")
SearchException elasticsearchFailedBecauseOfBulkFailure(String causeMessage, @Cause Throwable cause);
- @Message(id = ID_OFFSET + 90, value = "Request execution exceeded the timeout of %1$s. Request was %2$s")
+ @Message(id = ID_BACKEND_OFFSET + 90, value = "Request execution exceeded the timeout of %1$s. Request was %2$s")
SearchTimeoutException requestTimedOut(@FormatWith(DurationInSecondsAndFractionsFormatter.class) Duration timeout,
@FormatWith(ElasticsearchRequestFormatter.class) ElasticsearchRequest request);
- @Message(id = ID_OFFSET + 93,
+ @Message(id = ID_BACKEND_OFFSET + 93,
value = "Invalid Elasticsearch index layout:"
+ " index names [%1$s, %2$s] resolve to multiple distinct indexes %3$s."
+ " These names must resolve to a single index.")
SearchException elasticsearchIndexNameAndAliasesMatchMultipleIndexes(URLEncodedString write, URLEncodedString read,
Set matchingIndexes);
- @Message(id = ID_OFFSET + 94,
+ @Message(id = ID_BACKEND_OFFSET + 94,
value = "Invalid Elasticsearch index layout:"
+ " primary (non-alias) name for existing Elasticsearch index '%1$s'"
+ " does not match the expected pattern '%2$s'.")
SearchException invalidIndexPrimaryName(String elasticsearchIndexName, Pattern pattern);
- @Message(id = ID_OFFSET + 95,
+ @Message(id = ID_BACKEND_OFFSET + 95,
value = "Invalid Elasticsearch index layout:"
+ " unique key '%1$s' extracted from the index name does not match any of %2$s.")
SearchException invalidIndexUniqueKey(String uniqueKey, Set knownKeys);
- @Message(id = ID_OFFSET + 125,
+ @Message(id = ID_BACKEND_OFFSET + 125,
value = "Unable to update aliases for index '%1$s': %2$s")
SearchException elasticsearchAliasUpdateFailed(Object indexName, String causeMessage, @Cause Exception cause);
+
+
}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchJsonObjectFormatter.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchJsonObjectFormatter.java
similarity index 76%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchJsonObjectFormatter.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchJsonObjectFormatter.java
index 862faf27d71..54e3f6bfcb2 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchJsonObjectFormatter.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchJsonObjectFormatter.java
@@ -2,9 +2,9 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.logging.impl;
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
-import org.hibernate.search.backend.elasticsearch.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.JsonLogHelper;
import com.google.gson.JsonObject;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestFormatter.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestFormatter.java
similarity index 85%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestFormatter.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestFormatter.java
index 4eab62a9cd8..22d128b04ff 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestFormatter.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestFormatter.java
@@ -2,9 +2,9 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.logging.impl;
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchRequest;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
/**
* Used with JBoss Logging's {@link org.jboss.logging.annotations.FormatWith}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestLog.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestLog.java
similarity index 75%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestLog.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestLog.java
index adf053d0eaa..a9d156a32ba 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchRequestLog.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchRequestLog.java
@@ -2,13 +2,12 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.logging.impl;
-
-import static org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchLog.ID_OFFSET_LEGACY_ES;
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
import java.lang.invoke.MethodHandles;
import java.util.Map;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
import org.hibernate.search.util.common.logging.CategorizedLogger;
import org.hibernate.search.util.common.logging.impl.LoggerFactory;
import org.hibernate.search.util.common.logging.impl.MessageConstants;
@@ -19,8 +18,6 @@
import org.jboss.logging.annotations.Message;
import org.jboss.logging.annotations.MessageLogger;
-import org.apache.http.HttpHost;
-
@CategorizedLogger(
category = ElasticsearchRequestLog.CATEGORY_NAME,
description = """
@@ -29,6 +26,10 @@
"""
)
@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ +
+ "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
public interface ElasticsearchRequestLog extends BasicLogger {
/**
* This is the category of the Logger used to print out executed Elasticsearch requests,
@@ -47,21 +48,21 @@ public interface ElasticsearchRequestLog extends BasicLogger {
// -----------------------------------
@LogMessage(level = Logger.Level.DEBUG)
- @Message(id = ID_OFFSET_LEGACY_ES + 82,
+ @Message(id = ElasticsearchClientCommonLog.ID_OFFSET_LEGACY_ES + 82,
value = "Executed Elasticsearch HTTP %s request to '%s' with path '%s',"
+ " query parameters %s and %d objects in payload in %dms."
+ " Response had status %d '%s'. Request body: <%s>. Response body: <%s>")
- void executedRequestWithFailure(String method, HttpHost host, String path, Map getParameters,
+ void executedRequestWithFailure(String method, String host, String path, Map getParameters,
int bodyParts, long timeInMs,
int responseStatusCode, String responseStatusMessage,
String requestBodyParts, String responseBody);
@LogMessage(level = Logger.Level.TRACE)
- @Message(id = ID_OFFSET_LEGACY_ES + 93,
+ @Message(id = ElasticsearchClientCommonLog.ID_OFFSET_LEGACY_ES + 93,
value = "Executed Elasticsearch HTTP %s request to '%s' with path '%s',"
+ " query parameters %s and %d objects in payload in %dms."
+ " Response had status %d '%s'. Request body: <%s>. Response body: <%s>")
- void executedRequest(String method, HttpHost host, String path, Map getParameters, int bodyParts,
+ void executedRequest(String method, String host, String path, Map getParameters, int bodyParts,
long timeInMs,
int responseStatusCode, String responseStatusMessage,
String requestBodyParts, String responseBody);
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchResponseFormatter.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchResponseFormatter.java
similarity index 82%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchResponseFormatter.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchResponseFormatter.java
index 97c1b9f5a09..432837c2da4 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/logging/impl/ElasticsearchResponseFormatter.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/logging/spi/ElasticsearchResponseFormatter.java
@@ -2,10 +2,10 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.logging.impl;
+package org.hibernate.search.backend.elasticsearch.client.common.logging.spi;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchResponse;
-import org.hibernate.search.backend.elasticsearch.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchResponse;
/**
* Used with JBoss Logging's {@link org.jboss.logging.annotations.FormatWith}
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/package-info.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/package-info.java
new file mode 100644
index 00000000000..c4a1719f07a
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/package-info.java
@@ -0,0 +1 @@
+package org.hibernate.search.backend.elasticsearch.client.common;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClient.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClient.java
similarity index 93%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClient.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClient.java
index 26c68da978b..c6ca6c6344d 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClient.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClient.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
import java.util.concurrent.CompletableFuture;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientFactory.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientFactory.java
similarity index 64%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientFactory.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientFactory.java
index e1dfd0e6652..f68d477b8a9 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientFactory.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientFactory.java
@@ -2,27 +2,27 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
-import java.util.Optional;
-
-import org.hibernate.search.backend.elasticsearch.ElasticsearchVersion;
-import org.hibernate.search.backend.elasticsearch.gson.spi.GsonProvider;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
import org.hibernate.search.engine.environment.bean.BeanResolver;
import org.hibernate.search.engine.environment.thread.spi.ThreadProvider;
+import org.hibernate.search.util.common.annotation.Incubating;
/**
* Creates the Elasticsearch client.
- *
*/
public interface ElasticsearchClientFactory {
- ElasticsearchClientImplementor create(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ @Incubating
+ String DEFAULT_BEAN_NAME = "default";
+
+ ElasticsearchClientImplementor create(BeanResolver beanResolver,
+ ConfigurationPropertySource propertySource,
ThreadProvider threadProvider, String threadNamePrefix,
SimpleScheduledExecutor timeoutExecutorService,
- GsonProvider gsonProvider,
- Optional configuredVersion);
+ GsonProvider gsonProvider);
}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientImplementor.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientImplementor.java
similarity index 80%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientImplementor.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientImplementor.java
index 920f4c10087..78c6e3ae400 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchClientImplementor.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchClientImplementor.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
/**
* An interface allowing to close an {@link ElasticsearchClient}.
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchRequest.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequest.java
similarity index 96%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchRequest.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequest.java
index 8566b7c467f..ec811e90344 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchRequest.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequest.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
import java.util.ArrayList;
import java.util.Collection;
@@ -12,7 +12,7 @@
import java.util.Map;
import java.util.StringJoiner;
-import org.hibernate.search.backend.elasticsearch.util.spi.URLEncodedString;
+import org.hibernate.search.backend.elasticsearch.client.common.util.spi.URLEncodedString;
import org.hibernate.search.engine.common.timing.Deadline;
import com.google.gson.JsonObject;
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptor.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptor.java
new file mode 100644
index 00000000000..722a77b62e6
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptor.java
@@ -0,0 +1,44 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+
+import org.hibernate.search.util.common.annotation.Incubating;
+
+@Incubating
+public interface ElasticsearchRequestInterceptor {
+
+ void intercept(RequestContext requestContext) throws IOException;
+
+ interface RequestContext {
+
+ boolean hasContent();
+
+ InputStream content() throws IOException;
+
+ String scheme();
+
+ String host();
+
+ Integer port();
+
+ String method();
+
+ String path();
+
+ Map queryParameters();
+
+ void overrideHeaders(Map> headers);
+
+ /**
+ * @return A String representation of the wrapped request. Primarily used for logging the request.
+ */
+ String toString();
+ }
+}
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptorProvider.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptorProvider.java
new file mode 100644
index 00000000000..3a9d030873a
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchRequestInterceptorProvider.java
@@ -0,0 +1,33 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
+
+import java.util.Optional;
+
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.util.common.annotation.Incubating;
+
+@Incubating
+public interface ElasticsearchRequestInterceptorProvider {
+
+ Optional provide(Context context);
+
+ interface Context {
+ /**
+ * @return A {@link BeanResolver}.
+ */
+ BeanResolver beanResolver();
+
+ /**
+ * @return A configuration property source, appropriately masked so that the factory
+ * doesn't need to care about Hibernate Search prefixes (hibernate.search.*, etc.). All the properties
+ * can be accessed at the root.
+ * CAUTION: the property key "type" is reserved for use by the engine.
+ */
+ ConfigurationPropertySource configurationPropertySource();
+ }
+
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchResponse.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchResponse.java
similarity index 68%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchResponse.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchResponse.java
index aa171dc9e20..4bb709bf107 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/spi/ElasticsearchResponse.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/spi/ElasticsearchResponse.java
@@ -2,29 +2,24 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.spi;
import com.google.gson.JsonObject;
-import org.apache.http.HttpHost;
-
public final class ElasticsearchResponse {
-
- private final HttpHost host;
+ private final String host;
private final int statusCode;
-
private final String statusMessage;
-
private final JsonObject body;
- public ElasticsearchResponse(HttpHost host, int statusCode, String statusMessage, JsonObject body) {
+ public ElasticsearchResponse(String host, int statusCode, String statusMessage, JsonObject body) {
this.host = host;
this.statusCode = statusCode;
this.statusMessage = statusMessage;
this.body = body;
}
- public HttpHost host() {
+ public String host() {
return host;
}
@@ -39,5 +34,4 @@ public String statusMessage() {
public JsonObject body() {
return body;
}
-
}
diff --git a/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/ElasticsearchClientUtils.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/ElasticsearchClientUtils.java
new file mode 100644
index 00000000000..4ecc247a1ac
--- /dev/null
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/ElasticsearchClientUtils.java
@@ -0,0 +1,18 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.common.util.spi;
+
+
+public final class ElasticsearchClientUtils {
+
+ private ElasticsearchClientUtils() {
+ // Private constructor
+ }
+
+ public static boolean isSuccessCode(int code) {
+ return 200 <= code && code < 300;
+ }
+
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/util/spi/URLEncodedString.java b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/URLEncodedString.java
similarity index 95%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/util/spi/URLEncodedString.java
rename to backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/URLEncodedString.java
index 46017026ec0..5ba628c34bf 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/util/spi/URLEncodedString.java
+++ b/backend/elasticsearch-client/common/src/main/java/org/hibernate/search/backend/elasticsearch/client/common/util/spi/URLEncodedString.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.util.spi;
+package org.hibernate.search.backend.elasticsearch.client.common.util.spi;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/pom.xml b/backend/elasticsearch-client/elasticsearch-java-client/pom.xml
new file mode 100644
index 00000000000..a51b35768cf
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/pom.xml
@@ -0,0 +1,84 @@
+
+
+
+ 4.0.0
+
+ org.hibernate.search
+ hibernate-search-parent-public
+ 8.2.0-SNAPSHOT
+ ../../../build/parents/public
+
+ hibernate-search-backend-elasticsearch-client-java
+
+ Hibernate Search Backend - Elasticsearch client based on the low-level Elasticsearch java client
+ Hibernate Search Elasticsearch client based on the low-level Elasticsearch java client
+
+
+
+ false
+ org.hibernate.search.backend.elasticsearch.client.java
+
+
+
+
+ org.hibernate.search
+ hibernate-search-engine
+
+
+ org.hibernate.search
+ hibernate-search-backend-elasticsearch-client-common
+
+
+ co.elastic.clients
+ elasticsearch-java
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ org.apache.httpcomponents.core5
+ httpcore5
+
+
+ org.jboss.logging
+ jboss-logging
+
+
+ org.jboss.logging
+ jboss-logging-annotations
+
+
+ com.google.code.gson
+ gson
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+
+
+ org.hibernate.search
+ hibernate-search-util-internal-test-common
+ test
+
+
+
+
+
+
+ org.moditect
+ moditect-maven-plugin
+
+
+
+
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurationContext.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurationContext.java
new file mode 100644
index 00000000000..e85f5f0424a
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurationContext.java
@@ -0,0 +1,42 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java;
+
+
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
+
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+
+
+/**
+ * The context passed to {@link ElasticsearchHttpClientConfigurer}.
+ */
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ + "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
+public interface ElasticsearchHttpClientConfigurationContext {
+
+ /**
+ * @return A {@link BeanResolver}.
+ */
+ BeanResolver beanResolver();
+
+ /**
+ * @return A configuration property source, appropriately masked so that the factory
+ * doesn't need to care about Hibernate Search prefixes (hibernate.search.*, etc.). All the properties
+ * can be accessed at the root.
+ * CAUTION: the property key "type" is reserved for use by the engine.
+ */
+ ConfigurationPropertySource configurationPropertySource();
+
+ /**
+ * @return An Apache HTTP client builder, to set the configuration.
+ * @see the Apache HTTP Client documentation
+ */
+ HttpAsyncClientBuilder clientBuilder();
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurer.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurer.java
new file mode 100644
index 00000000000..1c3c61b59eb
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/ElasticsearchHttpClientConfigurer.java
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java;
+
+/**
+ * An extension point allowing fine tuning of the Apache HTTP Client used by the Elasticsearch integration.
+ *
+ * This enables in particular connecting to cloud services that require a particular authentication method,
+ * such as request signing on Amazon Web Services.
+ *
+ * The ElasticsearchHttpClientConfigurer implementation will be given access to the HTTP client builder
+ * on startup.
+ *
+ * Note that you don't have to configure the client unless you have specific needs:
+ * the default configuration should work just fine for an on-premise Elasticsearch server.
+ */
+public interface ElasticsearchHttpClientConfigurer {
+
+ /**
+ * Configure the HTTP Client.
+ *
+ * This method is called once for every configurer, each time an Elasticsearch client is set up.
+ *
+ * Implementors should take care of only applying configuration if relevant:
+ * there may be multiple, conflicting configurers in the path, so implementors should first check
+ * (through a configuration property) whether they are needed or not before applying any modification.
+ * For example an authentication configurer could decide not to do anything if no username is provided,
+ * or if the configuration property {@code my.configurer.enabled} is {@code false}.
+ *
+ * @param context A configuration context giving access to the Apache HTTP client builder
+ * and configuration properties in particular.
+ */
+ void configure(ElasticsearchHttpClientConfigurationContext context);
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/ClientJavaElasticsearchBackendClientSettings.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/ClientJavaElasticsearchBackendClientSettings.java
new file mode 100644
index 00000000000..4cd777bfb3b
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/ClientJavaElasticsearchBackendClientSettings.java
@@ -0,0 +1,138 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.cfg;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.java.ElasticsearchHttpClientConfigurer;
+
+/**
+ * Specific configuration properties for the Elasticsearch backend's rest client based on the Elasticsearch's low-level rest client.
+ *
+ * Constants in this class are to be appended to a prefix to form a property key;
+ * see {@link org.hibernate.search.engine.cfg.BackendSettings} for details.
+ *
+ * @author Gunnar Morling
+ */
+public final class ClientJavaElasticsearchBackendClientSettings {
+
+ private ClientJavaElasticsearchBackendClientSettings() {
+ }
+
+ /**
+ * The timeout when executing a request to an Elasticsearch server.
+ *
+ * This includes the time needed to establish a connection, send the request and read the response.
+ *
+ * Expects a positive Integer value in milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to no request timeout.
+ */
+ public static final String REQUEST_TIMEOUT = "request_timeout";
+
+ /**
+ * The timeout when reading responses from an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 60000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#READ_TIMEOUT}.
+ */
+ public static final String READ_TIMEOUT = "read_timeout";
+
+ /**
+ * The timeout when establishing a connection to an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 3000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#CONNECTION_TIMEOUT}.
+ */
+ public static final String CONNECTION_TIMEOUT = "connection_timeout";
+
+ /**
+ * The maximum number of simultaneous connections to the Elasticsearch cluster,
+ * all hosts taken together.
+ *
+ * Expects a positive Integer value, such as {@code 40},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS}.
+ */
+ public static final String MAX_CONNECTIONS = "max_connections";
+
+ /**
+ * The maximum number of simultaneous connections to each host of the Elasticsearch cluster.
+ *
+ * Expects a positive Integer value, such as {@code 20},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS_PER_ROUTE}.
+ */
+ public static final String MAX_CONNECTIONS_PER_ROUTE = "max_connections_per_route";
+
+ /**
+ * Whether automatic discovery of nodes in the Elasticsearch cluster is enabled.
+ *
+ * Expects a Boolean value such as {@code true} or {@code false},
+ * or a string that can be parsed into a Boolean value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_ENABLED}.
+ */
+ public static final String DISCOVERY_ENABLED = "discovery.enabled";
+
+ /**
+ * The time interval between two executions of the automatic discovery, if enabled.
+ *
+ * Expects a positive Integer value in seconds, such as {@code 2},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_REFRESH_INTERVAL}.
+ */
+ public static final String DISCOVERY_REFRESH_INTERVAL = "discovery.refresh_interval";
+
+ /**
+ * How long connections to the Elasticsearch cluster can be kept idle.
+ *
+ * Expects a positive Long value of milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * If the response from an Elasticsearch cluster contains a {@code Keep-Alive} header,
+ * then the effective max idle time will be whichever is lower:
+ * the duration from the {@code Keep-Alive} header or the value of this property (if set).
+ *
+ * If this property is not set, only the {@code Keep-Alive} header is considered,
+ * and if it's absent, idle connections will be kept forever.
+ */
+ public static final String MAX_KEEP_ALIVE = "max_keep_alive";
+
+ /**
+ * A {@link ElasticsearchHttpClientConfigurer} that defines custom HTTP client configuration.
+ *
+ * It can be used for example to tune the SSL context to accept self-signed certificates.
+ * It allows overriding other HTTP client settings, such as {@link ElasticsearchBackendClientCommonSettings#USERNAME} or {@link #MAX_CONNECTIONS_PER_ROUTE}.
+ *
+ * Expects a reference to a bean of type {@link ElasticsearchHttpClientConfigurer}.
+ *
+ * Defaults to no value.
+ */
+ public static final String CLIENT_CONFIGURER = "client.configurer";
+
+ /**
+ * Default values for the different settings if no values are given.
+ */
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+
+ public static final int READ_TIMEOUT = 30000;
+ public static final int CONNECTION_TIMEOUT = 1000;
+ public static final int MAX_CONNECTIONS = 40;
+ public static final int MAX_CONNECTIONS_PER_ROUTE = 20;
+ public static final boolean DISCOVERY_ENABLED = false;
+ public static final int DISCOVERY_REFRESH_INTERVAL = 10;
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/spi/ClientJavaElasticsearchBackendClientSpiSettings.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/spi/ClientJavaElasticsearchBackendClientSpiSettings.java
new file mode 100644
index 00000000000..047733f94ac
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/cfg/spi/ClientJavaElasticsearchBackendClientSpiSettings.java
@@ -0,0 +1,55 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.cfg.spi;
+
+import org.hibernate.search.engine.cfg.EngineSettings;
+
+/**
+ * Configuration properties for the Elasticsearch backend that are considered SPI (and not API).
+ */
+public final class ClientJavaElasticsearchBackendClientSpiSettings {
+
+ /**
+ * The prefix expected for the key of every Hibernate Search configuration property.
+ */
+ public static final String PREFIX = EngineSettings.PREFIX + "backend.";
+
+ /**
+ * An external Elasticsearch client instance that Hibernate Search should use for all requests to Elasticsearch.
+ *
+ * If this is set, Hibernate Search will not attempt to create its own Elasticsearch,
+ * and all other client-related configuration properties
+ * (hosts/uris, authentication, discovery, timeouts, max connections, configurer, ...)
+ * will be ignored.
+ *
+ * Expects a reference to a bean of type {@link co.elastic.clients.transport.rest5_client.low_level.Rest5Client}.
+ *
+ * Defaults to nothing: if no client instance is provided, Hibernate Search will create its own.
+ *
+ * WARNING - Incubating API: the underlying client class may change without prior notice.
+ *
+ * @see org.hibernate.search.engine.cfg The core documentation of configuration properties,
+ * which includes a description of the "bean reference" properties and accepted values.
+ */
+ public static final String CLIENT_INSTANCE = "client.instance";
+
+ private ClientJavaElasticsearchBackendClientSpiSettings() {
+ }
+
+ /**
+ * Configuration property keys without the {@link #PREFIX prefix}.
+ */
+ public static class Radicals {
+
+ private Radicals() {
+ }
+ }
+
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ByteBufferDataStreamChannel.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ByteBufferDataStreamChannel.java
new file mode 100644
index 00000000000..93b8394fd92
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ByteBufferDataStreamChannel.java
@@ -0,0 +1,59 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+
+final class ByteBufferDataStreamChannel implements ContentEncoder, DataStreamChannel {
+ private final ByteBuffer buffer;
+ private boolean complete = false;
+
+ ByteBufferDataStreamChannel(ByteBuffer buffer) {
+ this.buffer = buffer;
+ if ( !buffer.hasArray() ) {
+ throw new IllegalArgumentException( getClass().getName() + " requires a ByteBuffer backed by an array." );
+ }
+ }
+
+ @Override
+ public void requestOutput() {
+
+ }
+
+ @Override
+ public int write(ByteBuffer src) {
+ int toWrite = Math.min( src.remaining(), buffer.remaining() );
+ src.get( buffer.array(), buffer.arrayOffset() + buffer.position(), toWrite );
+ buffer.position( buffer.position() + toWrite );
+ return toWrite;
+ }
+
+ @Override
+ public void endStream() throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void endStream(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void complete(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public boolean isCompleted() {
+ return complete;
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClient.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClient.java
new file mode 100644
index 00000000000..a4f4886ff85
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClient.java
@@ -0,0 +1,275 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientLog;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchRequestLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchResponse;
+import org.hibernate.search.backend.elasticsearch.client.common.util.spi.ElasticsearchClientUtils;
+import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
+import org.hibernate.search.engine.common.timing.Deadline;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.util.common.impl.Closer;
+import org.hibernate.search.util.common.impl.Futures;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import co.elastic.clients.transport.rest5_client.low_level.Request;
+import co.elastic.clients.transport.rest5_client.low_level.RequestOptions;
+import co.elastic.clients.transport.rest5_client.low_level.Response;
+import co.elastic.clients.transport.rest5_client.low_level.ResponseException;
+import co.elastic.clients.transport.rest5_client.low_level.ResponseListener;
+import co.elastic.clients.transport.rest5_client.low_level.Rest5Client;
+import co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.impl.EnglishReasonPhraseCatalog;
+import org.apache.hc.core5.util.Timeout;
+
+public class ClientJavaElasticsearchClient implements ElasticsearchClientImplementor {
+
+ private final BeanHolder extends Rest5Client> restClientHolder;
+
+ private final Sniffer sniffer;
+
+ private final SimpleScheduledExecutor timeoutExecutorService;
+
+ private final Optional requestTimeoutMs;
+
+ private final Gson gson;
+ private final JsonLogHelper jsonLogHelper;
+
+ ClientJavaElasticsearchClient(BeanHolder extends Rest5Client> restClientHolder, Sniffer sniffer,
+ SimpleScheduledExecutor timeoutExecutorService,
+ Optional requestTimeoutMs,
+ Gson gson, JsonLogHelper jsonLogHelper) {
+ this.restClientHolder = restClientHolder;
+ this.sniffer = sniffer;
+ this.timeoutExecutorService = timeoutExecutorService;
+ this.requestTimeoutMs = requestTimeoutMs;
+ this.gson = gson;
+ this.jsonLogHelper = jsonLogHelper;
+ }
+
+ @Override
+ public CompletableFuture submit(ElasticsearchRequest request) {
+ CompletableFuture result = Futures.create( () -> send( request ) )
+ .thenApply( this::convertResponse );
+ if ( ElasticsearchRequestLog.INSTANCE.isDebugEnabled() ) {
+ long startTime = System.nanoTime();
+ result.thenAccept( response -> log( request, startTime, response ) );
+ }
+ return result;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T unwrap(Class clientClass) {
+ if ( Rest5Client.class.isAssignableFrom( clientClass ) ) {
+ return (T) restClientHolder.get();
+ }
+ throw ElasticsearchClientLog.INSTANCE.clientUnwrappingWithUnknownType( clientClass, Rest5Client.class );
+ }
+
+ private CompletableFuture send(ElasticsearchRequest elasticsearchRequest) {
+ CompletableFuture completableFuture = new CompletableFuture<>();
+
+ HttpEntity entity;
+ try {
+ entity = GsonHttpEntity.toEntity( gson, elasticsearchRequest );
+ }
+ catch (IOException | RuntimeException e) {
+ completableFuture.completeExceptionally( e );
+ return completableFuture;
+ }
+
+ restClientHolder.get().performRequestAsync(
+ toRequest( elasticsearchRequest, entity ),
+ new ResponseListener() {
+ @Override
+ public void onSuccess(Response response) {
+ completableFuture.complete( response );
+ }
+
+ @Override
+ public void onFailure(Exception exception) {
+ if ( exception instanceof ResponseException ) {
+ /*
+ * The client tries to guess what's an error and what's not, but it's too naive.
+ * A 404 on DELETE is not always important to us, for instance.
+ * Thus we ignore the exception and do our own checks afterwards.
+ */
+ completableFuture.complete( ( (ResponseException) exception ).getResponse() );
+ }
+ else {
+ completableFuture.completeExceptionally( exception );
+ }
+ }
+ }
+ );
+
+ Deadline deadline = elasticsearchRequest.deadline();
+ if ( deadline == null && !requestTimeoutMs.isPresent() ) {
+ // no need to schedule a client side timeout
+ return completableFuture;
+ }
+
+ long currentTimeoutValue =
+ deadline == null ? Long.valueOf( requestTimeoutMs.get() ) : deadline.checkRemainingTimeMillis();
+
+ /*
+ * TODO HSEARCH-3590 maybe the callback should also cancel the request?
+ * In any case, the RestClient doesn't return the Future> from Apache HTTP client,
+ * so we can't do much until this changes.
+ */
+ ScheduledFuture> timeout = timeoutExecutorService.schedule(
+ () -> {
+ if ( !completableFuture.isDone() ) {
+ RuntimeException cause = ElasticsearchClientLog.INSTANCE.requestTimedOut(
+ Duration.ofNanos( TimeUnit.MILLISECONDS.toNanos( currentTimeoutValue ) ),
+ elasticsearchRequest );
+ completableFuture.completeExceptionally(
+ deadline != null ? deadline.forceTimeoutAndCreateException( cause ) : cause
+ );
+ }
+ },
+ currentTimeoutValue, TimeUnit.MILLISECONDS
+ );
+ completableFuture.thenRun( () -> timeout.cancel( false ) );
+
+ return completableFuture;
+ }
+
+ private Request toRequest(ElasticsearchRequest elasticsearchRequest, HttpEntity entity) {
+ Request request = new Request( elasticsearchRequest.method(), elasticsearchRequest.path() );
+ setPerRequestSocketTimeout( elasticsearchRequest, request );
+
+ for ( Entry parameter : elasticsearchRequest.parameters().entrySet() ) {
+ request.addParameter( parameter.getKey(), parameter.getValue() );
+ }
+
+ request.setEntity( entity );
+
+ return request;
+ }
+
+ private void setPerRequestSocketTimeout(ElasticsearchRequest elasticsearchRequest, Request request) {
+ Deadline deadline = elasticsearchRequest.deadline();
+ if ( deadline == null ) {
+ return;
+ }
+
+ long timeToHardTimeout = deadline.checkRemainingTimeMillis();
+
+ // set a per-request socket timeout
+ int generalRequestTimeoutMs = ( timeToHardTimeout <= Integer.MAX_VALUE ) ? Math.toIntExact( timeToHardTimeout ) : -1;
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectionRequestTimeout( Timeout.DISABLED ) //Disable lease handling for the connection pool! See also HSEARCH-2681
+ .setResponseTimeout( generalRequestTimeoutMs, TimeUnit.MILLISECONDS )
+ .build();
+
+ RequestOptions.Builder requestOptions = RequestOptions.DEFAULT.toBuilder()
+ .setRequestConfig( requestConfig );
+
+ request.setOptions( requestOptions );
+ }
+
+ private ElasticsearchResponse convertResponse(Response response) {
+ String reason = EnglishReasonPhraseCatalog.INSTANCE.getReason( response.getStatusCode(), Locale.ENGLISH );
+ try {
+
+ JsonObject body = parseBody( response );
+ return new ElasticsearchResponse(
+ response.getHost().toHostString(),
+ response.getStatusCode(),
+ reason,
+ body );
+ }
+ catch (IOException | RuntimeException e) {
+ throw ElasticsearchClientLog.INSTANCE.failedToParseElasticsearchResponse( response.getStatusCode(),
+ reason, e.getMessage(), e );
+ }
+ }
+
+ private JsonObject parseBody(Response response) throws IOException {
+ HttpEntity entity = response.getEntity();
+ if ( entity == null ) {
+ return null;
+ }
+
+ Charset charset = getCharset( entity );
+ try ( InputStream inputStream = entity.getContent();
+ Reader reader = new InputStreamReader( inputStream, charset ) ) {
+ return gson.fromJson( reader, JsonObject.class );
+ }
+ }
+
+ private static Charset getCharset(HttpEntity entity) {
+ ContentType contentType = ContentType.parse( entity.getContentType() );
+ Charset charset = contentType.getCharset();
+ return charset != null ? charset : StandardCharsets.UTF_8;
+ }
+
+ private void log(ElasticsearchRequest request, long start, ElasticsearchResponse response) {
+ boolean successCode = ElasticsearchClientUtils.isSuccessCode( response.statusCode() );
+ if ( !ElasticsearchRequestLog.INSTANCE.isTraceEnabled() && successCode ) {
+ return;
+ }
+ long executionTimeNs = System.nanoTime() - start;
+ long executionTimeMs = TimeUnit.NANOSECONDS.toMillis( executionTimeNs );
+ if ( successCode ) {
+ ElasticsearchRequestLog.INSTANCE.executedRequest( request.method(), response.host(), request.path(),
+ request.parameters(),
+ request.bodyParts().size(), executionTimeMs,
+ response.statusCode(), response.statusMessage(),
+ jsonLogHelper.toString( request.bodyParts() ),
+ jsonLogHelper.toString( response.body() ) );
+ }
+ else {
+ ElasticsearchRequestLog.INSTANCE.executedRequestWithFailure( request.method(), response.host(), request.path(),
+ request.parameters(),
+ request.bodyParts().size(), executionTimeMs,
+ response.statusCode(), response.statusMessage(),
+ jsonLogHelper.toString( request.bodyParts() ),
+ jsonLogHelper.toString( response.body() ) );
+ }
+ }
+
+ @Override
+ public void close() {
+ try ( Closer closer = new Closer<>() ) {
+ /*
+ * There's no point waiting for timeouts: we'll just expect the RestClient to cancel all
+ * currently running requests when closing.
+ */
+ closer.push( Sniffer::close, this.sniffer );
+ // The BeanHolder is responsible for calling close() on the client if necessary.
+ closer.push( BeanHolder::close, this.restClientHolder );
+ }
+ catch (RuntimeException | IOException e) {
+ throw ElasticsearchClientLog.INSTANCE.unableToShutdownClient( e.getMessage(), e );
+ }
+ }
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientBeanConfigurer.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientBeanConfigurer.java
new file mode 100644
index 00000000000..dd4777882d5
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientBeanConfigurer.java
@@ -0,0 +1,20 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurationContext;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurer;
+
+public class ClientJavaElasticsearchClientBeanConfigurer implements BeanConfigurer {
+ @Override
+ public void configure(BeanConfigurationContext context) {
+ context.define(
+ ElasticsearchClientFactory.class, "elasticsearch-java",
+ beanResolver -> BeanHolder.of( new ClientJavaElasticsearchClientFactory() )
+ );
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientFactory.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientFactory.java
new file mode 100644
index 00000000000..f20a3ed2824
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchClientFactory.java
@@ -0,0 +1,360 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.net.SocketAddress;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.java.ElasticsearchHttpClientConfigurer;
+import org.hibernate.search.backend.elasticsearch.client.java.cfg.ClientJavaElasticsearchBackendClientSettings;
+import org.hibernate.search.backend.elasticsearch.client.java.cfg.spi.ClientJavaElasticsearchBackendClientSpiSettings;
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.cfg.spi.ConfigurationProperty;
+import org.hibernate.search.engine.cfg.spi.OptionalConfigurationProperty;
+import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.engine.environment.bean.BeanReference;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.engine.environment.thread.spi.ThreadProvider;
+import org.hibernate.search.util.common.impl.SuppressingCloser;
+
+import co.elastic.clients.transport.rest5_client.low_level.Rest5Client;
+import co.elastic.clients.transport.rest5_client.low_level.Rest5ClientBuilder;
+import co.elastic.clients.transport.rest5_client.low_level.sniffer.ElasticsearchNodesSniffer;
+import co.elastic.clients.transport.rest5_client.low_level.sniffer.NodesSniffer;
+import co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer;
+import co.elastic.clients.transport.rest5_client.low_level.sniffer.SnifferBuilder;
+import org.apache.hc.client5.http.auth.AuthScope;
+import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer;
+import org.apache.hc.core5.util.Timeout;
+
+
+/**
+ * @author Gunnar Morling
+ */
+public class ClientJavaElasticsearchClientFactory implements ElasticsearchClientFactory {
+
+ private static final OptionalConfigurationProperty> CLIENT_INSTANCE =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSpiSettings.CLIENT_INSTANCE )
+ .asBeanReference( Rest5Client.class )
+ .build();
+
+ private static final OptionalConfigurationProperty> HOSTS =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.HOSTS )
+ .asString().multivalued()
+ .build();
+
+ private static final OptionalConfigurationProperty PROTOCOL =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PROTOCOL )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty> URIS =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.URIS )
+ .asString().multivalued()
+ .build();
+
+ private static final ConfigurationProperty PATH_PREFIX =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PATH_PREFIX )
+ .asString()
+ .withDefault( ElasticsearchBackendClientCommonSettings.Defaults.PATH_PREFIX )
+ .build();
+
+ private static final OptionalConfigurationProperty USERNAME =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.USERNAME )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty PASSWORD =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PASSWORD )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty REQUEST_TIMEOUT =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.REQUEST_TIMEOUT )
+ .asIntegerStrictlyPositive()
+ .build();
+
+ private static final ConfigurationProperty READ_TIMEOUT =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.READ_TIMEOUT )
+ .asIntegerPositiveOrZeroOrNegative()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.READ_TIMEOUT )
+ .build();
+
+ private static final ConfigurationProperty CONNECTION_TIMEOUT =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.CONNECTION_TIMEOUT )
+ .asIntegerPositiveOrZeroOrNegative()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.CONNECTION_TIMEOUT )
+ .build();
+
+ private static final ConfigurationProperty MAX_TOTAL_CONNECTION =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.MAX_CONNECTIONS )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS )
+ .build();
+
+ private static final ConfigurationProperty MAX_TOTAL_CONNECTION_PER_ROUTE =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.MAX_CONNECTIONS_PER_ROUTE )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS_PER_ROUTE )
+ .build();
+
+ private static final ConfigurationProperty DISCOVERY_ENABLED =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.DISCOVERY_ENABLED )
+ .asBoolean()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.DISCOVERY_ENABLED )
+ .build();
+
+ private static final ConfigurationProperty DISCOVERY_REFRESH_INTERVAL =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.DISCOVERY_REFRESH_INTERVAL )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientJavaElasticsearchBackendClientSettings.Defaults.DISCOVERY_REFRESH_INTERVAL )
+ .build();
+
+ private static final OptionalConfigurationProperty<
+ BeanReference extends ElasticsearchHttpClientConfigurer>> CLIENT_CONFIGURER =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.CLIENT_CONFIGURER )
+ .asBeanReference( ElasticsearchHttpClientConfigurer.class )
+ .build();
+
+ private static final OptionalConfigurationProperty MAX_KEEP_ALIVE =
+ ConfigurationProperty.forKey( ClientJavaElasticsearchBackendClientSettings.MAX_KEEP_ALIVE )
+ .asLongStrictlyPositive()
+ .build();
+
+ @Override
+ public ElasticsearchClientImplementor create(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ SimpleScheduledExecutor timeoutExecutorService,
+ GsonProvider gsonProvider) {
+ Optional requestTimeoutMs = REQUEST_TIMEOUT.get( propertySource );
+
+ Optional> providedRestClientHolder = CLIENT_INSTANCE.getAndMap(
+ propertySource, beanResolver::resolve );
+
+ BeanHolder extends Rest5Client> restClientHolder;
+ Sniffer sniffer;
+ if ( providedRestClientHolder.isPresent() ) {
+ restClientHolder = providedRestClientHolder.get();
+ sniffer = null;
+ }
+ else {
+ ServerUris hosts = ServerUris.fromOptionalStrings( PROTOCOL.get( propertySource ),
+ HOSTS.get( propertySource ), URIS.get( propertySource ) );
+ restClientHolder = createClient( beanResolver, propertySource, threadProvider, threadNamePrefix,
+ hosts, PATH_PREFIX.get( propertySource ) );
+ sniffer = createSniffer( propertySource, restClientHolder.get(), hosts );
+ }
+
+ return new ClientJavaElasticsearchClient(
+ restClientHolder, sniffer, timeoutExecutorService,
+ requestTimeoutMs,
+ gsonProvider.getGson(), gsonProvider.getLogHelper()
+ );
+ }
+
+ private BeanHolder extends Rest5Client> createClient(BeanResolver beanResolver,
+ ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ ServerUris hosts, String pathPrefix) {
+ Rest5ClientBuilder builder = Rest5Client.builder( hosts.asHostsArray() );
+ if ( !pathPrefix.isEmpty() ) {
+ builder.setPathPrefix( pathPrefix );
+ }
+
+ Optional extends BeanHolder extends ElasticsearchHttpClientConfigurer>> customConfig = CLIENT_CONFIGURER
+ .getAndMap( propertySource, beanResolver::resolve );
+
+ Rest5Client client = null;
+ List> httpClientConfigurerReferences =
+ beanResolver.allConfiguredForRole( ElasticsearchHttpClientConfigurer.class );
+ List> requestInterceptorProviderReferences =
+ beanResolver.allConfiguredForRole( ElasticsearchRequestInterceptorProvider.class );
+ try ( BeanHolder> httpClientConfigurersHolder =
+ beanResolver.resolve( httpClientConfigurerReferences );
+ BeanHolder> requestInterceptorProvidersHodler =
+ beanResolver.resolve( requestInterceptorProviderReferences ) ) {
+ client = builder
+ .setRequestConfigCallback( b -> customizeRequestConfig( b, propertySource ) )
+ .setHttpClientConfigCallback(
+ b -> customizeHttpClientConfig(
+ b,
+ beanResolver, propertySource,
+ threadProvider, threadNamePrefix,
+ hosts, httpClientConfigurersHolder.get(), requestInterceptorProvidersHodler.get(),
+ customConfig
+ )
+ )
+ .build();
+ return BeanHolder.ofCloseable( client );
+ }
+ catch (RuntimeException e) {
+ new SuppressingCloser( e )
+ .push( client );
+ throw e;
+ }
+ finally {
+ if ( customConfig.isPresent() ) {
+ // Assuming that #customizeHttpClientConfig has been already executed
+ // and therefore the bean has been already used.
+ customConfig.get().close();
+ }
+ }
+ }
+
+ private Sniffer createSniffer(ConfigurationPropertySource propertySource,
+ Rest5Client client, ServerUris hosts) {
+ boolean discoveryEnabled = DISCOVERY_ENABLED.get( propertySource );
+ if ( discoveryEnabled ) {
+ SnifferBuilder builder = Sniffer.builder( client )
+ .setSniffIntervalMillis(
+ DISCOVERY_REFRESH_INTERVAL.get( propertySource ) * 1_000 // The configured value is in seconds
+ );
+
+ // https discovery support
+ if ( hosts.isSslEnabled() ) {
+ NodesSniffer hostsSniffer = new ElasticsearchNodesSniffer(
+ client,
+ ElasticsearchNodesSniffer.DEFAULT_SNIFF_REQUEST_TIMEOUT, // 1sec
+ ElasticsearchNodesSniffer.Scheme.HTTPS );
+ builder.setNodesSniffer( hostsSniffer );
+ }
+ return builder.build();
+ }
+ else {
+ return null;
+ }
+ }
+
+ private HttpAsyncClientBuilder customizeHttpClientConfig(HttpAsyncClientBuilder builder,
+ BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ ServerUris hosts, Iterable configurers,
+ Iterable requestInterceptorProviders,
+ Optional extends BeanHolder extends ElasticsearchHttpClientConfigurer>> customConfig) {
+ builder.setThreadFactory( threadProvider.createThreadFactory( threadNamePrefix + " - Transport thread" ) );
+
+ PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .setMaxConnTotal( MAX_TOTAL_CONNECTION.get( propertySource ) )
+ .setMaxConnPerRoute( MAX_TOTAL_CONNECTION_PER_ROUTE.get( propertySource ) );
+
+ if ( !hosts.isSslEnabled() ) {
+ // In this case disable the SSL capability as it might have an impact on
+ // bootstrap time, for example consuming entropy for no reason
+ // connectionManagerBuilder.setTlsStrategy(
+ // ClientTlsStrategyBuilder.create()
+ // .setSslContext(
+ // SSLContextBuilder.create()
+ // .loadTrustMaterial(null, new TrustAllStrategy())
+ // .buildAsync()
+ // )
+ // .setHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ // .build()
+ // );
+ connectionManagerBuilder.setTlsStrategy( NoopTlsStrategy.INSTANCE );
+ }
+
+ builder.setConnectionManager(
+ connectionManagerBuilder
+ .setDefaultConnectionConfig(
+ ConnectionConfig.copy( ConnectionConfig.DEFAULT )
+ .setConnectTimeout( CONNECTION_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS )
+ .setSocketTimeout( READ_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS )
+ .build()
+ )
+ .build()
+ );
+
+ Optional username = USERNAME.get( propertySource );
+ if ( username.isPresent() ) {
+ Optional password = PASSWORD.get( propertySource ).map( String::toCharArray );
+ if ( password.isPresent() && !hosts.isSslEnabled() ) {
+ ElasticsearchClientConfigurationLog.INSTANCE.usingPasswordOverHttp();
+ }
+
+ BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+ credentialsProvider.setCredentials(
+ new AuthScope( null, null, -1, null, null ),
+ new UsernamePasswordCredentials( username.get(), password.orElse( null ) )
+ );
+
+ builder.setDefaultCredentialsProvider( credentialsProvider );
+ }
+
+ Optional maxKeepAlive = MAX_KEEP_ALIVE.get( propertySource );
+ if ( maxKeepAlive.isPresent() ) {
+ builder.setKeepAliveStrategy( new CustomConnectionKeepAliveStrategy( maxKeepAlive.get() ) );
+ }
+
+ ClientJavaElasticsearchHttpClientConfigurationContext clientConfigurationContext =
+ new ClientJavaElasticsearchHttpClientConfigurationContext( beanResolver, propertySource, builder );
+
+ for ( ElasticsearchHttpClientConfigurer configurer : configurers ) {
+ configurer.configure( clientConfigurationContext );
+ }
+ for ( ElasticsearchRequestInterceptorProvider interceptorProvider : requestInterceptorProviders ) {
+ Optional requestInterceptor =
+ interceptorProvider.provide( clientConfigurationContext );
+ if ( requestInterceptor.isPresent() ) {
+ builder.addRequestInterceptorLast( new ClientJavaHttpRequestInterceptor( requestInterceptor.get() ) );
+ }
+ }
+ if ( customConfig.isPresent() ) {
+ BeanHolder extends ElasticsearchHttpClientConfigurer> customConfigBeanHolder = customConfig.get();
+ customConfigBeanHolder.get().configure( clientConfigurationContext );
+ }
+
+ return builder;
+ }
+
+ private RequestConfig.Builder customizeRequestConfig(RequestConfig.Builder builder,
+ ConfigurationPropertySource propertySource) {
+ return builder
+ .setConnectionRequestTimeout( Timeout.DISABLED ) //Disable lease handling for the connection pool! See also HSEARCH-2681
+ .setResponseTimeout( READ_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS );
+ }
+
+ private static class NoopTlsStrategy implements TlsStrategy {
+ private static final NoopTlsStrategy INSTANCE = new NoopTlsStrategy();
+
+ private NoopTlsStrategy() {
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public boolean upgrade(TransportSecurityLayer sessionLayer, HttpHost host, SocketAddress localAddress,
+ SocketAddress remoteAddress, Object attachment, Timeout handshakeTimeout) {
+ throw new UnsupportedOperationException( "upgrade is not supported." );
+ }
+
+ @Override
+ public void upgrade(TransportSecurityLayer sessionLayer, NamedEndpoint endpoint, Object attachment,
+ Timeout handshakeTimeout, FutureCallback callback) {
+ if ( callback != null ) {
+ callback.completed( sessionLayer );
+ }
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchHttpClientConfigurationContext.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchHttpClientConfigurationContext.java
new file mode 100644
index 00000000000..c934a80ad92
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaElasticsearchHttpClientConfigurationContext.java
@@ -0,0 +1,44 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.java.ElasticsearchHttpClientConfigurationContext;
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+
+final class ClientJavaElasticsearchHttpClientConfigurationContext
+ implements ElasticsearchHttpClientConfigurationContext, ElasticsearchRequestInterceptorProvider.Context {
+ private final BeanResolver beanResolver;
+ private final ConfigurationPropertySource configurationPropertySource;
+ private final HttpAsyncClientBuilder clientBuilder;
+
+ ClientJavaElasticsearchHttpClientConfigurationContext(
+ BeanResolver beanResolver,
+ ConfigurationPropertySource configurationPropertySource,
+ HttpAsyncClientBuilder clientBuilder) {
+ this.beanResolver = beanResolver;
+ this.configurationPropertySource = configurationPropertySource;
+ this.clientBuilder = clientBuilder;
+ }
+
+ @Override
+ public BeanResolver beanResolver() {
+ return beanResolver;
+ }
+
+ @Override
+ public ConfigurationPropertySource configurationPropertySource() {
+ return configurationPropertySource;
+ }
+
+ @Override
+ public HttpAsyncClientBuilder clientBuilder() {
+ return clientBuilder;
+ }
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaHttpRequestInterceptor.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaHttpRequestInterceptor.java
new file mode 100644
index 00000000000..3b6ada8fdcb
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ClientJavaHttpRequestInterceptor.java
@@ -0,0 +1,142 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.util.common.AssertionFailure;
+
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpEntityContainer;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpRequestInterceptor;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.net.URIBuilder;
+
+record ClientJavaHttpRequestInterceptor(ElasticsearchRequestInterceptor elasticsearchRequestInterceptor)
+ implements HttpRequestInterceptor {
+
+ @Override
+ public void process(HttpRequest request, EntityDetails entity, HttpContext context) throws IOException {
+ elasticsearchRequestInterceptor.intercept(
+ new ClientJavaRequestContext( request, entity, context )
+ );
+ }
+
+ private record ClientJavaRequestContext(HttpRequest request, EntityDetails entity, HttpClientContext clientContext)
+ implements ElasticsearchRequestInterceptor.RequestContext {
+ private ClientJavaRequestContext(HttpRequest request, EntityDetails entity, HttpContext context) {
+ this( request, entity, HttpClientContext.cast( context ) );
+ }
+
+ @Override
+ public boolean hasContent() {
+ return entity != null;
+ }
+
+ @Override
+ public InputStream content() throws IOException {
+ HttpEntity localEntity = null;
+ if ( entity instanceof HttpEntity httpEntity ) {
+ localEntity = httpEntity;
+ }
+ else if ( request instanceof HttpEntityContainer entityContainer ) {
+ localEntity = entityContainer.getEntity();
+ }
+
+ if ( localEntity != null ) {
+ if ( !localEntity.isRepeatable() ) {
+ throw new AssertionFailure( "Cannot sign AWS requests with non-repeatable entities" );
+ }
+ return localEntity.getContent();
+ }
+
+ if ( entity instanceof AsyncEntityProducer producer ) {
+ if ( !producer.isRepeatable() ) {
+ throw new AssertionFailure( "Cannot sign AWS requests with non-repeatable entities" );
+ }
+ return new HttpAsyncEntityProducerInputStream( producer, 1024 );
+ }
+ return null;
+ }
+
+ @Override
+ public String scheme() {
+ return clientContext.getHttpRoute().getTargetHost().getSchemeName();
+ }
+
+ @Override
+ public String host() {
+ return clientContext.getHttpRoute().getTargetHost().getHostName();
+ }
+
+ @Override
+ public Integer port() {
+ return clientContext.getHttpRoute().getTargetHost().getPort();
+ }
+
+ @Override
+ public String method() {
+ return request.getMethod();
+ }
+
+ @Override
+ public String path() {
+ try {
+ return request.getUri().getPath();
+ }
+ catch (URISyntaxException e) {
+ return request.getPath();
+ }
+ }
+
+ @Override
+ public Map queryParameters() {
+ try {
+ List queryParameters = new URIBuilder( request.getUri() ).getQueryParams();
+ Map map = new HashMap<>();
+ for ( NameValuePair parameter : queryParameters ) {
+ map.put( parameter.getName(), parameter.getValue() );
+ }
+ return map;
+ }
+ catch (URISyntaxException e) {
+ return Map.of();
+ }
+ }
+
+ @Override
+ public void overrideHeaders(Map> headers) {
+ for ( Map.Entry> header : headers.entrySet() ) {
+ String name = header.getKey();
+ boolean first = true;
+ for ( String value : header.getValue() ) {
+ if ( first ) {
+ request.setHeader( name, value );
+ first = false;
+ }
+ else {
+ request.addHeader( name, value );
+ }
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return request.toString();
+ }
+ }
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CountingOutputStream.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CountingOutputStream.java
similarity index 92%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CountingOutputStream.java
rename to backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CountingOutputStream.java
index 64e8e5fb65e..c152de27c4b 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CountingOutputStream.java
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CountingOutputStream.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
import java.io.FilterOutputStream;
import java.io.IOException;
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CustomConnectionKeepAliveStrategy.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CustomConnectionKeepAliveStrategy.java
new file mode 100644
index 00000000000..7372da4763b
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/CustomConnectionKeepAliveStrategy.java
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
+import org.apache.hc.client5.http.impl.DefaultConnectionKeepAliveStrategy;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.TimeValue;
+
+final class CustomConnectionKeepAliveStrategy implements ConnectionKeepAliveStrategy {
+
+ private final TimeValue maxKeepAlive;
+ private final long maxKeepAliveMilliseconds;
+
+ CustomConnectionKeepAliveStrategy(long maxKeepAlive) {
+ this.maxKeepAlive = TimeValue.of( maxKeepAlive, TimeUnit.MILLISECONDS );
+ this.maxKeepAliveMilliseconds = maxKeepAlive;
+ }
+
+ @Override
+ public TimeValue getKeepAliveDuration(HttpResponse response, HttpContext context) {
+ // get a keep alive from a request header if one is present
+ TimeValue keepAliveDuration = DefaultConnectionKeepAliveStrategy.INSTANCE.getKeepAliveDuration( response, context );
+ long keepAliveDurationMilliseconds = keepAliveDuration.toMilliseconds();
+ // if the keep alive timeout from a request is less than configured one - let's honor it:
+ if ( keepAliveDurationMilliseconds > 0 && keepAliveDurationMilliseconds < maxKeepAliveMilliseconds ) {
+ return keepAliveDuration;
+ }
+ return maxKeepAlive;
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntity.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntity.java
new file mode 100644
index 00000000000..4d70a70b9f2
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntity.java
@@ -0,0 +1,321 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.util.common.impl.Contracts;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import org.apache.hc.core5.function.Supplier;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+/**
+ * Optimised adapter to encode GSON objects into HttpEntity instances.
+ * The naive approach was using various StringBuilders; the objects we
+ * need to serialise into JSON might get large and this was causing the
+ * internal StringBuilder buffers to need frequent resizing and cause
+ * problems with excessive allocations.
+ *
+ * Rather than trying to guess reasonable default sizes for these buffers,
+ * we can defer the serialisation to write directly into the ByteBuffer
+ * of the HTTP client, this has the additional benefit of making the
+ * intermediary buffers short lived.
+ *
+ * The one complexity to watch for is flow control: when writing into
+ * the output buffer chances are that not all bytes are accepted; in
+ * this case we have to hold on the remaining portion of data to
+ * be written when the flow control is re-enabled.
+ *
+ * A side effect of this strategy is that the total content length which
+ * is being produced is not known in advance. Not reporting the length
+ * in advance to the Apache Http client causes it to use chunked-encoding,
+ * which is great for large blocks but not optimal for small messages.
+ * For this reason we attempt to start encoding into a small buffer
+ * upfront: if all data we need to produce fits into that then we can
+ * report the content length; if not the encoding completion will be deferred
+ * but not resetting so to avoid repeating encoding work.
+ *
+ * @author Sanne Grinovero (C) 2017 Red Hat Inc.
+ */
+final class GsonHttpEntity implements HttpEntity, AsyncEntityProducer {
+
+ private static final Charset CHARSET = StandardCharsets.UTF_8;
+
+ private static final String CONTENT_TYPE = ContentType.APPLICATION_JSON.toString();
+
+ /**
+ * The size of byte buffer pages in {@link ProgressiveCharBufferWriter}
+ * It's a rather large size: a tradeoff for very large JSON
+ * documents as we do heavy bulking, and not too large to
+ * be a penalty for small requests.
+ * 1024 has been shown to produce reasonable, TLAB only garbage.
+ */
+ private static final int BYTE_BUFFER_PAGE_SIZE = 1024;
+
+ /**
+ * We want the char buffer and byte buffer pages of approximately
+ * the same size, however one is in characters and the other in bytes.
+ * Considering we hardcoded UTF-8 as encoding, which has an average
+ * conversion ratio of almost 1.0, this should be close enough.
+ */
+ private static final int CHAR_BUFFER_SIZE = BYTE_BUFFER_PAGE_SIZE;
+
+ public static HttpEntity toEntity(Gson gson, ElasticsearchRequest request) throws IOException {
+ final List bodyParts = request.bodyParts();
+ if ( bodyParts.isEmpty() ) {
+ return null;
+ }
+ return new GsonHttpEntity( gson, bodyParts );
+ }
+
+ private final Gson gson;
+ private final List bodyParts;
+
+ /**
+ * We don't want to compute the length in advance as it would defeat the optimisations
+ * for large bulks.
+ * Still it's possible that we happen to find out, for example if a Digest from all
+ * content needs to be computed, or if the content is small enough as we attempt
+ * to serialise at least one page.
+ */
+ private long contentLength;
+
+ /**
+ * We can lazily compute the contentLength, but we need to avoid changing the value
+ * we report over time as this confuses the Apache HTTP client as it initially defines
+ * the encoding strategy based on this, then assumes it can rely on this being
+ * a constant.
+ * After the {@link #getContentLength()} was invoked at least once, freeze
+ * the value.
+ */
+ private boolean contentLengthWasProvided = false;
+
+ /**
+ * Since flow control might hint to stop producing data,
+ * while we can't interrupt the rendering of a single JSON body
+ * we can avoid starting the rendering of any subsequent JSON body.
+ * So keep track of the next body which still needs to be rendered;
+ * to allow the output to be "repeatable" we also need to reset this
+ * at the end.
+ */
+ private int nextBodyToEncodeIndex = 0;
+
+ /**
+ * Adaptor from string output rendered into the actual output sink.
+ * We keep this as a field level attribute as we might have
+ * partially rendered JSON stored in its buffers while flow control
+ * refuses to accept more bytes.
+ */
+ private ProgressiveCharBufferWriter writer =
+ new ProgressiveCharBufferWriter( CHARSET, CHAR_BUFFER_SIZE, BYTE_BUFFER_PAGE_SIZE );
+
+ public GsonHttpEntity(Gson gson, List bodyParts) throws IOException {
+ Contracts.assertNotNull( gson, "gson" );
+ Contracts.assertNotNull( bodyParts, "bodyParts" );
+ this.gson = gson;
+ this.bodyParts = bodyParts;
+ this.contentLength = -1;
+ attemptOnePassEncoding();
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return true;
+ }
+
+ @Override
+ public void failed(Exception cause) {
+
+ }
+
+ @Override
+ public boolean isChunked() {
+ return false;
+ }
+
+ @Override
+ public Set getTrailerNames() {
+ return Set.of();
+ }
+
+ @Override
+ public long getContentLength() {
+ this.contentLengthWasProvided = true;
+ return this.contentLength;
+ }
+
+ @Override
+ public String getContentType() {
+ return CONTENT_TYPE;
+ }
+
+ @Override
+ public String getContentEncoding() {
+ //Apparently this is the correct value:
+ return null;
+ }
+
+ @Override
+ public InputStream getContent() {
+ return new HttpAsyncEntityProducerInputStream( this, BYTE_BUFFER_PAGE_SIZE );
+ }
+
+ @Override
+ public void writeTo(OutputStream out) throws IOException {
+ /*
+ * For this method we use no pagination, so ignore the mutable fields.
+ *
+ * Note we don't close the counting stream or the writer,
+ * because we must not close the output stream that was passed as a parameter.
+ */
+ CountingOutputStream countingStream = new CountingOutputStream( out );
+ Writer outWriter = new OutputStreamWriter( countingStream, CHARSET );
+ for ( JsonObject bodyPart : bodyParts ) {
+ gson.toJson( bodyPart, outWriter );
+ outWriter.append( '\n' );
+ }
+ outWriter.flush();
+ //Now we finally know the content size in bytes:
+ hintContentLength( countingStream.getBytesWritten() );
+ }
+
+ @Override
+ public boolean isStreaming() {
+ return false;
+ }
+
+ @Override
+ public Supplier> getTrailers() {
+ return null;
+ }
+
+ @Override
+ public void close() {
+ //Nothing to close but let's make sure we re-wind the stream
+ //so that we can start from the beginning if needed
+ this.nextBodyToEncodeIndex = 0;
+ //Discard previous buffers as they might contain in-process content:
+ this.writer = new ProgressiveCharBufferWriter( CHARSET, CHAR_BUFFER_SIZE, BYTE_BUFFER_PAGE_SIZE );
+ }
+
+ /**
+ * Let's see if we can fully encode the content with a minimal write,
+ * i.e. only one body part.
+ * This will allow us to keep the memory consumption reasonable
+ * while also being able to hint the client about the {@link #getContentLength()}.
+ * Incidentally, having this information would avoid chunked output encoding
+ * which is ideal precisely for small messages which can fit into a single buffer.
+ *
+ * @throws IOException This is unlikely to be caused by a real IO operation as there's no output buffer yet,
+ * but it could also be triggered by the UTF8 encoding operations.
+ */
+ private void attemptOnePassEncoding() throws IOException {
+ // Essentially attempt to use the writer without going NPE on the output sink
+ // as it's not set yet.
+ triggerFullWrite();
+ if ( nextBodyToEncodeIndex == bodyParts.size() ) {
+ writer.flush();
+ // The buffer's content length so far is the final content length,
+ // as we know the entire content has been encoded already.
+ hintContentLength( writer.contentLength() );
+ }
+ }
+
+ /**
+ * Higher level write loop. It will start writing the JSON objects
+ * from either the beginning or the next object which wasn't written yet
+ * but simply stop and return as soon as the sink can't accept more data.
+ * Checking state of writer.flowControlPushingBack will reveal if everything
+ * was written.
+ * @throws IOException If writing fails.
+ */
+ private void triggerFullWrite() throws IOException {
+ while ( nextBodyToEncodeIndex < bodyParts.size() ) {
+ JsonObject bodyPart = bodyParts.get( nextBodyToEncodeIndex++ );
+ gson.toJson( bodyPart, writer );
+ writer.append( '\n' );
+ writer.flush();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+ }
+ }
+
+ @Override
+ public int available() {
+ return 0;
+ }
+
+ @Override
+ public void produce(DataStreamChannel channel) throws IOException {
+ Contracts.assertNotNull( channel, "channel" );
+ // Warning: this method is possibly invoked multiple times, depending on the output buffers
+ // to have available space !
+ // Production of data is expected to complete only after we invoke ContentEncoder#complete.
+
+ //Re-set the encoder as it might be a different one than a previously used instance:
+ writer.setOutput( channel );
+
+ //First write unfinished business from previous attempts
+ writer.resumePendingWrites();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+
+ triggerFullWrite();
+
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+ writer.flushToOutput();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+
+ // If we haven't aborted yet, we finished!
+
+ // The buffer's content length so far is the final content length,
+ // as we know the entire content has been encoded already.
+ // Hint at the content length.
+ // Note this is only useful if produceContent was called by some process
+ // that is not the HTTP client itself (e.g. for request signing),
+ // because the HTTP Client itself will request the size before it starts writing content.
+ hintContentLength( writer.contentLength() );
+
+ channel.endStream();
+ this.releaseResources();
+ }
+
+ private void hintContentLength(long contentLength) {
+ if ( !contentLengthWasProvided ) {
+ this.contentLength = contentLength;
+ }
+ }
+
+ @Override
+ public void releaseResources() {
+ close();
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/HttpAsyncEntityProducerInputStream.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/HttpAsyncEntityProducerInputStream.java
new file mode 100644
index 00000000000..be1ea946d2b
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/HttpAsyncEntityProducerInputStream.java
@@ -0,0 +1,83 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+
+
+final class HttpAsyncEntityProducerInputStream extends InputStream {
+ private final AsyncEntityProducer entityProducer;
+ private final ByteBuffer buffer;
+ private final ByteBufferDataStreamChannel contentEncoder;
+
+ public HttpAsyncEntityProducerInputStream(AsyncEntityProducer entityProducer, int bufferSize) {
+ this.entityProducer = entityProducer;
+ this.buffer = ByteBuffer.allocate( bufferSize );
+ this.buffer.limit( 0 );
+ this.contentEncoder = new ByteBufferDataStreamChannel( buffer );
+ }
+
+ @Override
+ public int read() throws IOException {
+ int read = readFromBuffer();
+ if ( read < 0 && !contentEncoder.isCompleted() ) {
+ writeToBuffer();
+ read = readFromBuffer();
+ }
+ return read;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int offset = off;
+ int length = len;
+ while ( length > 0 && ( buffer.remaining() > 0 || !contentEncoder.isCompleted() ) ) {
+ if ( buffer.remaining() == 0 ) {
+ writeToBuffer();
+ }
+ int bytesRead = readFromBuffer( b, offset, length );
+ offset += bytesRead;
+ length -= bytesRead;
+ }
+ int totalBytesRead = offset - off;
+ if ( totalBytesRead == 0 && contentEncoder.isCompleted() ) {
+ return -1;
+ }
+ return totalBytesRead;
+ }
+
+ @Override
+ public void close() {
+ entityProducer.releaseResources();
+ }
+
+ private void writeToBuffer() throws IOException {
+ buffer.clear();
+ entityProducer.produce( contentEncoder );
+ buffer.flip();
+ }
+
+ private int readFromBuffer() {
+ if ( buffer.hasRemaining() ) {
+ return buffer.get();
+ }
+ else {
+ return -1;
+ }
+ }
+
+ private int readFromBuffer(byte[] bytes, int offset, int length) {
+ int toRead = Math.min( buffer.remaining(), length );
+ if ( toRead > 0 ) {
+ buffer.get( bytes, offset, toRead );
+ }
+ return toRead;
+ }
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ProgressiveCharBufferWriter.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ProgressiveCharBufferWriter.java
new file mode 100644
index 00000000000..d7ad2072ea0
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ProgressiveCharBufferWriter.java
@@ -0,0 +1,288 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CoderResult;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+/**
+ * A writer to a ContentEncoder, using an automatically growing, paged buffer
+ * to store input when flow control pushes back.
+ *
+ * To be used when your input source is not reactive (uses {@link Writer}),
+ * but you have multiple elements to write and thus could take advantage of
+ * reactive output to some extent.
+ *
+ * @author Sanne Grinovero
+ */
+class ProgressiveCharBufferWriter extends Writer {
+
+ private final CharsetEncoder charsetEncoder;
+
+ /**
+ * Size of buffer pages.
+ */
+ private final int pageSize;
+
+ /**
+ * A higher-level buffer for chars, so that we don't have
+ * to wrap every single incoming char[] into a CharBuffer.
+ */
+ private final CharBuffer charBuffer;
+
+ /**
+ * Filled buffer pages to be written, in write order.
+ */
+ private final Deque needWritingPages = new ArrayDeque<>( 5 );
+
+ /**
+ * Current buffer page, potentially null,
+ * which may have some content but isn't full yet.
+ */
+ private ByteBuffer currentPage;
+
+ /**
+ * Initially null: must be set before writing is started and each
+ * time it's resumed as it might change between writes during
+ * chunked encoding.
+ */
+ private DataStreamChannel channel;
+
+ /**
+ * Set this to true when we detect clogging, so we can stop trying.
+ * Make sure to reset this when the HTTP Client hints so.
+ * It's never dangerous to re-enable, just not efficient to try writing
+ * unnecessarily.
+ */
+ private boolean flowControlPushingBack = false;
+
+ private int contentLength = 0;
+
+ public ProgressiveCharBufferWriter(Charset charset, int charBufferSize, int pageSize) {
+ this.charsetEncoder = charset.newEncoder();
+ this.pageSize = pageSize;
+ this.charBuffer = CharBuffer.allocate( charBufferSize );
+ }
+
+ /**
+ * Set the encoder to write to when buffers are full.
+ */
+ public void setOutput(DataStreamChannel channel) {
+ this.channel = channel;
+ }
+
+ // Overrides super.write(int) to remove the synchronized() wrapper.
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(int c) throws IOException {
+ if ( 1 > charBuffer.remaining() ) {
+ flush();
+ }
+ charBuffer.put( (char) c );
+ }
+
+ // Overrides super.write(String, int, int) to remove the synchronized() wrapper.
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(String str, int off, int len) throws IOException {
+ // See write(char[], int, int) for comments.
+ if ( len > charBuffer.capacity() ) {
+ flush();
+ writeToByteBuffer( CharBuffer.wrap( str, off, off + len ) );
+ }
+ else if ( len > charBuffer.remaining() ) {
+ flush();
+ charBuffer.put( str, off, off + len );
+ }
+ else {
+ charBuffer.put( str, off, off + len );
+ }
+ }
+
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(char[] cbuf, int off, int len) throws IOException {
+ if ( len > charBuffer.capacity() ) {
+ /*
+ * "cbuf" won't fit in our char buffer, so we'll just write
+ * everything to the byte buffer (first the pending chars in the
+ * char buffer, then "cbuf").
+ */
+ flush();
+ writeToByteBuffer( CharBuffer.wrap( cbuf, off, len ) );
+ }
+ else if ( len > charBuffer.remaining() ) {
+ /*
+ * We flush the buffer before writing anything in this case.
+ *
+ * If we did not, we'd run the risk of splitting a 3 or 4-byte
+ * character in two parts (one at the end of the buffer before
+ * flushing it, and the other at the beginning after flushing it),
+ * and the encoder would fail when encoding the second part.
+ *
+ * See HSEARCH-2886.
+ */
+ flush();
+ charBuffer.put( cbuf, off, len );
+ }
+ else {
+ charBuffer.put( cbuf, off, len );
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ if ( charBuffer.position() == 0 ) {
+ return;
+ }
+ charBuffer.flip();
+ writeToByteBuffer( charBuffer );
+ charBuffer.clear();
+
+ // don't flush byte buffers to output as we want to control that flushing independently.
+ }
+
+ @Override
+ public void close() {
+ // Nothing to do
+ }
+
+ /**
+ * Send all full buffer pages to the {@link #setOutput(DataStreamChannel) output}.
+ *
+ * Flow control may push back, in which case this method or {@link #flushToOutput()}
+ * should be called again later.
+ *
+ * @throws IOException when {@link ContentEncoder#write(ByteBuffer)} fails.
+ */
+ public void resumePendingWrites() throws IOException {
+ flush();
+ flowControlPushingBack = false;
+ attemptFlushPendingBuffers( false );
+ }
+
+ /**
+ * @return {@code true} if the {@link #setOutput(DataStreamChannel) output} pushed
+ * back the last time a write was attempted, {@code false} otherwise.
+ */
+ public boolean isFlowControlPushingBack() {
+ return flowControlPushingBack;
+ }
+
+ /**
+ * Send all buffer pages to the {@link #setOutput(DataStreamChannel) output},
+ * Even those that are not full yet
+ *
+ * Flow control may push back, in which case this method should be called again later.
+ *
+ * @throws IOException when {@link ContentEncoder#write(ByteBuffer)} fails.
+ */
+ public void flushToOutput() throws IOException {
+ flush();
+ flowControlPushingBack = false;
+ attemptFlushPendingBuffers( true );
+ }
+
+ /**
+ * @return The length of the content stored in the byte buffers so far, in bytes.
+ * This does include the content that has already been written to the {@link #setOutput(DataStreamChannel) output},
+ * but not the content of the char buffer (which can be flushed to byte buffers using {@link #flush()}).
+ */
+ public int contentLength() {
+ return contentLength;
+ }
+
+ private void writeToByteBuffer(CharBuffer input) throws IOException {
+ while ( true ) {
+ if ( currentPage == null ) {
+ currentPage = ByteBuffer.allocate( pageSize );
+ }
+ int initialPagePosition = currentPage.position();
+ CoderResult coderResult = charsetEncoder.encode( input, currentPage, false );
+ contentLength += ( currentPage.position() - initialPagePosition );
+ if ( coderResult.equals( CoderResult.UNDERFLOW ) ) {
+ return;
+ }
+ else if ( coderResult.equals( CoderResult.OVERFLOW ) ) {
+ // Avoid storing buffers if we can simply flush them
+ attemptFlushPendingBuffers( true );
+ if ( currentPage != null ) {
+ /*
+ * We couldn't flush the current page, but it's full,
+ * so let's move it out of the way.
+ */
+ currentPage.flip();
+ needWritingPages.add( currentPage );
+ currentPage = null;
+ }
+ }
+ else {
+ //Encoding exception
+ coderResult.throwException();
+ return; //Unreachable
+ }
+ }
+ }
+
+ /**
+ * @return {@code true} if this buffer contains content to be written, {@code false} otherwise.
+ */
+ private boolean hasRemaining() {
+ return !needWritingPages.isEmpty() || currentPage != null && currentPage.position() > 0;
+ }
+
+ private void attemptFlushPendingBuffers(boolean flushCurrentPage) throws IOException {
+ if ( channel == null ) {
+ flowControlPushingBack = true;
+ }
+ if ( flowControlPushingBack || !hasRemaining() ) {
+ // Nothing to do
+ return;
+ }
+ Iterator iterator = needWritingPages.iterator();
+ while ( iterator.hasNext() && !flowControlPushingBack ) {
+ ByteBuffer buffer = iterator.next();
+ boolean written = write( buffer );
+ if ( written ) {
+ iterator.remove();
+ }
+ else {
+ flowControlPushingBack = true;
+ }
+ }
+ if ( flushCurrentPage && !flowControlPushingBack && currentPage != null && currentPage.position() > 0 ) {
+ // The encoder still accepts some input, and we are allowed to flush the current page. Let's do.
+ currentPage.flip();
+ boolean written = write( currentPage );
+ if ( !written ) {
+ flowControlPushingBack = true;
+ needWritingPages.add( currentPage );
+ }
+ currentPage = null;
+ }
+ }
+
+ private boolean write(ByteBuffer buffer) throws IOException {
+ final int toWrite = buffer.remaining();
+ // We should never do 0-length writes, see HSEARCH-2854
+ if ( toWrite == 0 ) {
+ return true;
+ }
+ final int actuallyWritten = channel.write( buffer );
+ return toWrite == actuallyWritten;
+ }
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ServerUris.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ServerUris.java
new file mode 100644
index 00000000000..cf5611ae259
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/impl/ServerUris.java
@@ -0,0 +1,126 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
+
+import org.apache.hc.core5.http.HttpHost;
+
+
+final class ServerUris {
+
+ private final HttpHost[] hosts;
+ private final boolean sslEnabled;
+
+ private ServerUris(HttpHost[] hosts, boolean sslEnabled) {
+ this.hosts = hosts;
+ this.sslEnabled = sslEnabled;
+ }
+
+ static ServerUris fromOptionalStrings(Optional protocol, Optional> hostAndPortStrings,
+ Optional> uris) {
+ if ( !uris.isPresent() ) {
+ String protocolValue =
+ ( protocol.isPresent() ) ? protocol.get() : ElasticsearchBackendClientCommonSettings.Defaults.PROTOCOL;
+ List hostAndPortValues =
+ ( hostAndPortStrings.isPresent() )
+ ? hostAndPortStrings.get()
+ : ElasticsearchBackendClientCommonSettings.Defaults.HOSTS;
+ return fromStrings( protocolValue, hostAndPortValues );
+ }
+
+ if ( protocol.isPresent() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndProtocol( uris.get(), protocol.get() );
+ }
+
+ if ( hostAndPortStrings.isPresent() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndHosts( uris.get(), hostAndPortStrings.get() );
+ }
+
+ return fromStrings( uris.get() );
+ }
+
+ private static ServerUris fromStrings(List serverUrisStrings) {
+ if ( serverUrisStrings.isEmpty() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfUris();
+ }
+
+ HttpHost[] hosts = new HttpHost[serverUrisStrings.size()];
+ Boolean https = null;
+ for ( int i = 0; i < serverUrisStrings.size(); ++i ) {
+ String uri = serverUrisStrings.get( i );
+ try {
+ HttpHost host = HttpHost.create( uri );
+ hosts[i] = host;
+ String scheme = host.getSchemeName();
+ boolean currentHttps = "https".equals( scheme );
+ if ( https == null ) {
+ https = currentHttps;
+ }
+ else if ( currentHttps != https ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.differentProtocolsOnUris( serverUrisStrings );
+ }
+ }
+ catch (URISyntaxException e) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidUri( uri, e.getMessage(), e );
+ }
+ }
+
+ return new ServerUris( hosts, https );
+ }
+
+ private static ServerUris fromStrings(String protocol, List hostAndPortStrings) {
+ if ( hostAndPortStrings.isEmpty() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfHosts();
+ }
+
+ HttpHost[] hosts = new HttpHost[hostAndPortStrings.size()];
+ // Note: protocol and URI scheme are not the same thing,
+ // but for HTTP/HTTPS both the protocol and URI scheme are named HTTP/HTTPS.
+ String scheme = protocol.toLowerCase( Locale.ROOT );
+ for ( int i = 0; i < hostAndPortStrings.size(); ++i ) {
+ HttpHost host = createHttpHost( scheme, hostAndPortStrings.get( i ) );
+ hosts[i] = host;
+ }
+ return new ServerUris( hosts, "https".equals( scheme ) );
+ }
+
+ private static HttpHost createHttpHost(String scheme, String hostAndPort) {
+ if ( hostAndPort.indexOf( "://" ) >= 0 ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, null );
+ }
+ String host;
+ int port = -1;
+ final int portIdx = hostAndPort.lastIndexOf( ':' );
+ if ( portIdx < 0 ) {
+ host = hostAndPort;
+ }
+ else {
+ try {
+ port = Integer.parseInt( hostAndPort.substring( portIdx + 1 ) );
+ }
+ catch (final NumberFormatException e) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, e );
+ }
+ host = hostAndPort.substring( 0, portIdx );
+ }
+ return new HttpHost( scheme, host, port );
+ }
+
+ HttpHost[] asHostsArray() {
+ return hosts;
+ }
+
+ boolean isSslEnabled() {
+ return sslEnabled;
+ }
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/logging/impl/ElasticsearchClientLog.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/logging/impl/ElasticsearchClientLog.java
new file mode 100644
index 00000000000..0b7d57765f3
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/logging/impl/ElasticsearchClientLog.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.logging.impl;
+
+import java.lang.invoke.MethodHandles;
+
+import org.hibernate.search.util.common.logging.CategorizedLogger;
+import org.hibernate.search.util.common.logging.impl.LoggerFactory;
+import org.hibernate.search.util.common.logging.impl.MessageConstants;
+
+import org.jboss.logging.annotations.MessageLogger;
+
+@CategorizedLogger(
+ category = ElasticsearchClientLog.CATEGORY_NAME,
+ description = """
+ Logs information on low-level Elasticsearch backend operations.
+ +
+ This may include warnings about misconfigured Elasticsearch REST clients or index operations.
+ """
+)
+@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+public interface ElasticsearchClientLog {
+ String CATEGORY_NAME = "org.hibernate.search.elasticsearch.client";
+
+ ElasticsearchClientLog INSTANCE = LoggerFactory.make( ElasticsearchClientLog.class, CATEGORY_NAME, MethodHandles.lookup() );
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/package-info.java b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/package-info.java
new file mode 100644
index 00000000000..ce509db87fc
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/java/package-info.java
@@ -0,0 +1 @@
+package org.hibernate.search.backend.elasticsearch.client.java;
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer b/backend/elasticsearch-client/elasticsearch-java-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
new file mode 100644
index 00000000000..fbf115c82b4
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
@@ -0,0 +1 @@
+org.hibernate.search.backend.elasticsearch.client.java.impl.ClientJavaElasticsearchClientBeanConfigurer
diff --git a/backend/elasticsearch-client/elasticsearch-java-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntityTest.java b/backend/elasticsearch-client/elasticsearch-java-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntityTest.java
new file mode 100644
index 00000000000..f091ac131fc
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-java-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/java/impl/GsonHttpEntityTest.java
@@ -0,0 +1,343 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.java.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+class GsonHttpEntityTest {
+
+ public static List extends Arguments> params() {
+ List params = new ArrayList<>();
+ Gson gson = GsonProvider.create( GsonBuilder::new, true ).getGson();
+
+ JsonObject bodyPart1 = JsonParser.parseString( "{ \"foo\": \"bar\" }" ).getAsJsonObject();
+ JsonObject bodyPart2 = JsonParser.parseString( "{ \"foobar\": 235 }" ).getAsJsonObject();
+ JsonObject bodyPart3 = JsonParser.parseString( "{ \"obj1\": " + bodyPart1.toString()
+ + ", \"obj2\": " + bodyPart2.toString() + "}" ).getAsJsonObject();
+
+ for ( List jsonObjects : Arrays.>asList(
+ Collections.emptyList(),
+ Collections.singletonList( bodyPart1 ),
+ Collections.singletonList( bodyPart2 ),
+ Collections.singletonList( bodyPart3 ),
+ Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ),
+ Arrays.asList( bodyPart3, bodyPart2, bodyPart1 )
+ ) ) {
+ params.add( Arguments.of( jsonObjects.toString(), jsonObjects ) );
+ }
+ params.add( Arguments.of(
+ "50 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 50 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "200 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 200 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "10,000 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 10_000 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "200 large objects",
+ Stream.generate( () -> {
+ // Generate one large object
+ JsonObject object = new JsonObject();
+ JsonArray array = new JsonArray();
+ object.add( "array", array );
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 1_000 ).forEach( array::add );
+ return object;
+ } )
+ // Reproduce the large object multiple times
+ .limit( 200 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "1 very large object",
+ Stream.generate( () -> {
+ JsonObject object = new JsonObject();
+ JsonArray array = new JsonArray();
+ object.add( "array", array );
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 100_000 ).forEach( array::add );
+ return object;
+ } )
+ // Reproduce the large object multiple times
+ .limit( 1 ).collect( Collectors.toList() )
+ ) );
+
+ params.add( Arguments.of(
+ "Reproducer for HSEARCH-4239",
+ // Yes these objects are weird, but then this is a weird edge-case.
+ Arrays.asList(
+ gson.fromJson( "{\"index\":{\"_index\":\"indexname-write\",\"_id\":\"0\"}}", JsonObject.class ),
+ gson.fromJson( "{\"content\":\"..........................................................."
+ + "...........—.....................—.......—....—....................................."
+ + "...—.....................................................................——........."
+ + "...................................................................................."
+ + "........................—..........................................................."
+ + "...................................................................................."
+ + "...........................................—........................................"
+ + "............—............—.........................................................."
+ + "...................................................................................."
+ + "................................................................——.....—............"
+ + ".................—.................................................................."
+ + ".............................——....................................................."
+ + "..........................................\",\"_entity_type\":\"indexNameType\"}",
+ JsonObject.class )
+ )
+ ) );
+
+ params.add( Arguments.of(
+ "Reproducer for HSEARCH-4254",
+ Arrays.asList(
+ gson.fromJson(
+ // This is randomly generated content that happens to reproduce the problem.
+ // If it means anything, that's purely coincidental.
+ // I'm sorry if it's offensive,
+ // but if I change even one character the problem no longer appears,
+ // so I'm stuck with this content.
+ "{\"content\":\"\u010D\uFC7A\uCBED\uD857\uDFC9\uB5FA\u17B2\uD83E\uDD21\u15C5\uFF3D\uD397\u3F7B\u7822\uD84F\uDDB2\uD859\uDD21\uD871\uDFCC\uD852\uDDC3\uD848\uDCFD\uD85E\uDEAF\uD81E\uDD10\uD855\uDDF1\u2D67\uD859\uDC62\uD85C\uDC89\u7D75\uD82F\uDC09\uD800\uDFAA\uD803\uDC35\u1BE9\u4183\u068A\uC61E\uD85D\uDDC7\uD3CC\uD873\uDFD6\u3DA6\uD842\uDE91\uD85A\uDC3D\uD865\uDCF0\uD81C\uDDEE\u718A\uD84D\uDEB3\uC2C2\uD86C\uDF47\u8F9D\u4801\uD84C\uDED9\uD86B\uDE9C\uC759\uD862\uDF42\uCE83\u7B02\u526D\u978E\u3936\u7C94\u6746\uD866\uDF98\uD844\uDFC8\uD851\uDF8E\u4A98\u789E\uD85B\uDF01\uD855\uDE5B\uD87A\uDF2F\uD869\uDF29\u2AFF\uD821\uDD33\uD86C\uDE14\uD87E\uDCD7\uFF1F\uD853\uDEA4\uD847\uDD4B\uD85A\uDD5D\uD81A\uDCF1\u7F23\u7A6C\uD848\uDC48\uD84D\uDD06\uD821\uDED2\u2ED6\uD83E\uDD56\u591B\uD84C\uDD84\uD85D\uDE1B\uD877\uDEFE\uC624\u6DEA\u73BC\uD821\uDE30\u23A7\uD7DB\uD862\uDD93\u3B3C\u0ED4\uD846\uDC7B\u0959\uD86D\uDE2A\uD86B\uDC2B\uD86D\uDE87\u76A3\uA10F\uD872\uDEB1\uA604\uA852\uD1D5\uD856\uDEBD\uD844\uDD82\uD820\uDEB4\\u0006\uD81E\uDF6F\uD870\uDD2D\uD856\uDF4E\uD875\uDE78\uB7DB\uCF98\uD856\uDEAF\uD87A\uDEC9\u04A8\uD848\uDFEF\uD852\uDE49\uC933\u12AE\uD84F\uDC63\uD850\uDECE\uD874\uDDAC\uD872\uDC7D\u7F76\uD869\uDE37\uD2A0\uD4C7\uD868\uDDBC\uD848\uDC31\u86EA\u7276\u48B9\u8412\uB216\uD848\uDF0D\u7102\uD869\uDC63\u9018\u2FFA\uD844\uDD1E\u91DD\uFAA3\uD877\uDF17\u8617\uD821\uDEC9\uD860\uDCA7\u66B1\uD808\uDCF5\uD807\uDC85\u483B\u1CE0\u7DF3\uD878\uDDA4\u14BA\u3558\uB82D\uD845\uDE49\uD871\uDEFC\uD87E\uDDCC\uA1C2\uB195\uD874\uDE28\u3AD4\uD87A\uDF69\u12E4\u6787\uD850\uDC86\u414D\uD84B\uDCFC\u14DB\u3259\uD85A\uDD6B\u15A8\uD87E\uDCB3\uD868\uDE40\uD81D\uDF4B\uD821\uDDAF\uD81F\uDC9E\u1BC8\uD805\uDE1A\uD84B\uDE33\uC4E3\u9D76\uD849\uDEA5\u1230\u9DE0\u2F65\u2BBD\u604E\uD848\uDC6B\uD835\uDF6B\u27E6\uD846\uDF6C\u77AB\uD865\uDD55\u15EC\u380D\uD857\uDEA7\uD859\uDEA3\uD841\uDC58\uD86D\uDC68\uD85B\uDCCC\uD864\uDE04\uD874\uDEEB\uD879\uDC9A\uD874\uDFF5\uD83C\uDF9A\uD879\uDD5E\u4C5C\uD80C\uDE45\uCAB3\uD81F\uDFFB\uD81F\uDDC1\uD844\uDF64\u3DB6\uD862\uDF7D\uD870\uDC1B\uD853\uDC91\uD864\uDE99\uD845\uDD16\uD84D\uDCC5\uD870\uDF76\uD86C\uDCA2\uD821\uDC02\uD820\uDFF9\uD865\uDF2C\u75B5\u9AA4\uD87A\uDDCE\u3004\u8355\uA2D8\uD84B\uDCED\uD809\uDD01\u763F\uD845\uDE39\u610B\uD84A\uDDD8\u89DC\uD842\uDE17\u9797\uD842\uDFC2\uD83E\uDC70\uD804\uDD72\u732E\uD86C\uDEA0\uAE24\uD822\uDD4F\uD86A\uDDF1\uD847\uDDA7\uD876\uDDB9\uD85F\uDC41\u6428\uD85A\uDCE4\uD84F\uDE6C\uD868\uDE62\uD860\uDEDC\uD83E\uDC05\uD83A\uDC3A\uC710\u332D\uD17F\u3CBF\uC2C0\uD86A\uDE20\uD858\uDCB3\uD858\uDDDE\uD855\uDFF6\u5E77\uD860\uDD85\u16D8\uD867\uDCD2\uD84E\uDFB1\u3978\uD853\uDD69\u07D4\uD874\uDC5B\uD84F\uDFDC\uD86A\uDF87\uCA0C\u3FE1\uD84A\uDF3F\u2EEA\uD821\uDDA7\uAADD\uD80C\uDF61\uA8D3\uD860\uDFFB\u1386\uD86B\uDE4B\uD874\uDF01\uD855\uDE10\u104A\u8C65\uD85E\uDEC4\uD85E\uDD20\u810A\uD81A\uDCD0\u2B3D\uD842\uDEFF\uD821\uDC28\uD877\uDE3E\uD856\uDE83\uD854\uDFC9\uD87A\uDE84\u01D4\uD85F\uDED8\uC53E\uD841\uDF04\uD857\uDE31\uCDB9\uD877\uDF5A\u99E2\uC3A4\uD83E\uDD2E\u1BA9\uD852\uDD5B\uD848\uDE9D\uD870\uDED3\uD849\uDD9F\u3D3F\uD857\uDEB0\u4193\u053A\u5B90\uD852\uDFBA\uD878\uDE00\u738C\uD878\uDF7E\uD868\uDF86\u7A08\uD868\uDFF1\uD81A\uDF1A\uD80C\uDD52\uD85C\uDE22\uD870\uDD12\uD81C\uDC35\uD871\uDD2C\uD877\uDE45\uD84F\uDD4B\u37DB\uD86C\uDC11\u9E19\uD85B\uDDA7\uD84D\uDEE8\uD86F\uDF6D\uD86A\uDDFC\u4CD5\u4459\uD85A\uDD6E\uD844\uDF3C\uCB39\uD809\uDD36\uA240\uD84B\uDC56\uD847\uDD46\uD852\uDD78\uD84A\uDC25\u316E\uD85C\uDF62\uD86E\uDD3E\u720D\u3C94\u99ED\uD489\u66A3\u4325\u663D\uB04B\u60D3\uCFB2\uD840\uDEF8\uD83A\uDCAE\uD843\uDCC0\u97C6\uD84D\uDC8E\uD858\uDCEF\uD87A\uDE11\uD84D\uDC23\uD86F\uDCE1\uD875\uDF57\uD84F\uDD00\u9AAA\uD835\uDF15\uD84E\uDC0E\uD822\uDC79\uFC38\u6D95\uD876\uDE83\uD84B\uDEAB\uC7A6\uD849\uDEA8\uD866\uDCE5\uD836\uDD60\uCD9E\uD802\uDC78\uD867\uDEFA\uD800\uDCE1\uD112\uD804\uDE23\u74A7\uD86A\uDC5F\u8E99\uD811\uDDFA\uD848\uDCF0\uD875\uDC60\u38D9\uD808\uDD5A\uD820\uDF59\uD86E\uDDD2\u95A5\uA72E\u2756\uD845\uDF91\u610C\uD820\uDEF5\u6F33\uD868\uDFE3\uD859\uDE88\uD874\uDC80\uD85D\uDE43\uD85E\uDD15\uD875\uDEAE\u8DD8\u8BAD\u5D7E\u3491\uD858\uDE1E\u2A4F\u95EE\uD806\uDE5F\u2995\uD869\uDDDE\uD81A\uDF74\uFDB4\u8EAD\uD802\uDDE3\uD841\uDFA9\u9B32\u0F03\uD863\uDFC3\u20E6\uD85E\uDDB0\u928E\uB839\uD86C\uDDC7\uD840\uDDFF\uD870\uDED5\uD873\uDC91\uD850\uDECB\uD84A\uDD73\uD854\uDCFB\uBB90\uBA0D\uD834\uDD2F\u38CB\uD805\uDC83\u5AB3\uD847\uDCC4\uD85B\uDC14\u73A1\u13CE\uD846\uDF08\u136E\uA032\uF956\uA1B9\uD820\uDD41\uD85E\uDDC3\uD870\uDEB0\uD86D\uDC05\uD86F\uDECB\uD85E\uDF16\uD802\uDC1E\uC74E\u581C\uD808\uDF01\u5991\uD805\uDE0D\uD86F\uDE88\uD851\uDC40\uD86A\uDD24\uD865\uDFA8\u6DE1\uD804\uDCF4\uD84E\uDE5A\uD86E\uDFB3\u4F8E\u7F02\uD84F\uDCD1\uD863\uDC15\uD859\uDC82\uD834\uDE40\uD863\uDF4D\uD86C\uDC3A\uC599\uD01E\u82DB\uD871\uDF39\uD840\uDF44\uD81F\uDCA1\uD85E\uDDEE\u9745\uD845\uDC31\uD856\uDD79\uAB72\uD66D\uB29F\u37B0\u96DD\uB781\uD85A\uDF54\uCD02\uD86C\uDE6E\u2CFF\u367D\u7DA2\uD854\uDE05\u7D83\uD808\uDE70\uD85A\uDE12\uD853\uDD97\u2ACC\uD85C\uDEE5\uD872\uDE5C\uD85A\uDEBA\uD875\uDC4F\u7008\uD867\uDFC3\u1C13\u9888\u9DCA\u9FE9\uD84D\uDCA8\u5530\uD855\uDEE2\uD841\uDD5D\uD877\uDE59\uD867\uDD7A\uD85C\uDF97\uD80C\uDDD3\uD847\uDF4A\uD876\uDF1A\uC279\uD874\uDF6F\u1339\uD86A\uDC20\uD841\uDFAB\u09CC\uD811\uDC98\u4228\u5DCF\u2A82\uD86B\uDCF4\u069F\uD860\uDF40\u93F0\uD808\uDF0B\uD841\uDC86\uD870\uDD41\u610B\u63EE\uD81A\uDCBE\u9A85\uD809\uDC91\uD852\uDDAA\u6974\uD847\uDDA3\uD85B\uDDBA\uD840\uDE4C\u56BA\uD876\uDED3\uD867\uDDF8\uD808\uDC2F\uD867\uDC05\uD84B\uDC14\uD867\uDDB4\u4DF0\u82E6\uD845\uDEE2\uD81C\uDE14\uD802\uDD83\uBC7E\uD85D\uDC2C\uD846\uDD1E\uD873\uDCCC\uD860\uDC8A\uD878\uDC92\uD874\uDE65\uD861\uDC28\uD854\uDC3E\uD84C\uDC11\uD856\uDD11\uD81D\uDEB0\uB07A\u89A4\uD85A\uDED9\uD81C\uDEE2\uD805\uDC0D\uD834\uDC22\uD864\uDC4F\u240B\uD86A\uDE80\uD876\uDFC4\uD85E\uDCFF\uD871\uDE7C\u1987\uD835\uDC8F\u1990\uD863\uDFE1\uD81A\uDF26\uD802\uDC0E\uD853\uDE0B\uD841\uDE95\u59E7\u1E02\uD864\uDF58\uD877\uDEE1\uD82C\uDEC0\uD86B\uDD8A\u391D\u03BB\uD863\uDE29\uD84B\uDEDB\u8F02\uD2A0\u22ED\uD863\uDF74\u70A9\uD856\uDE3A\uBC76\uD861\uDD78\uD868\uDD9C\uD851\uDFF6\u8860\uD841\uDD85\uFB84\u110B\uD850\uDCD4\uD875\uDE6D\uD845\uDD20\uDB40\uDD9D\uD853\uDD7A\uD841\uDEA3\u0D05\uD869\uDDC0\uD856\uDDA8\uD870\uDD1F\uD86C\uDCF9\uD80C\uDF2A\uD804\uDCD4\uD85B\uDDF5\u9EF2\uD844\uDF27\u8536\uD85B\uDFE7\u581F\u6C39\uD856\uDE79\uA50D\uD864\uDCD9\uD879\uDEF7\uD841\uDE38\u3D49\u1B50\uD807\uDC8D\uD811\uDDAF\u82CC\u3F64\u851E\uD847\uDF0B\u0923\u3000\uD85A\uDE7E\uAC0D\u9344\uD801\uDEE5\u02EF\uD0D1\uD877\uDEDD\uD861\uDE7E\u9910\uD86B\uDEC1\uD85D\uDF3F\u6CBF\u27B2\u7869\uD822\uDC60\uD57E\uD860\uDD92\uD85F\uDCC0\uD86E\uDD44\uD872\uDE59\uD81D\uDE21\u956A\uD85F\uDD7A\u3A62\u95F5\uD878\uDF26\u5F0E\u2934\uD82C\uDC91\uD862\uDDA7\uD848\uDFD4\uD81D\uDE6F\uD86A\uDD5B\uD845\uDF26\u9C4F\uD82C\uDEBF\uD87A\uDE9B\uD849\uDC8A\u13D8\uD248\uBF92\uD853\uDDD6\uD862\uDEEE\uD7DA\uD863\uDCB6\uD860\uDDE9\uD859\uDCD6\uD856\uDCE8\u7612\uD84B\uDE38\uD82C\uDD70\uD85B\uDF2B\uD869\uDC4A\u396C\u7728\u41F4\uD859\uDC5D\uD83E\uDC3E\u648C\uD877\uDD93\u7358\uD845\uDE51\uD857\uDF95\uD870\uDE49\u93D4\uBDF1\u5E2E\u8197\u24E3\uD856\uDE54\u55CF\uD81F\uDF7A\uB6AC\uD865\uDF8B\uD81F\uDDD8\u6F19\uD84B\uDC82\uD867\uDD94\uB377\uD847\uDD74\uD875\uDFC4\uD86E\uDEF9\uD87A\uDD0C\u25C6\uD877\uDCF0\uD864\uDEE8\uD804\uDDC0\uD359\uD835\uDFC0\u813D\u2AC1\uD86C\uDD80\uD807\uDC58\u3750\uD805\uDCC7\u51D0\uD845\uDC5D\uD847\uDEB2\uD821\uDD63\uD869\uDE68\uD811\uDD5F\u76C0\uB127\u9D0D\u88DE\u163E\uD865\uDC75\uD85F\uDE50\uD857\uDF48\uD865\uDF68\uD87A\uDFCE\uD802\uDC89\uD86E\uDDE2\uD808\uDF58\uD850\uDC8F\u0714\uC90D\uD845\uDF49\u8EC9\uD856\uDE91\uD804\uDC61\uD847\uDE44\uD5FA\uD811\uDDB6\uD840\uDE71\uD38F\uFF3B\uD866\uDF1B\uD845\uDF61\uD871\uDFC5\u858A\uA12D\u53A8\uD820\uDF73\uD843\uDCCE\uD857\uDC68\uD84E\uDD33\uD867\uDC17\u26D1\u3573\u5701\u7BE5\uD870\uDF52\uD853\uDECD\u427E\uD873\uDF75\u51ED\uD857\uDF19\uD852\uDF3D\uD85F\uDC4E\uD84C\uDC13\uD842\uDDDE\uD868\uDDB4\uD871\uDEC5\uD871\uDC24\u8C19\uD85A\uDEF1\uD863\uDDDE\u2967\uD86A\uDEA6\uD857\uDC74\uD85B\uDF23\u3294\u7DC1\u7A9C\u7B1F\uD83B\uDE68\uD86D\uDCFC\uD80C\uDE3F\u2880\u488F\uD84C\uDE42\u70D2\uD861\uDC7D\uD821\uDFE3\uD848\uDEBD\uD841\uDD80\u69B6\uD848\uDEB5\u916E\uD803\uDC36\uD84E\uDE70\uD86E\uDC95\u0438\u95FC\uD870\uDE9A\uD7C5\uD875\uDF29\uCB39\uD860\uDDAF\uD86A\uDCBA\u1321\uD811\uDDD7\uD871\uDF2E\u35DF\uD873\uDCBB\u5373\u9A9A\u3055\uD84E\uDE62\uD85E\uDE7E\u7467\uB87D\uC42C\uD82C\uDD91\uD857\uDD05\uD84A\uDF2D\u9A18\uD81F\uDD42\u50FA\u8C62\uD84A\uDC70\u49AD\uD871\uDFD9\uD850\uDEA3\u3AF4\u198C\uB6FF\uA697\uD855\uDFEC\uD81D\uDD1A\uD878\uDC50\u9F9C\u1D34\u4FAF\uDB40\uDC64\u75C1\uD835\uDDF1\uD854\uDCEA\uD842\uDFC7\uD853\uDFD1\uD81C\uDD99\uD874\uDD2F\uBC5A\uD873\uDE4F\uD872\uDF8E\uD852\uDDF1\uD85F\uDC4C\uD842\uDF01\uD86D\uDD9A\uD84F\uDC70\uD867\uDD15\u0788\uD820\uDE7D\uDB40\uDDD3\uD82C\uDCCF\uD871\uDC2D\uD834\uDCD0\u9669\uD877\uDE12\uD872\uDDAB\uD843\uDC43\uD84E\uDC8D\u5840\uD840\uDD5D\uD858\uDFF2\uD855\uDF37\uB5A5\uD821\uDE6E\uD805\uDDCF\uD7DB\uD86A\uDC9F\uD861\uDC05\uD81E\uDE8B\u0718\uD852\uDEFE\uD80C\uDE1A\uD83D\uDC0D\uD86E\uDFBC\uD834\uDDCA\uFA86\u2E04\u0568\uC32B\uD871\uDE34\uD854\uDEC4\uD867\uDC93\uD85E\uDD31\u46A7\u8205\uD843\uDE2A\u0771\uD83C\uDE38\uD808\uDDD3\uD867\uDC11\uFD9F\uD84F\uDF7B\u552F\uA586\uD82F\uDC28\uD87A\uDCA9\u32B7\u39A4\uD802\uDC0F\uD806\uDE8B\u5E70\uBCA8\uD81D\uDCA2\uD840\uDC46\u8F5D\uD83D\uDF70\u47EF\uD85F\uDD3D\uA34E\uFE33\uD872\uDF71\u49D7\uD862\uDD8F\uD81D\uDE4A\uAF06\uCF0E\uD862\uDF2A\uD86E\uDE3B\uD867\uDE05\uD772\uD856\uDEC3\uD86F\uDC2A\uD878\uDCED\uD84C\uDF2E\u83F7\uD866\uDDD0\uD800\uDFB3\u33D4\uD81C\uDEAA\u0653\u7E17\uD858\uDEAB\u70BB\uD821\uDE30\uD85C\uDC09\uD856\uDC6E\u8D9E\uD854\uDF1D\uD86D\uDFF1\uAB78\u8511\uD858\uDE6C\uD820\uDE40\uBC40\u6F4C\u7F3E\uD858\uDE13\uC515\uB0D4\uD842\uDD61\uD852\uDFD8\uD821\uDCD1\uD025\uD858\uDF86\uD875\uDD75\uD86B\uDF7E\uCC14\u8184\uD855\uDF8C\uD844\uDF84\u486F\u7C5F\u607F\uD871\uDF9F\uD85D\uDCBE\u35A4\uD867\uDD8B\uD821\uDE68\uD84E\uDEC4\uC454\uD836\uDCFE\u41CE\uD861\uDFFE\uD852\uDD45\uD821\uDE08\u6B23\uD834\uDD8F\u1A62\uD843\uDF2D\uD864\uDD6A\uD871\uDD5E\uBA1E\uD802\uDF86\uD879\uDD5A\uD840\uDF1E\uD806\uDE00\u2954\uD86D\uDC96\u69A7\uD873\uDC6B\u97C4\uD862\uDE7C\uD872\uDD8E\u4655\uD871\uDC87\uD852\uDECD\uD80C\uDE09\uD84F\uDE14\uD80C\uDC55\uA462\uD872\uDCA9\u8FF6\u112F\uD867\uDDC1\uD874\uDDE7\uB42B\uD84E\uDCA6\uD87A\uDD28\uD876\uDE08\uD844\uDC59\u3339\uD2B2\u1846\uD84F\uDF01\uD843\uDC35\uD844\uDFCF\u3454\uD834\uDF19\uD864\uDFE1\uD81D\uDDDF\uD811\uDD67\uD875\uDC96\u249A\uD802\uDC55\uB8CA\u36E9\uD85D\uDEC2\u554B\uD867\uDEE7\u51AA\uD864\uDC4E\uFDAF\uD869\uDC06\uD854\uDC33\uADF4\u961C\uD861\uDF19\uD869\uDCDB\uD845\uDE48\uD800\uDF5D\uD853\uDFB7\uD86A\uDF06\uD875\uDD36\u7FF0\u5E62\uD865\uDD3A\uD860\uDFAC\uD821\uDEB3\uD862\uDCDC\uBEAA\uD87A\uDEBC\uD811\uDCDC\u85E4\u0361\u2034\u7908\uD845\uDD86\uD874\uDF9A\uD863\uDE70\u7BEE\uD85A\uDFE5\uD836\uDD4F\uC8AB\uD859\uDD0B\u4E29\uD86A\uDC59\uD83D\uDFC6\uD853\uDE9A\uD859\uDC89\u8F4E\u7B05\uD856\uDE6F\uD801\uDEA0\uD861\uDCA2\uA9CC\uD866\uDCAB\uD804\uDE29\uD845\uDF88\u4E60\u2C88\u6647\u7778\uA4AE\uD848\uDFB4\uD872\uDFCC\uD840\uDC2F\uD836\uDC88\uD873\uDDAE\uD849\uDC79\u560F\uD850\uDF73\uD85C\uDF65\uD135\u3BEE\uD84F\uDE98\uD848\uDEAC\uD854\uDD9A\uD877\uDCA7\uD84D\uDE3C\uCBBF\u4540\uD86E\uDD73\u64A0\uD836\uDC2D\uD861\uDF2D\uD81D\uDF78\uD867\uDC15\u250C\uD85F\uDDF0\u2FA3\uBF93\uD870\uDDAA\uD83C\uDCF3\uD86C\uDDE2\uD857\uDF46\uD852\uDE3D\uD868\uDFAC\uD846\uDDCC\uD81E\uDC20\uD86D\uDCFC\u1BB4\uB299\uD820\uDD71\uD867\uDFF3\uD840\uDF70\uD868\uDE0C\uD850\uDCE4\uD868\uDE55\uD85B\uDD96\u7655\uD80C\uDDBA\uD84C\uDF25\uD85C\uDD4B\uD861\uDF4C\uD84E\uDC4F\uD861\uDD1D\uD86E\uDD52\uD802\uDD9F\uD844\uDEB3\u6A9E\uD86F\uDFA0\uB34B\uD80C\uDF65\uD866\uDDF2\uD85F\uDCCA\uB9F3\uD85E\uDE08\uD84E\uDD9E\u183B\uD849\uDF5E\uD85A\uDC39\uD84A\uDD4D\uD852\uDC47\u6858\u011F\uD875\uDF8A\u84DA\u368B\uD876\uDE95\uD81F\uDEFE\uD87A\uDDE4\uA756\u037D\uD855\uDC7A\uD867\uDF03\uAFFC\u5FE2\uD86D\uDC47\uD872\uDE7C\uD856\uDD97\u3AE1\uD874\uDD8C\uD861\uDE70\uD860\uDC9D\uD698\uD81E\uDDF3\u35F0\u93C3\u2793\uD836\uDDBC\uD861\uDD09\uD85E\uDEEE\u9AEF\u1A16\uD849\uDE6D\u813F\uBA8B\uD821\uDC2E\uC8BE\uD83D\uDE4B\u833C\uD855\uDC8F\uD820\uDC58\u1ECD\u5ED5\uD85D\uDC49\u41F2\uD856\uDCBA\u4B3C\u246D\uD81D\uDD4C\u8A06\uD86F\uDD04\u19F6\uD85D\uDDB3\uD82C\uDE45\uD845\uDE39\uD846\uDD6C\u9D84\u1FBF\uD81C\uDF8B\uD872\uDE66\uD848\uDE60\uB7D0\uD859\uDC7A\uD822\uDC9B\u6337\uD855\uDD30\u4E11\u11DA\uD87A\uDCAD\uD811\uDCDE\uD83E\uDC94\uD6EC\u291D\uD81F\uDDE2\uD853\uDFE5\uD856\uDF8A\u4380\u26C1\uD848\uDED7\uD840\uDED8\u9B25\uD873\uDD9F\u302E\uD856\uDEB6\u8A9C\u8AE4\u9243\uD822\uDC9B\uD857\uDD59\uD86A\uDF91\u93BA\uD858\uDCC3\uD85E\uDD7D\uD873\uDCC8\uD85B\uDE59\uD855\uDD6E\\u2029\uD849\uDCB4\uD859\uDFF4\uD81C\uDC89\uD840\uDE15\u16E1\uD834\uDDA4\u689C\u1C70\uD85C\uDFAF\uD84C\uDE96\u6A60\uD851\uDD68\u3783\uD873\uDC9E\u35E1\u8C93\u1B6C\uD821\uDDC1\uC5E0\uDB40\uDD46\uD844\uDC60\u72FC\uD811\uDCCB\uD2F6\uD850\uDE8E\uF992\uD7E2\uB5B6\uD843\uDD4A\uD874\uDC3B\u40E4\u4F3A\u2B63\uD841\uDC98\u90A5\uD878\uDC20\uD821\uDE10\uD81A\uDF0C\uD853\uDF8C\uD836\uDE82\uD877\uDF38\uD867\uDFAF\uD846\uDC72\u2EEB\uD858\uDC64\uD851\uDC29\uD85B\uDCEB\uD851\uDD90\u50B0\uCF83\uD858\uDC19\uD855\uDE6C\uD84F\uDC96\uD878\uDF50\uFEA1\uD873\uDDA8\u0B71\uD820\uDE87\uD866\uDC3C\uD84E\uDF1B\uD855\uDEC8\uD875\uDDCB\u112E\u9220\uD863\uDD49\uB12C\uD84E\uDC02\uD865\uDC4B\uD869\uDF09\u936C\uD800\uDEBF\uD1FD\uD843\uDE31\u4D72\uA279\uD86A\uDC28\u8D16\uD858\uDEAF\u64A7\u100F\uA926\uD85A\uDC90\u86CF\u0664\u9DF3\u3D52\u22AF\uD86B\uDF1E\uD804\uDE80\uCEB3\u6265\u7519\uD81F\uDE52\uC4DB\uD865\uDD91\uD875\uDD25\uD867\uDE8E\uD85A\uDCEC\u67ED\uD857\uDC30\uD849\uDC78\uD841\uDC19\uD874\uDE96\uD841\uDEB1\uD874\uDE7D\uB05F\uD86A\uDC8D\uC01B\uA1E5\uD879\uDC39\uD861\uDC27\uD84D\uDD46\uBD5B\u6619\u7A05\uD851\uDEEB\uD835\uDF3C\uD81E\uDEB9\uD879\uDD0B\u092D\uD874\uDC91\uD864\uDFD6\uD848\uDEBC\uD847\uDDE8\u47BC\uD84B\uDC3F\u503D\uD862\uDF7A\uD851\uDC3E\u36B0\u7459\uA742\uD859\uDD91\uD85A\uDE3E\uC59C\uAAE6\u877C\uD840\uDCB4\uD859\uDC99\uD805\uDF1D\u98F8\uC819\uD85E\uDE2E\uD850\uDFB3\uD874\uDF7C\uD850\uDE51\u7484\uD873\uDCAA\uD857\uDC15\uD84C\uDCDF\u4B44\uD855\uDD04\uD863\uDED1\u47F0\uD858\uDF64\u01C4\u1504\uCF5C\u2E05\uD859\uDFE9\uD843\uDE85\uD846\uDE59\uD848\uDEB1\uD86F\uDEB0\uD873\uDF43\uA8A6\uD842\uDF95\uD878\uDC79\uD877\uDF0F\u4BB8\u663B\uA245\u5299\uD86A\uDF07\uD843\uDF2E\uD84D\uDE1F\u35DD\u87DF\uD83C\uDDF1\u79E4\uD84B\uDD87\uD84D\uDFF4\uD84B\uDE31\uD81C\uDC5B\uD855\uDE1C\u9AA0\u88BB\uD862\uDE94\u6BB7\uD86D\uDDC5\uD869\uDE77\u2D0C\uD801\uDD52\u300A\uD81C\uDE01\uD841\uDE12\uD81F\uDCB2\uD86E\uDDF0\uD820\uDC27\uD860\uDF12\u3C58\uD855\uDDE0\uD870\uDD44\u66BA\uD811\uDE32\uD878\uDC7C\uFF6B\uD846\uDE61\u8F4A\uD84B\uDEC8\uD865\uDF83\u8623\u8499\uD873\uDF41\u62B7\u61AF\uCFD3\uD843\uDC92\u33E5\uD85E\uDFD3\uD86B\uDF32\uD85E\uDF40\u4F9E\uD81E\uDF0F\uD847\uDE10\u6D8F\uD85F\uDFA0\uD87E\uDD2C\u13B3\uB70F\uD872\uDE3C\uD802\uDEC8\uD806\uDCE4\uD848\uDE9D\uD800\uDF30\u83FC\u776A\u2402\uD843\uDF67\uD867\uDCA8\uB6F7\u833E\uD820\uDFCE\uD87A\uDCF6\uD835\uDEED\uB3A3\uBDDE\uD851\uDF4E\uD867\uDF7A\uD84C\uDCDA\uC01B\uD879\uDD17\uD84F\uDDF9\uD878\uDE74\uD855\uDFA0\u2608\u30AF\uD859\uDDAE\uD874\uDDC1\u659E\uD873\uDF8B\uBA09\uD873\uDE87\uD879\uDD29\uD874\uDDC4\uD875\uDC3E\uFBFC\u047C\u5C00\u3EED\u5613\u2D07\uFF8D\u8AC4\uD855\uDDFD\uA153\uB518\u2B91\uD809\uDC6C\uD86A\uDF17\uD1F9\uD802\uDC2C\uD861\uDDD3\uFF38\uD80C\uDD65\uB375\uBE10\uD869\uDF49\uD862\uDDB0\uD86B\uDD7F\uD85E\uDEEE\uD874\uDF44\uD85E\uDD38\u1929\uD872\uDF71\uD855\uDC49\uAD04\uCCED\u3FB7\u1B03\u55E8\uD84E\uDE46\uD853\uDEFB\uD870\uDE40\uD849\uDFAF\uD875\uDF65\u07AF\uD863\uDFE6\uD802\uDEEE\uB8D1\u49E8\uD82C\uDD17\u5104\uD875\uDDAC\uD86B\uDDF6\uD846\uDF30\uD86E\uDFAA\u7BDE\u581A\uD81A\uDDBA\u7017\u2A45\uAC50\uD86D\uDDED\u6562\uD81D\uDF9D\uD84D\uDEFE\u9F00\uA566\uFCEF\u4B7C\u511B\u1C71\u8B8C\uD870\uDD18\uD81F\uDCEB\uD836\uDD4A\u6483\uD869\uDC59\uD86E\uDFC7\uD80C\uDD92\uD510\uD85C\uDFED\uA048\uD862\uDF75\uD877\uDDBB\uD865\uDEA8\uD867\uDF02\uD841\uDDAB\uD86B\uDEB6\uB009\u69E6\uD81C\uDDBA\uD844\uDD69\uD840\uDF41\uD811\uDCB5\uD85E\uDF5B\u78EA\uD86E\uDC52\uD84E\uDE70\uD879\uDDBB\uD10D\uC930\uD769\uA0DE\uD860\uDD27\uD84A\uDC4A\uD834\uDDCF\u8BE6\uBEAD\uD81D\uDE16\uD820\uDF52\uD80C\uDFB1\uD84C\uDDD6\uD870\uDCAF\u8886\uA105\uD808\uDCA9\u6758\uD879\uDF66\u4595\uD86C\uDC68\uD841\uDEC7\uD862\uDD65\u8A16\u712F\uC730\u5A0F\uD864\uDD8E\uD857\uDD5C\uD864\uDEA1\uD878\uDE43\uD87A\uDE0F\uD82F\uDC16\uD870\uDC99\uD800\uDC2E\u738B\u137C\u4EEA\uD852\uDD2E\uD860\uDEF3\uD876\uDC6D\uD802\uDEE2\uD804\uDDC6\u37DB\u7AF4\uD86F\uDDBD\u64FF\u056A\uD84F\uDEE2\uC93E\uD869\uDED5\uD800\uDF3F\u7F4A\u6FB4\uD861\uDF0C\uD846\uDE2A\u6641\u47AA\uD81D\uDCFB\u7419\uD84A\uDE98\uD809\uDC53\u6310\uD845\uDDBF\uD873\uDD5F\uD844\uDDD7\u3595\uD841\uDD4B\uD840\uDCFC\uD846\uDC1E\uB128\uD85A\uDD04\u4B93\uD87A\uDEF0\uD86F\uDF70\uD854\uDE47\uD808\uDF2A\uD836\uDDD2\uD878\uDF70\uD2CC\u9BFB\uD873\uDF95\uD81C\uDD3E\u8AC6\uD85E\uDCE1\u5369\uD853\uDDEC\uD803\uDE6A\u3E63\u85BD\uD82F\uDC9E\u0519\uD879\uDDB1\uD855\uDE15\u6401\uD871\uDF39\uD841\uDC8A\uD857\uDC4D\uD872\uDCB6\u8911\uD822\uDE0F\uD811\uDDD0\uD866\uDF4C\uD870\uDF8C\uD83E\uDC09\uD834\uDE16\uD820\uDE23\u8990\u3C7B\uD872\uDD61\uD849\uDDA4\u1243\uD845\uDF31\uD856\uDDD5\u2AD4\uD81F\uDF0C\uFAB5\uD809\uDD3B\u03E1\uD821\uDC12\uFE85\u4FFF\u2A22\uD876\uDDB2\uD876\uDE9F\uD870\uDCEE\uD855\uDCEF\uD865\uDC96\uD84D\uDDD9\uD86A\uDD62\uD844\uDF86\u5516\uD82F\uDC55\uD847\uDF31\uD868\uDFA2\u4C1F\uCDDA\uD875\uDDCB\u67CD\u852B\uB616\uD808\uDD8B\uD85F\uDF91\uAEC4\uD82F\uDC02\u7BC1\uD879\uDEE9\uD843\uDFB5\uD804\uDE0E\uD85E\uDFFA\u4AE9\uD848\uDD51\uD808\uDF49\uD862\uDCAF\uD81A\uDDDB\uD856\uDFC6\u6603\uD87E\uDDF1\u3F71\uD841\uDD8D\uD868\uDE1B\uD853\uDF6A\uD83B\uDE2D\uD87A\uDE90\u66E1\uD84A\uDC05\uD843\uDDD7\uD876\uDD21\uD862\uDFB6\u7640\u0092\uD845\uDC11\uD840\uDDE6\uD85A\uDE06\uD845\uDEF1\u2DD8\uD850\uDDD5\u526C\u0943\uD871\uDFEA\u08E7\uC9A9\uA0C8\uD873\uDDFF\u7D06\uD873\uDD5D\u146E\uCE31\u52E9\uD858\uDE16\uD849\uDD24\uD875\uDED2\uD868\uDDF9\u3E00\uA98F\uD81D\uDC5A\uD847\uDD1D\u85EF\uD806\uDE1C\u35A4\uD836\uDEAF\uA899\u7138\uD836\uDD4C\uD874\uDC5E\u7192\u69C9\uB01C\u8B54\uD842\uDF3F\uD84A\uDED3\uD85A\uDD34\uD86E\uDF76\u0B5D\uD875\uDD2B\uD873\uDDEA\u41ED\uD863\uDFD8\u3EEE\uD83C\uDF45\uD87A\uDC68\uD850\uDCFC\u2C83\uD801\uDC3D\uD855\uDC8D\uD86A\uDD77\u9986\uD822\uDE91\u3D03\u8CD9\uCCDC\uD821\uDC34\uD87E\uDCD5\uD81E\uDCC2\uD802\uDF28\u6310\uB32E\uBCA1\uD865\uDF5A\u5143\uD843\uDD31\u9C49\uD873\uDE8C\u2797\uD85A\uDE34\uD872\uDF6E\uD879\uDD70\u3A54\uD854\uDE26\uD86B\uDF20\uD81F\uDE8B\uD865\uDDA2\u75E0\uD80C\uDC3B\uFE7E\u1D08\uD84D\uDCE1\u628F\u2C31\uCD70\uD847\uDF86\uD84D\uDDC5\uD85E\uDD0F\uD879\uDEF3\uD845\uDE3C\uD866\uDD3B\uD864\uDE41\u3E2E\uD81F\uDD9A\uD848\uDE7C\uD861\uDC48\u7D00\u3C08\u7A91\uD868\uDCE0\uD86B\uDD16\uD84F\uDF51\uD80C\uDECA\uD853\uDFB3\uD85A\uDEA0\uD862\uDC59\uD874\uDCAA\uD879\uDD71\uBE48\u3D8E\u79F4\uCA2A\u8043\u43E6\u557E\uD86C\uDD29\uD873\uDE86\uD875\uDE80\uD835\uDE2D\uD83E\uDC34\u2E91\uD870\uDEA1\uAF93\u3A90\u8941\u1A5B\uD845\uDC02\u81B8\uD852\uDFEF\uD84D\uDF43\uD859\uDF73\u0D37\uD81F\uDD2D\uA241\uAC55\uD85C\uDDBF\u6477\uD863\uDDB7\uD86D\uDEE2\uD85F\uDD26\uD84C\uDE0B\uD76F\u205B\u8B92\uD84E\uDFA6\u9228\u7966\uD86A\uDF5D\u2606\uD84F\uDD7F\uD858\uDD7A\u581B\uD874\uDCCD\uD804\uDECF\u64BD\uD863\uDD22\uD822\uDC21\u9C88\u689E\uD86E\uDC4E\u857D\u0E39\uD83D\uDFAD\uD81E\uDF9A\u6338\uD869\uDC89\uD85B\uDC4D\u4774\uD81A\uDF8D\uC736\uD85A\uDF4C\uD836\uDDEB\uD850\uDFFB\u4424\uD84F\uDEFC\uBDAC\uD821\uDE90\uD85E\uDF4D\uD81F\uDCEC\uD033\uD840\uDEAE\uD85A\uDD6C\uD86F\uDF76\uD864\uDF1A\uD857\uDCDF\uFA73\uD852\uDF73\u052F\u9525\uD809\uDD2F\uD83D\uDD10\uD845\uDC8D\uD84D\uDE9D\uD84F\uDC2C\uD858\uDC4B\uD84F\uDD0B\uD87A\uDF09\uD877\uDFE9\uD859\uDD15\uD801\uDC69\uD81D\uDEB7\uD86C\uDCDC\uD846\uDC75\uD846\uDD5E\u9EDA\u7E8F\uDB40\uDDBA\uD85E\uDD0D\uD84F\uDF82\u20ED\u4C5C\u8DDA\uD81C\uDEBC\uD847\uDE7B\uD842\uDD69\uD858\uDD6F\u8FFB\u3741\uD863\uDF98\uD850\uDE38\uD869\uDC6A\uD87A\uDFC5\uD849\uDEC1\u3992\u2287\uD870\uDFDA\uBE71\uD840\uDDF7\uD843\uDD27\u37AB\uD808\uDCD0\uD865\uDD04\uD853\uDE2A\uD849\uDC92\uD86A\uDCDA\uD866\uDF24\uD862\uDF41\uD853\uDF8A\u63E4\uD852\uDDC3\uD858\uDF5E\uD86C\uDEA5\uD84C\uDD12\uD86D\uDDB0\u6E21\uD804\uDD40\uD574\uD869\uDD4D\uD840\uDEE0\u83F1\uD85D\uDFFB\uD13B\u600D\uBC1B\u2C85\uD851\uDD41\u6AAD\u407C\u8F1F\uAC4B\uDB40\uDDC1\uD83D\uDCB0\uD848\uDEEF\uD861\uDC62\uD85A\uDCE8\uD86B\uDC85\uD873\uDFA4\u484B\u1524\uD878\uDD8C\uA9E7\u8171\u86A2\u97A5\u239B\uD835\uDFAF\u73BF\uD85A\uDE79\uC17B\uD85A\uDD66\uD85C\uDCA4\uD834\uDE0D\uD83D\uDF3E\u8755\u6193\uD862\uDC35\uD86B\uDE7B\u9D92\uD822\uDC7E\uD879\uDF43\uFE23\uD86E\uDE5C\uB917\uD86F\uDECC\uD846\uDDFB\uD81A\uDCA5\uA8D2\uD81B\uDF3F\uD81C\uDFAC\uCCF2\uD84B\uDF81\u8391\uD866\uDC69\uD875\uDCCC\uD1B9\uD860\uDDD9\uAB01\uFCBF\u4807\uD859\uDECB\uD86E\uDE24\u71E8\uD857\uDFBB\uD802\uDC68\uD843\uDD71\uD865\uDEC9\u1695\u9246\uD845\uDFED\uD83D\uDF6C\u053B\uD84C\uDFBC\uD86F\uDE33\u2526\uD83C\uDD93\uD874\uDD20\uD85E\uDF48\uD83C\uDF29\uD846\uDE9F\uD86D\uDF57\u91ED\uD87A\uDDA8\u86EC\uD851\uDD42\uD876\uDC27\uD873\uDE0F\u58A6\uD862\uDEC7\u7535\uD860\uDFFC\uB5E9\u3D34\u32F3\uD841\uDC92\uD66E\uD83C\uDF4C\uD855\uDD20\u7FE3\uD807\uDC58\uD822\uDE45\uD84D\uDDF1\uBD8F\u26F2\uD848\uDC35\uD81C\uDE37\uA8B7\uD84E\uDDA2\uD866\uDD60\u6433\uD86B\uDFBE\u431B\u43E5\uD861\uDC8A\uD849\uDCC0\uD862\uDCD5\uD844\uDFBA\uD864\uDEBD\uD804\uDEA5\uD878\uDD37\uD87A\uDFA1\uD856\uDCFF\u6EBC\uD87E\uDD2E\uD848\uDE9B\u55B8\uD846\uDCED\uD81C\uDC50\uD845\uDCEF\uD86F\uDD66\u0C08\uD801\uDEF6\uD85C\uDE64\uC6BE\uD84B\uDE9B\u3320\uD861\uDD3F\uD81F\uDFDC\uD859\uDD76\uD851\uDDE0\u261F\uD835\uDCF6\u3400\uD853\uDEBE\u9F4D\u311A\uFE0E\uD85E\uDFCD\uD865\uDD09\uD852\uDEB8\u070D\uD847\uDEF8\u04E1\u53FF\uD842\uDE64\u55C7\uD85A\uDE5F\uD87A\uDE0F\uD835\uDC1F\uD842\uDE63\u6F3D\u1744\uD847\uDE23\uD873\uDD5A\uA990\u04DE\uD835\uDCB3\uD840\uDE4E\u4E63\uD845\uDDA1\uD86F\uDCCF\uD842\uDF08\uD85B\uDE72\uD84B\uDFB6\uD846\uDE0F\uD809\uDD1B\uD855\uDF29\uD84A\uDCE9\uD864\uDC38\uD81B\uDF2F\uA2EF\uD853\uDCE5\u14BA\u9435\u7FD2\uD875\uDE7E\uD81A\uDC8E\uD80C\uDFF4\uD820\uDD92\u7967\uD841\uDEEE\u8393\u3C83\uD87E\uDC8A\u844A\uD84A\uDE53\u4895\uA34E\uD861\uDD73\uD854\uDE98\u88DC\u1BA4\u7B6B\u0F1C\uD873\uDDD7\uD86C\uDF01\uD168\uAD5A\uD876\uDF29\uD860\uDC40\uD80C\uDF2C\uD879\uDEB5\uD801\uDEF0\u0544\u8421\uD83D\uDF08\uD857\uDF51\uD867\uDD9D\uD867\uDFB1\uB404\u4724\uB132\u081E\uD86A\uDE8D\u3597\u9DDA\u751E\uC58C\uB2E7\uD875\uDD65\uD82C\uDCF0\uD842\uDEE6\uD81A\uDE64\uD848\uDF86\u9AA0\u87A1\uD866\uDFD4\uD855\uDD29\uD871\uDDD0\u45C1\u8AE2\uD851\uDF00\u62DE\uD855\uDDEE\uD84A\uDC9C\uD84D\uDFCB\uD805\uDE58\uD820\uDEED\uD846\uDE6E\u7B32\uD878\uDEE2\uD84A\uDC92\uD808\uDE7C\uD84E\uDE47\uD805\uDCB8\uCD1B\uD804\uDD1E\uD803\uDCA3\uD808\uDF1F\uD846\uDD23\uD860\uDF17\uD84C\uDE52\u9C27\u2C53\uD85D\uDF8E\u8A55\uD800\uDE96\uD85A\uDF07\u7E8F\uD858\uDD45\uD85F\uDF5A\uD854\uDD14\u5C1D\uD861\uDFE6\uD842\uDCC2\uD81A\uDD70\u8128\uD86D\uDEFE\uD86D\uDDC4\uD844\uDFB7\uD862\uDF98\uD85C\uDF21\uD86D\uDF9B\u0DA5\uAF3F\u966B\uD81C\uDD62\uD83E\uDC83\uD86B\uDFAF\u9CF6\uD85F\uDE74\u47E9\u25FE\uD864\uDC07\u3108\u7467\uD85C\uDE1B\uD83D\uDC9B\u83F6\u37DE\u12F9\uD851\uDC05\uD83C\uDCA6\uA223\u344A\u6039\u2361\uD855\uDC44\u1439\u4758\uC9FD\u7820\uD855\uDE93\u3D7D\u5F8A\uD83C\uDE13\uA643\uD85F\uDC9D\u8550\uD85A\uDC95\uD835\uDEC8\u541E\uD802\uDC0F\uAF1B\uCFEC\uD855\uDEE5\uA0EB\uD821\uDC2C\u406F\u5739\uD87E\uDC91\u23DE\uD870\uDF41\uD110\u516F\uD843\uDC58\uD846\uDD53\uD81C\uDD62\uD834\uDF19\uD835\uDD7E\uD808\uDEEC\uD86D\uDD72\uD878\uDC92\u8273\uD85B\uDE1C\uD867\uDC1E\u2B1D\uD870\uDC31\uD851\uDF1F\uD85E\uDF7C\u324B\uCC6C\uD85E\uDE44\u7F85\uD841\uDFD8\uD851\uDC71\uD879\uDEDC\uD87A\uDF05\uD801\uDE5F\uD864\uDF5B\u119E\u29B3\uD844\uDED6\uD85B\uDE57\u853D\uD856\uDFD0\u486F\uD869\uDC7C\uD875\uDD73\uD87A\uDDED\uCE3C\uD81A\uDC28\uC85E\uD85C\uDDFC\u373C\uC174\uD868\uDE6D\uD83D\uDE28\uD808\uDC39\u74B1\u6BE3\uDB40\uDDC3\u8907\u4DF5\uD860\uDEB6\uD864\uDFAB\u0F55\uD84B\uDE0A\u1851\uD81D\uDDDB\uD850\uDD7E\uFF71\uD82C\uDE36\uD859\uDE0F\uD841\uDE0D\uD83C\uDFB4\uD848\uDF8B\u3476\uD80C\uDF25\uD7E9\uD85B\uDC9B\uD80C\uDC78\u38A0\uD84D\uDE0F\uD871\uDC10\uD86F\uDEAA\uD802\uDD98\uD84A\uDFF2\uD822\uDDC2\uB39B\u301D\uD84D\uDDF5\uBDC5\uD865\uDD56\uD879\uDE8E\uD855\uDE0A\uD821\uDC85\uC25D\uD84F\uDDA6\uD841\uDC52\uD878\uDCD5\u4C4F\u2E9C\uD821\uDC6C\u6083\u719F\uD84C\uDC47\u8D6B\uD86B\uDFE8\u056E\u4703\u60EF\uFF21\uFE9C\u6B09\uD85A\uDD10\uD213\uDB40\uDD23\uFBF8\uD86D\uDC88\uD854\uDD1E\u8FB2\uD841\uDFA0\uD857\uDFFE\u9618\uD870\uDF1C\uB3B7\uD861\uDEF6\uD854\uDCC6\uA2B8\uB8D1\u95E1\uD84E\uDEE8\u3253\uA58D\uD851\uDDF4\u6C4C\u9963\uD86D\uDF98\uD868\uDF4F\uD820\uDC90\uD86D\uDCC1\u6ACC\uD804\uDC28\uD877\uDC66\uD86A\uDD7B\uD876\uDEA4\u3E90\uD80D\uDC01\uDB40\uDD2A\u8813\uD850\uDCC0\u02D5\uD1F9\uD86D\uDFFF\uD850\uDFF7\uD81C\uDC5B\u97C6\u6C4E\uD83D\uDF6D\uD848\uDF70\u75DA\uD861\uDE21\uD841\uDEB4\uD84A\uDF01\uD842\uDE83\uB65E\u8367\u22E8\u5885\uD82C\uDC27\uCC2E\uD85C\uDD77\uD858\uDCD0\uD876\uDFF0\u0670\u4ED4\uBF9C\uD83E\uDD48\uD841\uDDA2\uD674\u9A95\uD862\uDFF0\uAEFD\uAE14\uC0C5\uD84C\uDC39\uD850\uDD59\u4FEB\u5F87\u825C\uD845\uDF61\u5B57\uB83F\uD82C\uDCF8\uD84D\uDF12\uD86A\uDCB9\uD854\uDC37\uD849\uDEC3\uD848\uDD2D\u7CD9\uD877\uDEE7\uAF4A\uD81E\uDFC3\uD87A\uDF33\u90AC\uD85E\uDC14\uD836\uDD7A\uD81A\uDF10\uD867\uDD36\uD844\uDCDD\u2467\u39E9\uD85D\uDC7C\u9721\u88E6\uDB40\uDC3B\uD860\uDFAD\uA375\uD83B\uDE8C\uBB67\uD873\uDEF3\u4571\uD864\uDFEF\uC9B5\uAF14\uD86D\uDD8F\u7799\uB5C2\uD81C\uDCD9\uD872\uDE5D\uD82C\uDC33\uD86B\uDD40\u5D1D\uBE98\uD811\uDC4E\u4E84\uD87A\uDFCF\uD809\uDD30\uAD3D\u3A83\u643D\u4521\uD86E\uDF23\u3665\uD873\uDD9C\u2DED\uD820\uDF5F\uD3B2\uD862\uDD37\u0269\uBB59\u0AB2\uCB7A\uD847\uDD0C\u5BD3\uD83D\uDD32\u29A4\uD836\uDC84\u6F08\u4E4A\uD878\uDDE8\uD876\uDC69\u16F6\u21E9\uD804\uDC5C\u4265\u5C3F\uD851\uDCE7\u547B\uD848\uDDE7\uD855\uDF7F\uD865\uDC73\uD85A\uDEAA\uD849\uDFA4\uD84F\uDE0B\uD861\uDF83\uD87A\uDF2A\u0989\uD875\uDE78\u9E1E\uD841\uDC45\u832F\uD836\uDDB7\uD85A\uDF6A\u70EE\uD81E\uDFCA\uD85F\uDFED\u07C3\uD85F\uDDE7\u9312\uD807\uDC10\uD86F\uDE41\uC88A\uD86E\uDD85\uD811\uDD10\u0CC2\u96E9\uD85A\uDC52\u8A9C\uD879\uDE7B\uD80C\uDCFC\uD85F\uDEB9\uD851\uDC50\uD85D\uDFAA\u4780\uD835\uDC8C\uD866\uDC10\uD868\uDD03\u0352\uC2C0\u7CF7\uADF9\uD82C\uDC78\uD86C\uDE47\uD848\uDF7E\uD855\uDC11\uD846\uDD34\u7A5E\uD875\uDD13\uD84A\uDC0B\uD2CA\uD835\uDC5A\uD81F\uDDD1\uD84B\uDE88\u7036\uABCA\uD865\uDF42\uC62B\uC51C\uD84B\uDEF2\u8136\u3C33\uD871\uDC9B\uAEA1\u7784\u5CB1\uD81D\uDF0E\u4971\uD849\uDCDE\uD86D\uDE44\uD85B\uDC86\uD847\uDD0C\u17E2\uD85A\uDC55\uD84C\uDED6\u9649\u62A9\u33E2\uD836\uDE4D\uD44B\u7BFE\uD836\uDE79\u5C62\uD865\uDE86\uD858\uDDE0\uD80C\uDF54\uD84F\uDEA1\uD843\uDDEF\uD803\uDC44\u2123\uD87A\uDF55\uD877\uDDBA\u5CD9\uD834\uDCA8\uD836\uDCBB\uD808\uDC65\u16DC\u1F6F\uD87E\uDC12\u4CF3\uD81D\uDD46\uD821\uDCB6\uD86E\uDF12\uA539\u0837\u76F4\u1108\u95D1\u34CD\uD805\uDEB4\u3766\u03BE\uD81C\uDCBB\u8312\uD865\uDC63\u4A8B\uD804\uDE36\uD820\uDF88\uB3EB\u9584\uD86F\uDD25\uD843\uDCBB\uDB40\uDC4A\u3B3E\uD84D\uDC6B\uFA9F\u15E2\uC5B8\uD856\uDC76\uD867\uDE6E\u9CC0\u7353\uD855\uDC47\uD854\uDFFC\uD873\uDF6B\uD855\uDD49\uD85F\uDC00\u8B73\uD841\uDDA5\u9238\u589C\uD855\uDEB7\uD85E\uDD4A\uD846\uDD96\uD808\uDE62\uD82C\uDCF9\uD81E\uDD7C\u6940\uD844\uDECC\uD861\uDEE4\uD87A\uDE04\u5D8B\uD84F\uDC28\u1BB5\uD846\uDFF7\u8D69\uD860\uDEBB\uD85D\uDF3A\uD85A\uDE53\uD864\uDD95\uD85F\uDFA3\uC23F\u4739\uD878\uDEF7\uD800\uDE88\uD86B\uDDDC\uD840\uDE18\uD854\uDFA1\u654D\uD842\uDDD6\uD834\uDF55\uD821\uDD33\uB3B4\uD85B\uDEC5\u7A0A\uD862\uDDC3\uD876\uDDF9\uD875\uDE5F\uD877\uDC9E\uD804\uDC9C\uD878\uDFA2\u7BB7\uA576\uD848\uDECB\uD81C\uDF99\uD85C\uDFAB\uD835\uDC26\uD84E\uDD0A\uD860\uDE5A\u82BB\uD857\uDEEB\uD81F\uDF05\u0974\uD860\uDD22\uD876\uDD43\uD84A\uDC59\uD834\uDDB0\uD81F\uDE09\uD87A\uDC53\uD875\uDE1F\u28B6\uD870\uDFEB\uD840\uDEAC\uAFA6\uD83D\uDEBB\u082A\u8A68\u3517\u6411\uD856\uDDA4\uD86F\uDE37\uD855\uDD82\uD854\uDEA4\uD835\uDC02\uD871\uDE24\uD821\uDC4B\uD81C\uDEBE\uD862\uDE5E\u56BE\u89CC\uBF6F\uD877\uDF59\uD81E\uDE8C\u9FC2\uD808\uDDB3\uD874\uDE47\u169A\uD875\uDCCB\uC1C9\uD835\uDEBF\u67C0\u676B\uD836\uDDA4\uD841\uDC14\u8569\uD81C\uDEEB\uD856\uDCE6\uD85C\uDEFA\u88ED\uD847\uDD12\uD873\uDED8\uD850\uDC14\u61FD\uD85B\uDF1B\u5C37\uB979\uD840\uDC78\uD860\uDE41\uD84E\uDFE8\uD808\uDC9B\u47CC\u7928\uD86A\uDFDC\uD850\uDE9B\uD821\uDC95\uD811\uDD42\uD863\uDE15\u73B4\uD852\uDCB6\u1662\u96CB\uD866\uDCF7\uD84E\uDE0C\uD83E\uDD8E\u68D6\uD85A\uDCD3\u4F31\u8A54\uD821\uDED2\uD871\uDF20\u5EEA\u4DF6\uD859\uDE43\u2C7D\u2166\uD868\uDD22\uD835\uDE6B\u0854\uD85E\uDE0F\uD84B\uDEB9\uD871\uDD60\uD848\uDFDD\u961B\uCD9C\uD86C\uDD57\u8491\u13A6\uD807\uDD1C\u25A9\u22A6\uD84E\uDCC1\uD81C\uDE8F\uD821\uDF0F\uD85F\uDCDE\u2E1A\u23EB\uD85F\uDD24\uD81D\uDDA5\uD84C\uDFE3\uC41D\u76C7\uD85E\uDF8C\uD81D\uDED0\uD864\uDE62\uD861\uDF24\uAC4F\uD872\uDCC7\uD846\uDEBD\uD84E\uDD6C\uD5CC\uD836\uDC01\uD847\uDEB7\uD841\uDFEF\uD84C\uDD80\uD809\uDCD9\u6DA0\uD869\uDC90\u0331\uD7E1\u9720\u193B\uD879\uDF8C\uD857\uDF72\uD848\uDEF1\uD834\uDD16\u04B6\uBE33\uD878\uDF6C\uD843\uDEE6\uD808\uDF5B\uD855\uDC4A\u118A\uD85C\uDD92\uFAC0\uD851\uDDA1\u1C7D\u80D8\uC33E\u6C68\uD876\uDE42\u40DE\uD84B\uDC9A\uD855\uDCE3\uD844\uDEF6\uD873\uDFF2\u8F62\uD84E\uDD59\u43EC\uD841\uDFCB\uD851\uDE76\u6F27\uD857\uDD2B\u9821\uD836\uDE3C\uD820\uDE02\u45CE\u2B81\u07AB\uD84D\uDC6A\uD81C\uDFF4\u6F91\u11CD\uD808\uDD6C\uD849\uDE1C\uD843\uDCDA\u5180\uD86B\uDF40\uD862\uDD1C\uD808\uDF04\uD852\uDD14\uD872\uDDF2\uD848\uDC43\u42E1\u0607\uD81D\uDC18\uD83A\uDC04\uD855\uDF29\uD820\uDED0\uD85E\uDE13\uA78E\uD860\uDC73\uD806\uDE5A\uD864\uDEA2\uD848\uDECF\uB6A7\uD860\uDD12\uA8A9\u29A6\u638A\uD867\uDCAB\uD874\uDCD0\uD85D\uDF05\uD81D\uDD2B\uD843\uDEA9\uCCEF\u636A\u8445\u5B47\uD869\uDE66\uD84C\uDE79\uD86D\uDDEF\uD870\uDFEE\u30DB\uD86E\uDD53\uD85B\uDCA1\uD868\uDE4C\uAB24\uD852\uDD5B\uD851\uDE0D\u7525\uD84E\uDF26\uD86E\uDD8C\u1D38\uD85F\uDF73\uD86E\uDD63\uD866\uDEFA\u3E6F\u1D19\uD843\uDF8C\uFF88\u205D\u858A\uD85A\uDEAC\uD86F\uDC19\uD874\uDE7B\uD879\uDE47\uB1A3\u878B\uD85D\uDCF2\uD862\uDCCF\uD876\uDDD2\uD847\uDCF0\uD845\uDF22\u909E\uD84D\uDDA9\uD859\uDDDA\uADD5\u4B9E\uBB44\u745F\u67A9\uD803\uDC96\u7A4D\uD85E\uDE46\uD87A\uDC6B\uB69E\uCF38\uD851\uDF62\uD801\uDF50\uD82F\uDC77\u8932\uD875\uDE7B\u1610\uD52A\u6A51\uD5B4\uD852\uDD63\u0C60\uD86D\uDCD8\uD822\uDE11\uC621\uD802\uDCFD\u8D9E\u98B4\u6F03\uD849\uDCFB\u1B30\u1922\uD846\uDFF9\uD855\uDF14\uDB40\uDD16\uD867\uDC1D\uD848\uDD5E\uD850\uDC3D\u3E97\uD840\uDE88\uD86C\uDC85\u485C\u8A3C\u5C45\uBBDE\uAA41\u6AEA\uD855\uDF96\u8381\uD84F\uDFC0\u7381\uD81C\uDF14\uD86B\uDF20\u1985\uD879\uDCAA\uD80C\uDD5A\uD872\uDC36\uA3CD\u7499\uD85D\uDCF2\u18F4\uD857\uDDB7\uD851\uDDE6\uD834\uDDD0\u3372\uD877\uDCFE\u9DF7\u6C12\uD851\uDC70\uA53D\uBC94\u5449\uD849\uDFE4\u8D71\u23A7\uD85E\uDE58\\u000b\uD85E\uDFCE\uD2DA\uD859\uDCF0\uD873\uDC08\u9C20\u2AB6\u7488\uD879\uDE04\u3487\uD86E\uDFD2\u80DA\u445F\uD801\uDCDF\uD84D\uDD95\u6683\u91C8\uD841\uDD28\uD81D\uDE3B\uD821\uDC21\uD853\uDF02\uA9AC\uD81C\uDDE7\uD846\uDD74\uD854\uDD56\uD802\uDC35\uD846\uDF52\u4DA1\uD875\uDCF7\uCCE7\uD854\uDD16\uD869\uDC44\u4E6C\uD85D\uDC13\uD873\uDCBC\uD852\uDF11\uD809\uDC8A\u09A1\uD834\uDC56\uD840\uDE4C\uD878\uDCE6\u600F\uD85D\uDF5A\u59A5\uA8B1\uD86F\uDC4A\u63A8\uD86C\uDEE0\u9DFD\uD86D\uDC26\uD81F\uDCA5\uD84D\uDC97\uD86D\uDC55\u3A08\uD81C\uDC58\uD821\uDCBB\uD800\uDEB4\u28E6\uD860\uDF98\uD801\uDCE5\u7E28\uD864\uDFF4\uD821\uDC39\uD81E\uDCD8\uD81A\uDCE2\uC7B9\uD83C\uDF26\uD847\uDDCB\uD84D\uDE60\uD811\uDE38\u7532\uD81A\uDC16\u5513\u30F6\u3C8E\uC116\uD811\uDDA9\uD846\uDEC2\u52F4\u5C1C\uD80C\uDDC9\u10A6\uCABD\uD844\uDFE3\u094C\uD863\uDF3C\uD84D\uDD9D\uD821\uDDF6\uD855\uDCBC\uD822\uDC12\u70E0\uD859\uDC5F\u623A\uD877\uDF0F\uD85D\uDD75\uD866\uDFE2\uD86A\uDF1C\u4D12\uD822\uDC6F\u81E1\uD877\uDC3A\u4B42\uD872\uDC66\uD87A\uDCD9\uD850\uDF84\uD878\uDE8B\u2941\u2F62\u8A09\uD81C\uDC5C\uD836\uDE88\uD84F\uDE7D\u5152\u188A\uD80C\uDF17\uD82C\uDDEF\uD86A\uDE2D\uD868\uDCDC\uD81F\uDF1A\u9249\u8D10\uFE44\uD858\uDEFF\uD850\uDEBA\uD853\uDE41\uA863\uD803\uDE72\uD879\uDCF5\u050E\uD846\uDD5F\u5124\uA2C5\uD86E\uDED3\u6FED\uD820\uDF3E\uA502\uD846\uDE7C\uD453\uD866\uDE6E\u05DE\uD855\uDD5F\uD848\uDD52\uD84C\uDF2B\uD846\uDE2D\uD863\uDCAC\uD869\uDE0B\uD86E\uDC67\uD821\uDE37\uD842\uDC2A\uD802\uDE65\uD862\uDC66\u919D\uD83C\uDC43\u6A39\uD866\uDCCC\uB78D\u5F30\uD874\uDE53\u29DB\u3432\uC9BA\uD83A\uDC90\uD821\uDDA7\uD851\uDEF4\uF9C3\uD85F\uDE34\uD81E\uDEC3\uD866\uDCCE\uD835\uDD9E\uD87A\uDE2D\uD86C\uDD15\u0449\u8128\uD86A\uDC4F\uD874\uDD3E\uD84C\uDED9\uD85C\uDFEC\uD851\uDE65\uD856\uDEA9\uD802\uDDA6\uD85B\uDFC3\u5E4F\uD835\uDCD7\uD86E\uDE7A\uD84B\uDC33\uD3F9\uD878\uDC56\uD846\uDFE7\uD867\uDF70\u4325\u3CE0\u95F3\uA6A1\u690B\u012A\uD81A\uDC11\u55E8\uD850\uDC04\u8321\uD879\uDE6D\u58D0\uD853\uDD57\uFDF5\u76E3\uD805\uDE8A\uD81C\uDCBF\uD81B\uDF71\uD849\uDDDA\uD866\uDD72\uD869\uDF7A\uD83C\uDC5C\uD867\uDD0B\u2CF1\uD84D\uDD0E\u94E4\u67AE\uD876\uDF2A\uD85F\uDEEE\uD834\uDC31\uCDFD\uD811\uDDFA\uD85B\uDD2C\uD822\uDE71\uD81F\uDE1A\uD83D\uDDCC\u03E2\uD84F\uDEB5\uD86A\uDEE9\uD2D7\uD873\uDD0E\u24F3\uD871\uDC53\uA93B\uD857\uDD08\uD859\uDEF7\uD822\uDD0B\uD869\uDCCF\u870F\uD86E\uDCD7\u2C00\uD85E\uDF01\uD866\uDC8E\u749B\uD85C\uDF13\uD868\uDD84\uD865\uDFF4\uD87E\uDD9B\uD846\uDDF9\u99E7\uC088\uD85D\uDFD8\u875E\uD855\uDD33\uD806\uDCB0\uD856\uDD87\uC92D\uD85F\uDEA2\u88EB\uD85B\uDCF3\uD863\uDE3F\uD809\uDC89\uD81F\uDDCC\uD859\uDF39\u24E3\uCF70\uD83D\uDC45\uD86F\uDF21\uD821\uDD2C\u4C90\uD83D\uDFCB\uD845\uDED7\uD806\uDE7B\uD84D\uDFDA\u11FC\uD850\uDEB9\uD84C\uDE00\uAB65\u6519\uD841\uDF8F\u28F1\uB12D\uD859\uDF5D\uD863\uDF90\\\"\uD85B\uDD13\uD858\uDF26\u5964\u8F03\u3A46\uD806\uDED1\uD804\uDD76\uD878\uDF66\uD81F\uDD6B\uD878\uDE2C\uD81C\uDD81\uD874\uDC8A\uD862\uDE29\uD800\uDEB4\u0CCD\u547C\uD844\uDDA8\uA88E\uD81C\uDD12\uD81A\uDDD9\uD834\uDF31\u46CE\u6A78\uD841\uDF71\uD86C\uDD02\u3FFC\u9752\uD868\uDEEA\u673C\uD86D\uDC48\u4863\uD857\uDDF9\uD800\uDEC2\uD861\uDF1E\uD81F\uDE93\u9E97\u5EE0\uD84C\uDC52\uC500\u076C\u9381\uD83C\uDC24\uD840\uDFDD\uD840\uDD29\uD855\uDC74\uD859\uDF2E\uD86A\uDE25\u58FE\u4AEC\uD844\uDE6E\u2625\uFD33\u1617\u701C\uD871\uDD2B\uD840\uDE3B\uD861\uDF43\uD86E\uDDFF\uD80C\uDF66\uD85F\uDC58\uD879\uDF62\uD865\uDF9C\uD84C\uDCB6\uD840\uDF3E\u85DC\u2CE2\uD84B\uDD5F\u1EB0\uD86F\uDF9F\uD840\uDD14\uD859\uDD10\u6092\u4CE5\uD850\uDFD2\uD87A\uDFAC\uD84E\uDD3E\u384B\uD81E\uDF1E\u585C\uD873\uDE74\uD83D\uDEC6\uC989\uD860\uDE84\u72ED\uD85D\uDFAD\uD86C\uDFE9\uD81E\uDD2C\uD859\uDCAD\uD840\uDC8E\uD868\uDC17\uD875\uDFD3\uD801\uDD16\u3236\uD844\uDF96\uD874\uDF3B\uC7F3\uCF3C\u6870\u04FD\u2535\uD86C\uDE22\u91C5\uD811\uDD0C\uD86A\uDF1D\uD866\uDD85\uD862\uDD8A\uD860\uDF22\uD83C\uDCA1\uD84C\uDFBD\uD842\uDF84\uD874\uDCBD\uD861\uDE8A\uD85F\uDC97\u5F0C\uD845\uDFE1\uB8A7\u7721\u43BF\uD84E\uDF93\uD867\uDF26\uD854\uDCD9\uD852\uDFFF\uD86D\uDC11\uD879\uDEE9\u950E\uD866\uDE28\uD863\uDF13\u67F4\uD81F\uDF5A\uD85E\uDC8E\u0446\uD868\uDC39\uD81F\uDDE7\u9FAD\uD820\uDC40\uB55E\uD859\uDC6C\uD848\uDECD\uABB1\uD860\uDCBD\u80B6\u9028\u6611\uD844\uDCE1\uD86E\uDCB8\uD81F\uDE1B\uD85C\uDCC2\uD844\uDE78\uD858\uDF2E\uD847\uDF92\uD805\uDC45\uD84C\uDD34\u73D6\uAF33\uDB40\uDD2E\uD877\uDECE\u8B16\uD85D\uDD55\uD878\uDF4A\u4954\uD86D\uDD5B\uD870\uDD63\u29FE\uAFFB\uFE87\u7187\uA0F6\uD807\uDC3C\uD854\uDE4F\uD86D\uDCBC\uD846\uDF9A\uD853\uDF06\uD81F\uDE23\uD842\uDF24\uD821\uDED0\uCF54\uD85F\uDDA1\uD864\uDFC7\uD879\uDD4F\uAD88\uD859\uDEA7\uD861\uDFC9\uD3C5\uD864\uDD81\u3811\u02A8\uD848\uDD8D\uD834\uDD54\uD86F\uDD20\uD868\uDE95\uD83D\uDD7D\u388B\uC799\uD86E\uDC18\u5780\uD84F\uDF5C\u6557\uD84B\uDEA8\uD845\uDE8F\uD856\uDF31\uD865\uDD07\uD84E\uDD7D\uD83E\uDD83\uD856\uDD35\uD857\uDEBA\uD845\uDF42\u68DB\uD850\uDC1D\uCCC0\u996A\uD862\uDC2E\uD861\uDF3A\u288D\uD81F\uDEC7\uD820\uDC94\uD852\uDDD3\uD84A\uDDD2\u6E1A\u9973\uD844\uDC4D\uD81E\uDEA3\uD84D\uDEBC\uD843\uDC64\uD858\uDEB0\uD86E\uDD95\uD81E\uDD63\uD85D\uDCDB\uD821\uDC99\uD87E\uDC6D\uD852\uDEDD\uA7FC\uD85A\uDF0A\u42A9\uD84E\uDE37\uD81E\uDCDB\uD868\uDF71\uD820\uDC1D\uD850\uDDD4\u083D\u5069\uB72A\uAD32\uD83C\uDC62\uD83C\uDF37\uC809\uAE51\u5BEE\uD874\uDEA9\uD874\uDC88\uD842\uDF8C\uA14E\u583F\uD85B\uDF78\uD86B\uDFCE\uD86D\uDC96\u2910\uD864\uDFB6\uD861\uDDDB\uC9D3\uD821\uDFE6\uD85A\uDDF0\u58DA\uD866\uDFF5\uD846\uDC7C\uBCBB\uD875\uDF3B\uD862\uDFDC\uD811\uDCBA\uC649\uD859\uDE21\uD867\uDC6B\uD874\uDC99\u1123\uD840\uDF87\uA6B5\uD868\uDE1D\uD84E\uDCC1\u371C\uD802\uDEE4\u9DDC\uD875\uDD50\uD86E\uDFE4\u94BB\uD821\uDE4F\uD86F\uDE77\uD84A\uDE25\uAB56\u4907\uB212\uD81A\uDF2E\u1DF8\uD86D\uDD5D\uD80C\uDE84\uB594\uD80C\uDF76\uD866\uDD85\uD805\uDCA9\uD840\uDCF5\uD862\uDC6C\uD85F\uDD6C\u8A0B\uD874\uDC24\uB94E\uD81C\uDC20\u5E1C\uD844\uDF81\uD80C\uDC3E\uD844\uDFFC\uD81D\uDE1B\uD866\uDCF4\u80D4\uD853\uDD24\u17C5\uD808\uDE0B\uFBA2\u67F3\u8D19\u9C3E\u66BE\uD87E\uDD65\uB122\uD874\uDE31\u6B21\uD851\uDF1E\uD849\uDD03\u8033\uD81F\uDF03\uD875\uDD29\uD820\uDC07\uD86E\uDD67\u9108\uD83D\uDF65\uD803\uDC0F\uD82C\uDC4A\uDB40\uDD24\uD800\uDCED\u4F14\uD869\uDD2D\uAA12\uD842\uDE86\u1FD2\u98EE\u20A5\uD82C\uDCCB\uC926\uD850\uDD99\uD851\uDE9F\uD860\uDC07\uD868\uDE9D\uD451\u7282\uD86C\uDD47\uD870\uDF45\uDB40\uDD60\u4D27\u4D57\uD876\uDCA4\u8D1D\u6C1C\uD844\uDC66\uD86C\uDD06\u9E6B\u4AB3\uAE21\uD81A\uDF2A\u7CD7\uD854\uDEA6\u05C7\u664A\u4017\uD857\uDC2E\u7551\u0719\uA0F5\u8FD8\uBE6A\u4823\uD879\uDD9E\u16E5\uD862\uDCC5\uD844\uDED3\uA598\uD855\uDD14\uAE3E\uD873\uDFAC\uD865\uDFF0\uD850\uDC83\u9E5E\uD870\uDE74\uD59D\uD83E\uDC6D\u09DD\uD808\uDDD1\uD840\uDF63\uD870\uDF7E\uD875\uDF1A\uD877\uDFE1\uD876\uDF0F\uD802\uDC6B\uD864\uDC4C\uD854\uDFD7\uCF96\uCA8C\u761E\uD872\uDC89\u6F87\uD83A\uDCA9\u5858\uD822\uDCAF\uD821\uDEB6\uD840\uDEAB\uD821\uDC20\uD85D\uDECC\uD867\uDC59\u5F4F\uD84A\uDDB8\uD85D\uDD77\uD801\uDC01\uD86E\uDFFD\uD84E\uDCEC\uD854\uDD61\u30CA\u29B3\uD872\uDC7B\uC52F\uC7C7\u5ED1\uD863\uDF8B\uD855\uDF03\u1B73\uD86B\uDF49\uD862\uDF33\u208E\u1E85\u0F86\u7B67\uD855\uDD03\uD873\uDC8F\u1F8A\uD870\uDC93\u188E\uD859\uDDA4\uD864\uDFFA\u3AED\u1492\uD860\uDE7D\uAD2A\uD835\uDCC5\uD854\uDEA2\uD870\uDF26\u84FD\uAE7B\uD86F\uDC29\u8DA5\u98B0\uD86A\uDCA3\uD81E\uDE65\uCB51\uD85D\uDDD9\u880E\uD867\uDF74\uD873\uDD54\u9F37\uD81F\uDE39\uD845\uDD3D\uFD02\u8FFE\uD847\uDEF2\uD86D\uDE99\uD820\uDD79\uB0EB\uD809\uDCA3\uB6AB\uD805\uDE2B\uD857\uDF8E\uC1C2\uD847\uDE03\u7E09\uA6AB\uD3EE\u32FF\uD874\uDD13\uD835\uDEE1\uD85E\uDC13\uD835\uDD95\uD869\uDC00\u1084\u521E\u5723\u1936\uB338\uD841\uDF8D\u7F49\uD85D\uDC9B\uD877\uDFA8\uD86B\uDD54\uD87A\uDE9C\u6FA5\uD800\uDE94\uD84A\uDF2F\uD851\uDE47\u6BC1\u2713\uD852\uDC0C\uD6DB\uD86B\uDC7D\u812B\uD80C\uDE68\uD85C\uDF8C\uB313\u0F65\uD820\uDEBF\uD822\uDD80\u8C5C\u527D\uD865\uDEF9\u9B09\uD842\uDD96\uD86C\uDCDA\uAD6E\uD84C\uDEAE\uCE9F\uD874\uDC18\u4B30\uD853\uDF10\uD861\uDEA4\uD879\uDCBF\uD859\uDD75\uD86F\uDE14\uD81D\uDE26\u914E\uD820\uDF50\uD84B\uDEDC\uD85E\uDD76\uD856\uDD6B\uC144\uD843\uDE6E\uD83A\uDD2A\u4E93\u27CE\uD862\uDCF5\u54CF\uD879\uDFC8\uD808\uDE24\u3EE4\uFD94\uD86D\uDCA1\uD87E\uDCC7\u791A\u9DF7\u897C\uB85D\uD801\uDC9C\uD84A\uDD48\uD801\uDF20\u7ABC\uD83D\uDC99\uA9F0\u2366\u8E95\uD857\uDF9E\u07DF\uD872\uDD6D\uD854\uDCEF\uD848\uDF2A\u6E7D\uB99D\uD843\uDD20\u9275\uD867\uDE47\uD857\uDCE8\uD820\uDDAB\uC7BD\uFB95\uD81A\uDE50\u37A9\uD82C\uDCC3\u7DE6\u4E23\uD844\uDE6D\uD85C\uDC58\uD802\uDDCE\u8454\uD843\uDDFD\uD840\uDD2F\uD854\uDF77\uD853\uDFA9\uD876\uDF77\u103B\uBC9E\uD82C\uDECD\uD861\uDE96\uD853\uDF48\uD864\uDEAF\u0C30\uD85F\uDC52\uA195\u580F\uD873\uDC63\uD849\uDF9F\u1F9E\uD878\uDF45\u585D\u12FE\uD800\uDD19\uD860\uDC32\uD82C\uDCBB\u33C0\u4D7A\uD84D\uDF4B\u250F\uD857\uDD21\u2CA9\uD81C\uDD5B\uD877\uDD53\u2BA0\uC921\uD85D\uDF79\uD847\uDFBD\u9CA7\uD83C\uDF5B\uD868\uDF75\u3872\uD85E\uDEF2\u6847\uB526\uD849\uDCDC\uD869\uDC1D\u2EBC\uD876\uDEF2\uD868\uDDED\uB5E8\uD848\uDCAC\uD860\uDCF8\uBC8E\u6CAA\u2236\uD869\uDDF7\u21AC\uD801\uDE4B\uBFA5\uD877\uDDA9\uD851\uDDEE\uD85F\uDF33\uD848\uDC8F\uD864\uDCD6\uFF64\uD85E\uDE87\uD856\uDE04\u484C\u3170\uD843\uDDE8\u5A8D\u7EB1\u3F78\uD80C\uDD00\uD801\uDD56\uD857\uDF76\uD874\uDD2E\uD852\uDF28\uD86B\uDFA2\uD808\uDDB5\uB323\uA0C2\uD847\uDD6C\u5988\uD85A\uDF89\uD858\uDC2F\u0732\uD86F\uDFB5\u2623\uD854\uDF73\uD870\uDC50\u6324\u18C8\u8124\uD84B\uDF63\uD83C\uDF36\uD81E\uDE9D\uD86A\uDD71\uFCFB\uD873\uDDDD\uD857\uDF41\uD876\uDC93\u84E5\uD81F\uDFCD\uD869\uDEB3\uD852\uDF7C\uD867\uDDC2\uB0B3\uD81E\uDD10\uD86C\uDE43\uBB58\uD805\uDF38\uD86E\uDEDB\uD866\uDDE4\uBFF4\uD81A\uDCA3\uD858\uDCA2\u26DB\uD865\uDFAB\uAC56\uD835\uDF87\uD86D\uDC20\u8BE5\u9FC5\uD854\uDF98\uCF52\u8911\uD86D\uDD46\uD856\uDDB7\uA576\u0FB2\uD868\uDE23\u191C\uD85A\uDDAD\uD86A\uDEAC\uD849\uDE8D\uD875\uDEE4\u35F0\uD842\uDC92\uD800\uDD33\u2CC0\u23CD\u14E8\uD835\uDD10\u96A8\uD860\uDD4C\uD86C\uDFBD\uD861\uDC52\uD85E\uDEB0\uD81F\uDEC6\uD807\uDC3F\uD858\uDD7E\u24F2\uD863\uDEF7\uD867\uDFDD\u9508\uD859\uDCEC\uD873\uDFED\u39C0\u3251\uFFE0\uD86F\uDE34\uD808\uDC7A\uD85F\uDFD5\uD855\uDDCF\uBF31\uD85D\uDEA7\uD83C\uDC0E\uC629\uD841\uDF6E\uA64F\u863E\uD862\uDF02\uD86D\uDCA2\uD857\uDD2E\u145A\uD805\uDF20\u2018\uD877\uDE36\uD86F\uDC3A\u8CF9\uD86A\uDD51\uD875\uDF8B\uD855\uDCE8\uD3D9\uD83D\uDDF5\uD871\uDC32\u5CF2\uD877\uDEDC\u6567\uD84C\uDD44\u1041\uD867\uDD70\uD844\uDF9B\uD858\uDC9B\u1C1D\uD873\uDFB2\uD862\uDFD7\u7B05\uD863\uDE0A\u47AA\uD80C\uDDEE\u59F9\uD856\uDCAA\uD82C\uDEF9\u3B43\u87EC\uD855\uDDDC\u80A5\uD850\uDC09\u21D5\uD85A\uDFB8\u6E9C\uD86B\uDE2C\uD807\uDD36\uD879\uDE51\u9FA0\uD86E\uDE5C\uD81C\uDEFC\uD847\uDD71\u6440\u2B19\u0084\uA244\uD874\uDF6D\uC75E\uCE78\uD856\uDC24\uA9BD\uD844\uDE5E\uD835\uDCF0\uD83E\uDD64\u54EC\u5614\uD868\uDD4D\uD85C\uDFEB\uD855\uDCF0\uB35D\u86CC\u6C1C\uA2CF\uD86D\uDC1F\u428A\uD803\uDCD0\u3922\u7B1F\uD822\uDDE2\uD860\uDD61\uD811\uDD12\u8A8E\uD801\uDD48\u42FE\uD85A\uDF51\u80CA\uD859\uDF21\uD853\uDE0D\uD86C\uDDE9\uD800\uDE8D\uD853\uDEE8\uD874\uDD49\u763C\uD873\uDDD7\u1B0E\uD85E\uDE78\uD857\uDE23\u4CEE\uD855\uDF16\uB1A0\uCF36\uD801\uDC22\u8151\u166A\uD809\uDCF9\uD85B\uDF00\uD85A\uDF05\uD862\uDF74\uD81E\uDDFA\uC410\u3787\uD863\uDEDC\uD81E\uDEBD\u8A19\u8710\u10A8\uB715\uD806\uDE6D\uD874\uDDEC\uD849\uDC19\uBB7A\uC9D2\uD879\uDFBE\uD865\uDE0C\uD840\uDD35\u3343\u1F9B\uBD98\u7E47\uD841\uDC24\uD864\uDCFD\uD853\uDF20\u4F81\u1A24\u3D41\u3552\uD851\uDC40\uD857\uDD89\u5E21\uD874\uDFCD\uD841\uDDD2\u12CE\u8487\uD875\uDC7A\uD801\uDC62\u73A1\u4D24\u5163\uD836\uDDF1\u5D38\u081E\u24A2\u9EE0\uCC12\uD864\uDC1F\u9BBE\uD878\uDCEF\uD86C\uDCC9\uD842\uDCFB\u814F\uD853\uDDFB\uD861\uDDF9\uD85C\uDDA3\u651F\uD845\uDF52\u1BA2\uD84E\uDDF3\u3244\uD81D\uDFB7\uD84C\uDD28\uD842\uDC3A\uD854\uDF75\u35D2\uD84B\uDC7B\uD855\uDE64\u5384\uD863\uDCDC\uD81A\uDCA5\uD844\uDD91\uDB40\uDC2F\uD86F\uDC45\uD85D\uDFB3\u1B88\uD805\uDF23\uD84B\uDE5F\uD84A\uDFEA\uD84A\uDF9A\uD842\uDCA7\uD862\uDFDF\uD85C\uDD57\u56B6\uD84A\uDE3D\uD87A\uDCE6\u478F\uD877\uDE38\u6C60\uD849\uDE4F\u1D9F\u055A\u3741\uD81F\uDFD3\u69FE\uD84D\uDD18\uDB40\uDC7E\u8AB8\uD81E\uDEF3\u4EDF\uD877\uDD66\uD849\uDDD4\u7C7C\uD854\uDFF3\uD843\uDE55\u6EF4\uD835\uDD09\uD866\uDCA4\u63E2\uD86D\uDF5E\uD81E\uDEC3\uAB09\u529B\uD81B\uDF59\uD84A\uDD19\uD855\uDD04\uD86C\uDF2F\uD863\uDFE6\u783D\u937A\uD84E\uDF0F\uD877\uDCDE\uD845\uDF98\u55C6\uD81A\uDD3F\uD820\uDCAB\uD872\uDC11\uAD96\u5C38\uD338\uD858\uDDF8\uBC6D\uD84D\uDEC9\uD85C\uDF11\uD867\uDCB8\uD834\uDCEE\u306C\u1646\uD842\uDE09\u513B\uD865\uDDCA\u2F1C\uA25F\uD790\uD875\uDF9D\u6791\uD846\uDF46\uD872\uDDF8\uD39C\uD84F\uDE5F\uD83C\uDF32\uD83D\uDF04\u04BB\uD870\uDD56\uD86B\uDE4F\uC028\u9C00\uD81C\uDFE3\uD834\uDD54\uD81E\uDED9\u3A7F\u6D65\u9353\uD793\uD84A\uDD3B\u45FF\uD86A\uDEAB\u141B\uD820\uDF4A\uD857\uDFBB\uD872\uDE41\uD85F\uDC35\u15C3\uACBD\uD81D\uDC2E\uD870\uDC31\uD875\uDE0E\uD861\uDC33\uD871\uDCCD\uD865\uDEEF\uD83E\uDC37\uD1C4\uD846\uDCC1\uD87A\uDDCA\uD821\uDCF7\uD83C\uDC1C\uD81D\uDC4A\uD861\uDCC5\uD869\uDF13\uD840\uDD0E\u23EB\uD851\uDD70\uD847\uDF0E\u4013\uB0EB\u656E\uD865\uDDFD\uD841\uDFF8\u7FB7\u14DE\uD86A\uDD94\uD834\uDCE6\",\"_entity_type\":\"indexNameType\"}",
+ JsonObject.class
+ )
+ )
+ ) );
+
+ return params;
+ }
+
+ private GsonHttpEntity gsonEntity;
+ private String expectedPayloadString;
+ private int expectedContentLength;
+
+ public void init(List payload) throws IOException {
+ Gson gson = GsonProvider.create( GsonBuilder::new, true ).getGson();
+ this.gsonEntity = new GsonHttpEntity( gson, payload );
+ StringBuilder builder = new StringBuilder();
+ for ( JsonObject object : payload ) {
+ gson.toJson( object, builder );
+ builder.append( "\n" );
+ }
+ this.expectedPayloadString = builder.toString();
+ this.expectedContentLength = StandardCharsets.UTF_8.encode( expectedPayloadString ).limit();
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void initialContentLength(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ // The content length cannot be known from the start for large, multi-object payloads
+ assumeTrue( payload.size() <= 1 || expectedContentLength < 1024 );
+
+ assertThat( gsonEntity.getContentLength() ).isEqualTo( expectedContentLength );
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void contentType(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ String contentType = gsonEntity.getContentType();
+ assertThat( contentType ).isEqualTo( "application/json; charset=UTF-8" );
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_noPushBack(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ int pushBackPeriod = Integer.MAX_VALUE;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every5Bytes(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ int pushBackPeriod = 5;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every100Bytes(String ignoredLabel, List payload)
+ throws IOException {
+ init( payload );
+ int pushBackPeriod = 100;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every500Bytes(String ignoredLabel, List payload)
+ throws IOException {
+ init( payload );
+ int pushBackPeriod = 500;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void writeTo(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doWriteTo( gsonEntity ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void getContent(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doGetContent( gsonEntity ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ private String doProduceContent(GsonHttpEntity entity, int pushBackPeriod) throws IOException {
+ try ( ByteArrayOutputStream outputStream = new ByteArrayOutputStream() ) {
+ OutputStreamContentEncoder contentEncoder = new OutputStreamContentEncoder( outputStream, pushBackPeriod );
+ while ( !contentEncoder.isCompleted() ) {
+ entity.produce( contentEncoder );
+ }
+ return outputStream.toString( StandardCharsets.UTF_8.name() );
+ }
+ finally {
+ entity.close();
+ }
+ }
+
+ private String doWriteTo(GsonHttpEntity entity) throws IOException {
+ try ( ByteArrayOutputStream outputStream = new ByteArrayOutputStream() ) {
+ entity.writeTo( outputStream );
+ return outputStream.toString( StandardCharsets.UTF_8.name() );
+ }
+ }
+
+ private String doGetContent(GsonHttpEntity entity) throws IOException {
+ try ( InputStream inputStream = entity.getContent();
+ Reader reader = new InputStreamReader( inputStream, StandardCharsets.UTF_8 );
+ BufferedReader bufferedReader = new BufferedReader( reader ) ) {
+ StringBuilder builder = new StringBuilder();
+ int read;
+ while ( ( read = bufferedReader.read() ) >= 0 ) {
+ builder.appendCodePoint( read );
+ }
+ return builder.toString();
+ }
+ }
+
+ private static class OutputStreamContentEncoder implements ContentEncoder, DataStreamChannel {
+ private boolean complete = false;
+ private final OutputStream outputStream;
+ private final int pushBackPeriod;
+
+ private int written = 0;
+ private boolean pushedBack = false;
+
+ private OutputStreamContentEncoder(OutputStream outputStream, int pushBackPeriod) {
+ this.outputStream = outputStream;
+ this.pushBackPeriod = pushBackPeriod;
+ }
+
+ @Override
+ public void requestOutput() {
+
+ }
+
+ @Override
+ public int write(ByteBuffer src) throws IOException {
+ int toWrite = src.remaining();
+ if ( !pushedBack && ( written % pushBackPeriod ) != ( ( written + toWrite ) % pushBackPeriod ) ) {
+ // push back
+ pushedBack = true;
+ return 0;
+ }
+ pushedBack = false;
+ outputStream.write( src.array(), src.arrayOffset(), toWrite );
+ written += toWrite;
+ return toWrite;
+ }
+
+ @Override
+ public void endStream() throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void endStream(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void complete(List extends Header> trailers) {
+ this.complete = true;
+ }
+
+ @Override
+ public boolean isCompleted() {
+ return this.complete;
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/pom.xml b/backend/elasticsearch-client/elasticsearch-rest-client/pom.xml
new file mode 100644
index 00000000000..5fbdd1bbf6f
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/pom.xml
@@ -0,0 +1,100 @@
+
+
+
+ 4.0.0
+
+ org.hibernate.search
+ hibernate-search-parent-public
+ 8.2.0-SNAPSHOT
+ ../../../build/parents/public
+
+ hibernate-search-backend-elasticsearch-client-rest
+
+ Hibernate Search Backend - Elasticsearch client based on the low-level Elasticsearch client
+ Hibernate Search Elasticsearch client based on the low-level Elasticsearch client
+
+
+
+ false
+ org.hibernate.search.backend.elasticsearch.client.rest
+
+
+
+
+ org.hibernate.search
+ hibernate-search-engine
+
+
+ org.hibernate.search
+ hibernate-search-backend-elasticsearch-client-common
+
+
+ org.elasticsearch.client
+ elasticsearch-rest-client
+
+
+ org.elasticsearch.client
+ elasticsearch-rest-client-sniffer
+
+
+ org.apache.httpcomponents
+ httpclient
+ 4.5.14
+
+
+ org.apache.httpcomponents
+ httpcore
+ 4.4.16
+
+
+ org.apache.httpcomponents
+ httpcore-nio
+ 4.4.16
+
+
+ org.apache.httpcomponents
+ httpasyncclient
+ 4.1.5
+
+
+ org.jboss.logging
+ jboss-logging
+
+
+ org.jboss.logging
+ jboss-logging-annotations
+
+
+ com.google.code.gson
+ gson
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+
+
+ org.hibernate.search
+ hibernate-search-util-internal-test-common
+ test
+
+
+
+
+
+
+ org.moditect
+ moditect-maven-plugin
+
+
+
+
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurationContext.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurationContext.java
new file mode 100644
index 00000000000..765138a6ee9
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurationContext.java
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest;
+
+
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
+
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+
+/**
+ * The context passed to {@link ElasticsearchHttpClientConfigurer}.
+ */
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ + "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
+public interface ElasticsearchHttpClientConfigurationContext {
+
+ /**
+ * @return A {@link BeanResolver}.
+ */
+ BeanResolver beanResolver();
+
+ /**
+ * @return A configuration property source, appropriately masked so that the factory
+ * doesn't need to care about Hibernate Search prefixes (hibernate.search.*, etc.). All the properties
+ * can be accessed at the root.
+ * CAUTION: the property key "type" is reserved for use by the engine.
+ */
+ ConfigurationPropertySource configurationPropertySource();
+
+ /**
+ * @return An Apache HTTP client builder, to set the configuration.
+ * @see the Apache HTTP Client documentation
+ */
+ HttpAsyncClientBuilder clientBuilder();
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurer.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurer.java
new file mode 100644
index 00000000000..46fcda3c987
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/ElasticsearchHttpClientConfigurer.java
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest;
+
+/**
+ * An extension point allowing fine tuning of the Apache HTTP Client used by the Elasticsearch integration.
+ *
+ * This enables in particular connecting to cloud services that require a particular authentication method,
+ * such as request signing on Amazon Web Services.
+ *
+ * The ElasticsearchHttpClientConfigurer implementation will be given access to the HTTP client builder
+ * on startup.
+ *
+ * Note that you don't have to configure the client unless you have specific needs:
+ * the default configuration should work just fine for an on-premise Elasticsearch server.
+ */
+public interface ElasticsearchHttpClientConfigurer {
+
+ /**
+ * Configure the HTTP Client.
+ *
+ * This method is called once for every configurer, each time an Elasticsearch client is set up.
+ *
+ * Implementors should take care of only applying configuration if relevant:
+ * there may be multiple, conflicting configurers in the path, so implementors should first check
+ * (through a configuration property) whether they are needed or not before applying any modification.
+ * For example an authentication configurer could decide not to do anything if no username is provided,
+ * or if the configuration property {@code my.configurer.enabled} is {@code false}.
+ *
+ * @param context A configuration context giving access to the Apache HTTP client builder
+ * and configuration properties in particular.
+ */
+ void configure(ElasticsearchHttpClientConfigurationContext context);
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/ClientRestElasticsearchBackendClientSettings.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/ClientRestElasticsearchBackendClientSettings.java
new file mode 100644
index 00000000000..12ac28cb2de
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/ClientRestElasticsearchBackendClientSettings.java
@@ -0,0 +1,138 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.cfg;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.rest.ElasticsearchHttpClientConfigurer;
+
+/**
+ * Specific configuration properties for the Elasticsearch backend's rest client based on the Elasticsearch's low-level rest client.
+ *
+ * Constants in this class are to be appended to a prefix to form a property key;
+ * see {@link org.hibernate.search.engine.cfg.BackendSettings} for details.
+ *
+ * @author Gunnar Morling
+ */
+public final class ClientRestElasticsearchBackendClientSettings {
+
+ private ClientRestElasticsearchBackendClientSettings() {
+ }
+
+ /**
+ * The timeout when executing a request to an Elasticsearch server.
+ *
+ * This includes the time needed to establish a connection, send the request and read the response.
+ *
+ * Expects a positive Integer value in milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to no request timeout.
+ */
+ public static final String REQUEST_TIMEOUT = "request_timeout";
+
+ /**
+ * The timeout when reading responses from an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 60000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#READ_TIMEOUT}.
+ */
+ public static final String READ_TIMEOUT = "read_timeout";
+
+ /**
+ * The timeout when establishing a connection to an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 3000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#CONNECTION_TIMEOUT}.
+ */
+ public static final String CONNECTION_TIMEOUT = "connection_timeout";
+
+ /**
+ * The maximum number of simultaneous connections to the Elasticsearch cluster,
+ * all hosts taken together.
+ *
+ * Expects a positive Integer value, such as {@code 40},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS}.
+ */
+ public static final String MAX_CONNECTIONS = "max_connections";
+
+ /**
+ * The maximum number of simultaneous connections to each host of the Elasticsearch cluster.
+ *
+ * Expects a positive Integer value, such as {@code 20},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS_PER_ROUTE}.
+ */
+ public static final String MAX_CONNECTIONS_PER_ROUTE = "max_connections_per_route";
+
+ /**
+ * Whether automatic discovery of nodes in the Elasticsearch cluster is enabled.
+ *
+ * Expects a Boolean value such as {@code true} or {@code false},
+ * or a string that can be parsed into a Boolean value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_ENABLED}.
+ */
+ public static final String DISCOVERY_ENABLED = "discovery.enabled";
+
+ /**
+ * The time interval between two executions of the automatic discovery, if enabled.
+ *
+ * Expects a positive Integer value in seconds, such as {@code 2},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_REFRESH_INTERVAL}.
+ */
+ public static final String DISCOVERY_REFRESH_INTERVAL = "discovery.refresh_interval";
+
+ /**
+ * How long connections to the Elasticsearch cluster can be kept idle.
+ *
+ * Expects a positive Long value of milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * If the response from an Elasticsearch cluster contains a {@code Keep-Alive} header,
+ * then the effective max idle time will be whichever is lower:
+ * the duration from the {@code Keep-Alive} header or the value of this property (if set).
+ *
+ * If this property is not set, only the {@code Keep-Alive} header is considered,
+ * and if it's absent, idle connections will be kept forever.
+ */
+ public static final String MAX_KEEP_ALIVE = "max_keep_alive";
+
+ /**
+ * A {@link ElasticsearchHttpClientConfigurer} that defines custom HTTP client configuration.
+ *
+ * It can be used for example to tune the SSL context to accept self-signed certificates.
+ * It allows overriding other HTTP client settings, such as {@link ElasticsearchBackendClientCommonSettings#USERNAME} or {@link #MAX_CONNECTIONS_PER_ROUTE}.
+ *
+ * Expects a reference to a bean of type {@link ElasticsearchHttpClientConfigurer}.
+ *
+ * Defaults to no value.
+ */
+ public static final String CLIENT_CONFIGURER = "client.configurer";
+
+ /**
+ * Default values for the different settings if no values are given.
+ */
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+
+ public static final int READ_TIMEOUT = 30000;
+ public static final int CONNECTION_TIMEOUT = 1000;
+ public static final int MAX_CONNECTIONS = 40;
+ public static final int MAX_CONNECTIONS_PER_ROUTE = 20;
+ public static final boolean DISCOVERY_ENABLED = false;
+ public static final int DISCOVERY_REFRESH_INTERVAL = 10;
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/spi/ClientRestElasticsearchBackendClientSpiSettings.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/spi/ClientRestElasticsearchBackendClientSpiSettings.java
new file mode 100644
index 00000000000..7f1b9ba2adc
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/cfg/spi/ClientRestElasticsearchBackendClientSpiSettings.java
@@ -0,0 +1,55 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.cfg.spi;
+
+import org.hibernate.search.engine.cfg.EngineSettings;
+
+/**
+ * Configuration properties for the Elasticsearch backend that are considered SPI (and not API).
+ */
+public final class ClientRestElasticsearchBackendClientSpiSettings {
+
+ /**
+ * The prefix expected for the key of every Hibernate Search configuration property.
+ */
+ public static final String PREFIX = EngineSettings.PREFIX + "backend.";
+
+ /**
+ * An external Elasticsearch client instance that Hibernate Search should use for all requests to Elasticsearch.
+ *
+ * If this is set, Hibernate Search will not attempt to create its own Elasticsearch,
+ * and all other client-related configuration properties
+ * (hosts/uris, authentication, discovery, timeouts, max connections, configurer, ...)
+ * will be ignored.
+ *
+ * Expects a reference to a bean of type {@link org.elasticsearch.client.RestClient}.
+ *
+ * Defaults to nothing: if no client instance is provided, Hibernate Search will create its own.
+ *
+ * WARNING - Incubating API: the underlying client class may change without prior notice.
+ *
+ * @see org.hibernate.search.engine.cfg The core documentation of configuration properties,
+ * which includes a description of the "bean reference" properties and accepted values.
+ */
+ public static final String CLIENT_INSTANCE = "client.instance";
+
+ private ClientRestElasticsearchBackendClientSpiSettings() {
+ }
+
+ /**
+ * Configuration property keys without the {@link #PREFIX prefix}.
+ */
+ public static class Radicals {
+
+ private Radicals() {
+ }
+ }
+
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+ }
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ByteBufferContentEncoder.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ByteBufferContentEncoder.java
similarity index 92%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ByteBufferContentEncoder.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ByteBufferContentEncoder.java
index fe6c7346d0b..ca146cae5b7 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ByteBufferContentEncoder.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ByteBufferContentEncoder.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.nio.ByteBuffer;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientImpl.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClient.java
similarity index 88%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientImpl.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClient.java
index 9d96fe1ee49..e8a564b0444 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientImpl.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClient.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.io.IOException;
import java.io.InputStream;
@@ -17,13 +17,13 @@
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchClientImplementor;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchRequest;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchResponse;
-import org.hibernate.search.backend.elasticsearch.gson.spi.JsonLogHelper;
-import org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchClientLog;
-import org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchMiscLog;
-import org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchRequestLog;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientLog;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchRequestLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchResponse;
+import org.hibernate.search.backend.elasticsearch.client.common.util.spi.ElasticsearchClientUtils;
import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
import org.hibernate.search.engine.common.timing.Deadline;
import org.hibernate.search.engine.environment.bean.BeanHolder;
@@ -44,7 +44,7 @@
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.sniff.Sniffer;
-public class ElasticsearchClientImpl implements ElasticsearchClientImplementor {
+public class ClientRestElasticsearchClient implements ElasticsearchClientImplementor {
private final BeanHolder extends RestClient> restClientHolder;
@@ -58,7 +58,7 @@ public class ElasticsearchClientImpl implements ElasticsearchClientImplementor {
private final Gson gson;
private final JsonLogHelper jsonLogHelper;
- ElasticsearchClientImpl(BeanHolder extends RestClient> restClientHolder, Sniffer sniffer,
+ ClientRestElasticsearchClient(BeanHolder extends RestClient> restClientHolder, Sniffer sniffer,
SimpleScheduledExecutor timeoutExecutorService,
Optional requestTimeoutMs, int connectionTimeoutMs,
Gson gson, JsonLogHelper jsonLogHelper) {
@@ -88,7 +88,7 @@ public T unwrap(Class clientClass) {
if ( RestClient.class.isAssignableFrom( clientClass ) ) {
return (T) restClientHolder.get();
}
- throw ElasticsearchMiscLog.INSTANCE.clientUnwrappingWithUnknownType( clientClass, RestClient.class );
+ throw ElasticsearchClientLog.INSTANCE.clientUnwrappingWithUnknownType( clientClass, RestClient.class );
}
private CompletableFuture send(ElasticsearchRequest elasticsearchRequest) {
@@ -96,7 +96,7 @@ private CompletableFuture send(ElasticsearchRequest elasticsearchReque
HttpEntity entity;
try {
- entity = ElasticsearchClientUtils.toEntity( gson, elasticsearchRequest );
+ entity = GsonHttpEntity.toEntity( gson, elasticsearchRequest );
}
catch (IOException | RuntimeException e) {
completableFuture.completeExceptionally( e );
@@ -199,7 +199,7 @@ private ElasticsearchResponse convertResponse(Response response) {
try {
JsonObject body = parseBody( response );
return new ElasticsearchResponse(
- response.getHost(),
+ response.getHost().toHostString(),
response.getStatusLine().getStatusCode(),
response.getStatusLine().getReasonPhrase(),
body );
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientBeanConfigurer.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientBeanConfigurer.java
new file mode 100644
index 00000000000..71662c41bc8
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientBeanConfigurer.java
@@ -0,0 +1,20 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurationContext;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurer;
+
+public class ClientRestElasticsearchClientBeanConfigurer implements BeanConfigurer {
+ @Override
+ public void configure(BeanConfigurationContext context) {
+ context.define(
+ ElasticsearchClientFactory.class, ElasticsearchClientFactory.DEFAULT_BEAN_NAME,
+ beanResolver -> BeanHolder.of( new ClientRestElasticsearchClientFactory() )
+ );
+ }
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientFactoryImpl.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientFactory.java
similarity index 66%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientFactoryImpl.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientFactory.java
index a48a5ee3c06..769bf2126fa 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchClientFactoryImpl.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchClientFactory.java
@@ -2,19 +2,21 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.util.List;
import java.util.Optional;
-import org.hibernate.search.backend.elasticsearch.ElasticsearchVersion;
-import org.hibernate.search.backend.elasticsearch.cfg.ElasticsearchBackendSettings;
-import org.hibernate.search.backend.elasticsearch.cfg.spi.ElasticsearchBackendSpiSettings;
-import org.hibernate.search.backend.elasticsearch.client.ElasticsearchHttpClientConfigurer;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchClientFactory;
-import org.hibernate.search.backend.elasticsearch.client.spi.ElasticsearchClientImplementor;
-import org.hibernate.search.backend.elasticsearch.gson.spi.GsonProvider;
-import org.hibernate.search.backend.elasticsearch.logging.impl.ElasticsearchClientLog;
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.rest.ElasticsearchHttpClientConfigurer;
+import org.hibernate.search.backend.elasticsearch.client.rest.cfg.ClientRestElasticsearchBackendClientSettings;
+import org.hibernate.search.backend.elasticsearch.client.rest.cfg.spi.ClientRestElasticsearchBackendClientSpiSettings;
import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
import org.hibernate.search.engine.cfg.spi.ConfigurationProperty;
import org.hibernate.search.engine.cfg.spi.OptionalConfigurationProperty;
@@ -41,93 +43,93 @@
/**
* @author Gunnar Morling
*/
-public class ElasticsearchClientFactoryImpl implements ElasticsearchClientFactory {
+public class ClientRestElasticsearchClientFactory implements ElasticsearchClientFactory {
private static final OptionalConfigurationProperty> CLIENT_INSTANCE =
- ConfigurationProperty.forKey( ElasticsearchBackendSpiSettings.CLIENT_INSTANCE )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSpiSettings.CLIENT_INSTANCE )
.asBeanReference( RestClient.class )
.build();
private static final OptionalConfigurationProperty> HOSTS =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.HOSTS )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.HOSTS )
.asString().multivalued()
.build();
private static final OptionalConfigurationProperty PROTOCOL =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.PROTOCOL )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PROTOCOL )
.asString()
.build();
private static final OptionalConfigurationProperty> URIS =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.URIS )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.URIS )
.asString().multivalued()
.build();
private static final ConfigurationProperty PATH_PREFIX =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.PATH_PREFIX )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PATH_PREFIX )
.asString()
- .withDefault( ElasticsearchBackendSettings.Defaults.PATH_PREFIX )
+ .withDefault( ElasticsearchBackendClientCommonSettings.Defaults.PATH_PREFIX )
.build();
private static final OptionalConfigurationProperty USERNAME =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.USERNAME )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.USERNAME )
.asString()
.build();
private static final OptionalConfigurationProperty PASSWORD =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.PASSWORD )
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PASSWORD )
.asString()
.build();
private static final OptionalConfigurationProperty REQUEST_TIMEOUT =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.REQUEST_TIMEOUT )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.REQUEST_TIMEOUT )
.asIntegerStrictlyPositive()
.build();
private static final ConfigurationProperty READ_TIMEOUT =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.READ_TIMEOUT )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.READ_TIMEOUT )
.asIntegerPositiveOrZeroOrNegative()
- .withDefault( ElasticsearchBackendSettings.Defaults.READ_TIMEOUT )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.READ_TIMEOUT )
.build();
private static final ConfigurationProperty CONNECTION_TIMEOUT =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.CONNECTION_TIMEOUT )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.CONNECTION_TIMEOUT )
.asIntegerPositiveOrZeroOrNegative()
- .withDefault( ElasticsearchBackendSettings.Defaults.CONNECTION_TIMEOUT )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.CONNECTION_TIMEOUT )
.build();
private static final ConfigurationProperty MAX_TOTAL_CONNECTION =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.MAX_CONNECTIONS )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.MAX_CONNECTIONS )
.asIntegerStrictlyPositive()
- .withDefault( ElasticsearchBackendSettings.Defaults.MAX_CONNECTIONS )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS )
.build();
private static final ConfigurationProperty MAX_TOTAL_CONNECTION_PER_ROUTE =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.MAX_CONNECTIONS_PER_ROUTE )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.MAX_CONNECTIONS_PER_ROUTE )
.asIntegerStrictlyPositive()
- .withDefault( ElasticsearchBackendSettings.Defaults.MAX_CONNECTIONS_PER_ROUTE )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS_PER_ROUTE )
.build();
private static final ConfigurationProperty DISCOVERY_ENABLED =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.DISCOVERY_ENABLED )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.DISCOVERY_ENABLED )
.asBoolean()
- .withDefault( ElasticsearchBackendSettings.Defaults.DISCOVERY_ENABLED )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.DISCOVERY_ENABLED )
.build();
private static final ConfigurationProperty DISCOVERY_REFRESH_INTERVAL =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.DISCOVERY_REFRESH_INTERVAL )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.DISCOVERY_REFRESH_INTERVAL )
.asIntegerStrictlyPositive()
- .withDefault( ElasticsearchBackendSettings.Defaults.DISCOVERY_REFRESH_INTERVAL )
+ .withDefault( ClientRestElasticsearchBackendClientSettings.Defaults.DISCOVERY_REFRESH_INTERVAL )
.build();
private static final OptionalConfigurationProperty<
BeanReference extends ElasticsearchHttpClientConfigurer>> CLIENT_CONFIGURER =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.CLIENT_CONFIGURER )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.CLIENT_CONFIGURER )
.asBeanReference( ElasticsearchHttpClientConfigurer.class )
.build();
private static final OptionalConfigurationProperty MAX_KEEP_ALIVE =
- ConfigurationProperty.forKey( ElasticsearchBackendSettings.MAX_KEEP_ALIVE )
+ ConfigurationProperty.forKey( ClientRestElasticsearchBackendClientSettings.MAX_KEEP_ALIVE )
.asLongStrictlyPositive()
.build();
@@ -135,8 +137,7 @@ public class ElasticsearchClientFactoryImpl implements ElasticsearchClientFactor
public ElasticsearchClientImplementor create(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
ThreadProvider threadProvider, String threadNamePrefix,
SimpleScheduledExecutor timeoutExecutorService,
- GsonProvider gsonProvider,
- Optional configuredVersion) {
+ GsonProvider gsonProvider) {
Optional requestTimeoutMs = REQUEST_TIMEOUT.get( propertySource );
int connectionTimeoutMs = CONNECTION_TIMEOUT.get( propertySource );
@@ -153,11 +154,11 @@ public ElasticsearchClientImplementor create(BeanResolver beanResolver, Configur
ServerUris hosts = ServerUris.fromOptionalStrings( PROTOCOL.get( propertySource ),
HOSTS.get( propertySource ), URIS.get( propertySource ) );
restClientHolder = createClient( beanResolver, propertySource, threadProvider, threadNamePrefix,
- configuredVersion, hosts, PATH_PREFIX.get( propertySource ) );
+ hosts, PATH_PREFIX.get( propertySource ) );
sniffer = createSniffer( propertySource, restClientHolder.get(), hosts );
}
- return new ElasticsearchClientImpl(
+ return new ClientRestElasticsearchClient(
restClientHolder, sniffer, timeoutExecutorService,
requestTimeoutMs, connectionTimeoutMs,
gsonProvider.getGson(), gsonProvider.getLogHelper()
@@ -166,7 +167,6 @@ public ElasticsearchClientImplementor create(BeanResolver beanResolver, Configur
private BeanHolder extends RestClient> createClient(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
ThreadProvider threadProvider, String threadNamePrefix,
- Optional configuredVersion,
ServerUris hosts, String pathPrefix) {
RestClientBuilder builder = RestClient.builder( hosts.asHostsArray() );
if ( !pathPrefix.isEmpty() ) {
@@ -179,8 +179,13 @@ private BeanHolder extends RestClient> createClient(BeanResolver beanResolver,
RestClient client = null;
List> httpClientConfigurerReferences =
beanResolver.allConfiguredForRole( ElasticsearchHttpClientConfigurer.class );
+ List> requestInterceptorProviderReferences =
+ beanResolver.allConfiguredForRole( ElasticsearchRequestInterceptorProvider.class );
+
try ( BeanHolder> httpClientConfigurersHolder =
- beanResolver.resolve( httpClientConfigurerReferences ) ) {
+ beanResolver.resolve( httpClientConfigurerReferences );
+ BeanHolder> requestInterceptorProvidersHodler =
+ beanResolver.resolve( requestInterceptorProviderReferences ) ) {
client = builder
.setRequestConfigCallback( b -> customizeRequestConfig( b, propertySource ) )
.setHttpClientConfigCallback(
@@ -188,8 +193,9 @@ private BeanHolder extends RestClient> createClient(BeanResolver beanResolver,
b,
beanResolver, propertySource,
threadProvider, threadNamePrefix,
- configuredVersion, hosts,
- httpClientConfigurersHolder.get(), customConfig
+ hosts,
+ httpClientConfigurersHolder.get(), requestInterceptorProvidersHodler.get(),
+ customConfig
)
)
.build();
@@ -236,8 +242,8 @@ private Sniffer createSniffer(ConfigurationPropertySource propertySource,
private HttpAsyncClientBuilder customizeHttpClientConfig(HttpAsyncClientBuilder builder,
BeanResolver beanResolver, ConfigurationPropertySource propertySource,
ThreadProvider threadProvider, String threadNamePrefix,
- Optional configuredVersion,
ServerUris hosts, Iterable configurers,
+ Iterable requestInterceptorProviders,
Optional extends BeanHolder extends ElasticsearchHttpClientConfigurer>> customConfig) {
builder.setMaxConnTotal( MAX_TOTAL_CONNECTION.get( propertySource ) )
.setMaxConnPerRoute( MAX_TOTAL_CONNECTION_PER_ROUTE.get( propertySource ) )
@@ -252,7 +258,7 @@ private HttpAsyncClientBuilder customizeHttpClientConfig(HttpAsyncClientBuilder
if ( username.isPresent() ) {
Optional password = PASSWORD.get( propertySource );
if ( password.isPresent() && !hosts.isSslEnabled() ) {
- ElasticsearchClientLog.INSTANCE.usingPasswordOverHttp();
+ ElasticsearchClientConfigurationLog.INSTANCE.usingPasswordOverHttp();
}
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
@@ -269,12 +275,19 @@ private HttpAsyncClientBuilder customizeHttpClientConfig(HttpAsyncClientBuilder
builder.setKeepAliveStrategy( new CustomConnectionKeepAliveStrategy( maxKeepAlive.get() ) );
}
- ElasticsearchHttpClientConfigurationContextImpl clientConfigurationContext =
- new ElasticsearchHttpClientConfigurationContextImpl( beanResolver, propertySource, builder, configuredVersion );
+ ClientRestElasticsearchHttpClientConfigurationContext clientConfigurationContext =
+ new ClientRestElasticsearchHttpClientConfigurationContext( beanResolver, propertySource, builder );
for ( ElasticsearchHttpClientConfigurer configurer : configurers ) {
configurer.configure( clientConfigurationContext );
}
+ for ( ElasticsearchRequestInterceptorProvider interceptorProvider : requestInterceptorProviders ) {
+ Optional requestInterceptor =
+ interceptorProvider.provide( clientConfigurationContext );
+ if ( requestInterceptor.isPresent() ) {
+ builder.addInterceptorLast( new ClientRestHttpRequestInterceptor( requestInterceptor.get() ) );
+ }
+ }
if ( customConfig.isPresent() ) {
BeanHolder extends ElasticsearchHttpClientConfigurer> customConfigBeanHolder = customConfig.get();
customConfigBeanHolder.get().configure( clientConfigurationContext );
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchHttpClientConfigurationContextImpl.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchHttpClientConfigurationContext.java
similarity index 56%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchHttpClientConfigurationContextImpl.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchHttpClientConfigurationContext.java
index 42a325a71eb..f5ca2c67587 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ElasticsearchHttpClientConfigurationContextImpl.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestElasticsearchHttpClientConfigurationContext.java
@@ -2,33 +2,28 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
-import java.util.Optional;
-
-import org.hibernate.search.backend.elasticsearch.ElasticsearchVersion;
-import org.hibernate.search.backend.elasticsearch.client.ElasticsearchHttpClientConfigurationContext;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.rest.ElasticsearchHttpClientConfigurationContext;
import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
import org.hibernate.search.engine.environment.bean.BeanResolver;
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
-final class ElasticsearchHttpClientConfigurationContextImpl
- implements ElasticsearchHttpClientConfigurationContext {
+final class ClientRestElasticsearchHttpClientConfigurationContext
+ implements ElasticsearchHttpClientConfigurationContext, ElasticsearchRequestInterceptorProvider.Context {
private final BeanResolver beanResolver;
private final ConfigurationPropertySource configurationPropertySource;
private final HttpAsyncClientBuilder clientBuilder;
- private final Optional configuredVersion;
- ElasticsearchHttpClientConfigurationContextImpl(
+ ClientRestElasticsearchHttpClientConfigurationContext(
BeanResolver beanResolver,
ConfigurationPropertySource configurationPropertySource,
- HttpAsyncClientBuilder clientBuilder,
- Optional configuredVersion) {
+ HttpAsyncClientBuilder clientBuilder) {
this.beanResolver = beanResolver;
this.configurationPropertySource = configurationPropertySource;
this.clientBuilder = clientBuilder;
- this.configuredVersion = configuredVersion;
}
@Override
@@ -46,9 +41,4 @@ public HttpAsyncClientBuilder clientBuilder() {
return clientBuilder;
}
- @Override
- public Optional configuredVersion() {
- return configuredVersion;
- }
-
}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestHttpRequestInterceptor.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestHttpRequestInterceptor.java
new file mode 100644
index 00000000000..1ade535b204
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ClientRestHttpRequestInterceptor.java
@@ -0,0 +1,135 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.util.common.AssertionFailure;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpEntityEnclosingRequest;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.utils.URLEncodedUtils;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.protocol.HttpCoreContext;
+
+record ClientRestHttpRequestInterceptor(ElasticsearchRequestInterceptor elasticsearchRequestInterceptor)
+ implements HttpRequestInterceptor {
+
+ @Override
+ public void process(HttpRequest request, HttpContext context) throws IOException {
+ elasticsearchRequestInterceptor.intercept(
+ new ClientRestRequestContext( request, context )
+ );
+ }
+
+ private record ClientRestRequestContext(HttpRequest request, HttpCoreContext coreContext)
+ implements ElasticsearchRequestInterceptor.RequestContext {
+ private ClientRestRequestContext(HttpRequest request, HttpContext coreContext) {
+ this( request, HttpCoreContext.adapt( coreContext ) );
+ }
+
+ @Override
+ public boolean hasContent() {
+ if ( request instanceof HttpEntityEnclosingRequest enclosingRequest ) {
+ return enclosingRequest.getEntity() != null;
+ }
+ return false;
+ }
+
+ @Override
+ public InputStream content() throws IOException {
+ if ( request instanceof HttpEntityEnclosingRequest enclosingRequest ) {
+ HttpEntity entity = enclosingRequest.getEntity();
+ if ( entity == null ) {
+ return null;
+ }
+ if ( !entity.isRepeatable() ) {
+ throw new AssertionFailure( "Cannot sign AWS requests with non-repeatable entities" );
+ }
+ return entity.getContent();
+ }
+ else {
+ return null;
+ }
+ }
+
+ @Override
+ public String scheme() {
+ return coreContext.getTargetHost().getSchemeName();
+ }
+
+ @Override
+ public String host() {
+ return coreContext.getTargetHost().getHostName();
+ }
+
+ @Override
+ public Integer port() {
+ return coreContext.getTargetHost().getPort();
+ }
+
+ @Override
+ public String method() {
+ return request.getRequestLine().getMethod();
+ }
+
+ @Override
+ public String path() {
+ String uri = request.getRequestLine().getUri();
+ int queryStart = uri.indexOf( '?' );
+ if ( queryStart >= 0 ) {
+ return uri.substring( 0, queryStart );
+ }
+ return uri;
+ }
+
+ @Override
+ public Map queryParameters() {
+ String pathAndQuery = request.getRequestLine().getUri();
+ List queryParameters;
+ int queryStart = pathAndQuery.indexOf( '?' );
+ if ( queryStart >= 0 ) {
+ queryParameters = URLEncodedUtils.parse( pathAndQuery.substring( queryStart + 1 ), StandardCharsets.UTF_8 );
+ Map map = new HashMap<>();
+ for ( NameValuePair parameter : queryParameters ) {
+ map.put( parameter.getName(), parameter.getValue() );
+ }
+ return map;
+ }
+ return Map.of();
+ }
+
+ @Override
+ public void overrideHeaders(Map> headers) {
+ for ( Map.Entry> header : headers.entrySet() ) {
+ String name = header.getKey();
+ boolean first = true;
+ for ( String value : header.getValue() ) {
+ if ( first ) {
+ request.setHeader( name, value );
+ first = false;
+ }
+ else {
+ request.addHeader( name, value );
+ }
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return request.toString();
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CountingOutputStream.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CountingOutputStream.java
new file mode 100644
index 00000000000..1ea595fb21a
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CountingOutputStream.java
@@ -0,0 +1,46 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+final class CountingOutputStream extends FilterOutputStream {
+
+ private long bytesWritten = 0L;
+
+ public CountingOutputStream(OutputStream out) {
+ super( out );
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ out.write( b );
+ count( 1 );
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ write( b, 0, b.length );
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ out.write( b, off, len );
+ count( len );
+ }
+
+ void count(int written) {
+ if ( written > 0 ) {
+ bytesWritten += written;
+ }
+ }
+
+ public long getBytesWritten() {
+ return bytesWritten;
+ }
+
+}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CustomConnectionKeepAliveStrategy.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CustomConnectionKeepAliveStrategy.java
similarity index 93%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CustomConnectionKeepAliveStrategy.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CustomConnectionKeepAliveStrategy.java
index 4af070102e0..d5fef9cdbb4 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/CustomConnectionKeepAliveStrategy.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/CustomConnectionKeepAliveStrategy.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import org.apache.http.HttpResponse;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntity.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntity.java
similarity index 96%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntity.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntity.java
index ebb2e9fa962..c04afd5f7d6 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntity.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntity.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.io.IOException;
import java.io.InputStream;
@@ -13,6 +13,7 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
import org.hibernate.search.util.common.impl.Contracts;
import com.google.gson.Gson;
@@ -79,6 +80,14 @@ final class GsonHttpEntity implements HttpEntity, HttpAsyncContentProducer {
*/
private static final int CHAR_BUFFER_SIZE = BYTE_BUFFER_PAGE_SIZE;
+ public static HttpEntity toEntity(Gson gson, ElasticsearchRequest request) throws IOException {
+ final List bodyParts = request.bodyParts();
+ if ( bodyParts.isEmpty() ) {
+ return null;
+ }
+ return new GsonHttpEntity( gson, bodyParts );
+ }
+
private final Gson gson;
private final List bodyParts;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/HttpAsyncContentProducerInputStream.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/HttpAsyncContentProducerInputStream.java
similarity index 96%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/HttpAsyncContentProducerInputStream.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/HttpAsyncContentProducerInputStream.java
index a3edbbeefd3..ff85f6213fc 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/HttpAsyncContentProducerInputStream.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/HttpAsyncContentProducerInputStream.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.io.IOException;
import java.io.InputStream;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ProgressiveCharBufferWriter.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ProgressiveCharBufferWriter.java
similarity index 99%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ProgressiveCharBufferWriter.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ProgressiveCharBufferWriter.java
index 1c5f294880c..74334f12e09 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ProgressiveCharBufferWriter.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ProgressiveCharBufferWriter.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.io.IOException;
import java.io.Writer;
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ServerUris.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ServerUris.java
similarity index 68%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ServerUris.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ServerUris.java
index 683e998bb04..d3f5edf47b0 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/ServerUris.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/ServerUris.java
@@ -2,14 +2,14 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
-import org.hibernate.search.backend.elasticsearch.cfg.ElasticsearchBackendSettings;
-import org.hibernate.search.backend.elasticsearch.logging.impl.ConfigurationLog;
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
import org.apache.http.HttpHost;
@@ -26,18 +26,21 @@ private ServerUris(HttpHost[] hosts, boolean sslEnabled) {
static ServerUris fromOptionalStrings(Optional protocol, Optional> hostAndPortStrings,
Optional> uris) {
if ( !uris.isPresent() ) {
- String protocolValue = ( protocol.isPresent() ) ? protocol.get() : ElasticsearchBackendSettings.Defaults.PROTOCOL;
+ String protocolValue =
+ ( protocol.isPresent() ) ? protocol.get() : ElasticsearchBackendClientCommonSettings.Defaults.PROTOCOL;
List hostAndPortValues =
- ( hostAndPortStrings.isPresent() ) ? hostAndPortStrings.get() : ElasticsearchBackendSettings.Defaults.HOSTS;
+ ( hostAndPortStrings.isPresent() )
+ ? hostAndPortStrings.get()
+ : ElasticsearchBackendClientCommonSettings.Defaults.HOSTS;
return fromStrings( protocolValue, hostAndPortValues );
}
if ( protocol.isPresent() ) {
- throw ConfigurationLog.INSTANCE.uriAndProtocol( uris.get(), protocol.get() );
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndProtocol( uris.get(), protocol.get() );
}
if ( hostAndPortStrings.isPresent() ) {
- throw ConfigurationLog.INSTANCE.uriAndHosts( uris.get(), hostAndPortStrings.get() );
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndHosts( uris.get(), hostAndPortStrings.get() );
}
return fromStrings( uris.get() );
@@ -45,7 +48,7 @@ static ServerUris fromOptionalStrings(Optional protocol, Optional serverUrisStrings) {
if ( serverUrisStrings.isEmpty() ) {
- throw ConfigurationLog.INSTANCE.emptyListOfUris();
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfUris();
}
HttpHost[] hosts = new HttpHost[serverUrisStrings.size()];
@@ -59,7 +62,7 @@ private static ServerUris fromStrings(List serverUrisStrings) {
https = currentHttps;
}
else if ( currentHttps != https ) {
- throw ConfigurationLog.INSTANCE.differentProtocolsOnUris( serverUrisStrings );
+ throw ElasticsearchClientConfigurationLog.INSTANCE.differentProtocolsOnUris( serverUrisStrings );
}
}
@@ -68,7 +71,7 @@ else if ( currentHttps != https ) {
private static ServerUris fromStrings(String protocol, List hostAndPortStrings) {
if ( hostAndPortStrings.isEmpty() ) {
- throw ConfigurationLog.INSTANCE.emptyListOfHosts();
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfHosts();
}
HttpHost[] hosts = new HttpHost[hostAndPortStrings.size()];
@@ -84,7 +87,7 @@ private static ServerUris fromStrings(String protocol, List hostAndPortS
private static HttpHost createHttpHost(String scheme, String hostAndPort) {
if ( hostAndPort.indexOf( "://" ) >= 0 ) {
- throw ConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, null );
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, null );
}
String host;
int port = -1;
@@ -97,7 +100,7 @@ private static HttpHost createHttpHost(String scheme, String hostAndPort) {
port = Integer.parseInt( hostAndPort.substring( portIdx + 1 ) );
}
catch (final NumberFormatException e) {
- throw ConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, e );
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, e );
}
host = hostAndPort.substring( 0, portIdx );
}
diff --git a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/StubIOControl.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/StubIOControl.java
similarity index 92%
rename from backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/StubIOControl.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/StubIOControl.java
index d3a9186b495..8dc82f6ee8f 100644
--- a/backend/elasticsearch/src/main/java/org/hibernate/search/backend/elasticsearch/client/impl/StubIOControl.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/StubIOControl.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import java.io.IOException;
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/logging/impl/ElasticsearchClientLog.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/logging/impl/ElasticsearchClientLog.java
new file mode 100644
index 00000000000..84102d28f47
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/logging/impl/ElasticsearchClientLog.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.rest.logging.impl;
+
+import java.lang.invoke.MethodHandles;
+
+import org.hibernate.search.util.common.logging.CategorizedLogger;
+import org.hibernate.search.util.common.logging.impl.LoggerFactory;
+import org.hibernate.search.util.common.logging.impl.MessageConstants;
+
+import org.jboss.logging.annotations.MessageLogger;
+
+@CategorizedLogger(
+ category = ElasticsearchClientLog.CATEGORY_NAME,
+ description = """
+ Logs information on low-level Elasticsearch backend operations.
+ +
+ This may include warnings about misconfigured Elasticsearch REST clients or index operations.
+ """
+)
+@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+public interface ElasticsearchClientLog {
+ String CATEGORY_NAME = "org.hibernate.search.elasticsearch.client";
+
+ ElasticsearchClientLog INSTANCE = LoggerFactory.make( ElasticsearchClientLog.class, CATEGORY_NAME, MethodHandles.lookup() );
+
+}
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/package-info.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/package-info.java
new file mode 100644
index 00000000000..e60c5ae145f
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/rest/package-info.java
@@ -0,0 +1 @@
+package org.hibernate.search.backend.elasticsearch.client.rest;
diff --git a/backend/elasticsearch-client/elasticsearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
new file mode 100644
index 00000000000..1c7d97aec0a
--- /dev/null
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
@@ -0,0 +1 @@
+org.hibernate.search.backend.elasticsearch.client.rest.impl.ClientRestElasticsearchClientBeanConfigurer
diff --git a/backend/elasticsearch/src/test/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntityTest.java b/backend/elasticsearch-client/elasticsearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntityTest.java
similarity index 99%
rename from backend/elasticsearch/src/test/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntityTest.java
rename to backend/elasticsearch-client/elasticsearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntityTest.java
index 28fa52595c1..94cee931310 100644
--- a/backend/elasticsearch/src/test/java/org/hibernate/search/backend/elasticsearch/client/impl/GsonHttpEntityTest.java
+++ b/backend/elasticsearch-client/elasticsearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/rest/impl/GsonHttpEntityTest.java
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
-package org.hibernate.search.backend.elasticsearch.client.impl;
+package org.hibernate.search.backend.elasticsearch.client.rest.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -23,7 +23,7 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import org.hibernate.search.backend.elasticsearch.gson.spi.GsonProvider;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
diff --git a/backend/elasticsearch-client/opensearch-rest-client/pom.xml b/backend/elasticsearch-client/opensearch-rest-client/pom.xml
new file mode 100644
index 00000000000..8f081fe7883
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/pom.xml
@@ -0,0 +1,88 @@
+
+
+
+ 4.0.0
+
+ org.hibernate.search
+ hibernate-search-parent-public
+ 8.2.0-SNAPSHOT
+ ../../../build/parents/public
+
+ hibernate-search-backend-elasticsearch-client-opensearch
+
+ Hibernate Search Backend - Elasticsearch client based on the low-level OpenSearch client
+ Hibernate Search Elasticsearch client based on the low-level OpenSearch client
+
+
+
+ false
+ org.hibernate.search.backend.elasticsearch.client.opensearch
+
+
+
+
+ org.hibernate.search
+ hibernate-search-engine
+
+
+ org.hibernate.search
+ hibernate-search-backend-elasticsearch-client-common
+
+
+ org.opensearch.client
+ opensearch-rest-client
+
+
+ org.opensearch.client
+ opensearch-rest-client-sniffer
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ org.apache.httpcomponents.core5
+ httpcore5
+
+
+ org.jboss.logging
+ jboss-logging
+
+
+ org.jboss.logging
+ jboss-logging-annotations
+
+
+ com.google.code.gson
+ gson
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+
+
+ org.hibernate.search
+ hibernate-search-util-internal-test-common
+ test
+
+
+
+
+
+
+ org.moditect
+ moditect-maven-plugin
+
+
+
+
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurationContext.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurationContext.java
new file mode 100644
index 00000000000..45d3a27a2b9
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurationContext.java
@@ -0,0 +1,42 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch;
+
+
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.util.common.annotation.impl.SuppressJQAssistant;
+
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+
+
+/**
+ * The context passed to {@link ElasticsearchHttpClientConfigurer}.
+ */
+@SuppressJQAssistant(
+ reason = "Apache HTTP Client 5 uses a lot of classes/interfaces in the impl packages to create builders/instances etc. "
+ + "So while it is bad to expose impl types ... in this case it's what Apache Client expects users to do?")
+public interface ElasticsearchHttpClientConfigurationContext {
+
+ /**
+ * @return A {@link BeanResolver}.
+ */
+ BeanResolver beanResolver();
+
+ /**
+ * @return A configuration property source, appropriately masked so that the factory
+ * doesn't need to care about Hibernate Search prefixes (hibernate.search.*, etc.). All the properties
+ * can be accessed at the root.
+ * CAUTION: the property key "type" is reserved for use by the engine.
+ */
+ ConfigurationPropertySource configurationPropertySource();
+
+ /**
+ * @return An Apache HTTP client builder, to set the configuration.
+ * @see the Apache HTTP Client documentation
+ */
+ HttpAsyncClientBuilder clientBuilder();
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurer.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurer.java
new file mode 100644
index 00000000000..2cbf4231e74
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/ElasticsearchHttpClientConfigurer.java
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch;
+
+/**
+ * An extension point allowing fine tuning of the Apache HTTP Client used by the Elasticsearch integration.
+ *
+ * This enables in particular connecting to cloud services that require a particular authentication method,
+ * such as request signing on Amazon Web Services.
+ *
+ * The ElasticsearchHttpClientConfigurer implementation will be given access to the HTTP client builder
+ * on startup.
+ *
+ * Note that you don't have to configure the client unless you have specific needs:
+ * the default configuration should work just fine for an on-premise Elasticsearch server.
+ */
+public interface ElasticsearchHttpClientConfigurer {
+
+ /**
+ * Configure the HTTP Client.
+ *
+ * This method is called once for every configurer, each time an Elasticsearch client is set up.
+ *
+ * Implementors should take care of only applying configuration if relevant:
+ * there may be multiple, conflicting configurers in the path, so implementors should first check
+ * (through a configuration property) whether they are needed or not before applying any modification.
+ * For example an authentication configurer could decide not to do anything if no username is provided,
+ * or if the configuration property {@code my.configurer.enabled} is {@code false}.
+ *
+ * @param context A configuration context giving access to the Apache HTTP client builder
+ * and configuration properties in particular.
+ */
+ void configure(ElasticsearchHttpClientConfigurationContext context);
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/ClientOpenSearchElasticsearchBackendClientSettings.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/ClientOpenSearchElasticsearchBackendClientSettings.java
new file mode 100644
index 00000000000..0f7853612de
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/ClientOpenSearchElasticsearchBackendClientSettings.java
@@ -0,0 +1,138 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.cfg;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.opensearch.ElasticsearchHttpClientConfigurer;
+
+/**
+ * Specific configuration properties for the Elasticsearch backend's rest client based on the Elasticsearch's low-level rest client.
+ *
+ * Constants in this class are to be appended to a prefix to form a property key;
+ * see {@link org.hibernate.search.engine.cfg.BackendSettings} for details.
+ *
+ * @author Gunnar Morling
+ */
+public final class ClientOpenSearchElasticsearchBackendClientSettings {
+
+ private ClientOpenSearchElasticsearchBackendClientSettings() {
+ }
+
+ /**
+ * The timeout when executing a request to an Elasticsearch server.
+ *
+ * This includes the time needed to establish a connection, send the request and read the response.
+ *
+ * Expects a positive Integer value in milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to no request timeout.
+ */
+ public static final String REQUEST_TIMEOUT = "request_timeout";
+
+ /**
+ * The timeout when reading responses from an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 60000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#READ_TIMEOUT}.
+ */
+ public static final String READ_TIMEOUT = "read_timeout";
+
+ /**
+ * The timeout when establishing a connection to an Elasticsearch server.
+ *
+ * Expects a positive Integer value in milliseconds, such as {@code 3000},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#CONNECTION_TIMEOUT}.
+ */
+ public static final String CONNECTION_TIMEOUT = "connection_timeout";
+
+ /**
+ * The maximum number of simultaneous connections to the Elasticsearch cluster,
+ * all hosts taken together.
+ *
+ * Expects a positive Integer value, such as {@code 40},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS}.
+ */
+ public static final String MAX_CONNECTIONS = "max_connections";
+
+ /**
+ * The maximum number of simultaneous connections to each host of the Elasticsearch cluster.
+ *
+ * Expects a positive Integer value, such as {@code 20},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#MAX_CONNECTIONS_PER_ROUTE}.
+ */
+ public static final String MAX_CONNECTIONS_PER_ROUTE = "max_connections_per_route";
+
+ /**
+ * Whether automatic discovery of nodes in the Elasticsearch cluster is enabled.
+ *
+ * Expects a Boolean value such as {@code true} or {@code false},
+ * or a string that can be parsed into a Boolean value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_ENABLED}.
+ */
+ public static final String DISCOVERY_ENABLED = "discovery.enabled";
+
+ /**
+ * The time interval between two executions of the automatic discovery, if enabled.
+ *
+ * Expects a positive Integer value in seconds, such as {@code 2},
+ * or a String that can be parsed into such Integer value.
+ *
+ * Defaults to {@link Defaults#DISCOVERY_REFRESH_INTERVAL}.
+ */
+ public static final String DISCOVERY_REFRESH_INTERVAL = "discovery.refresh_interval";
+
+ /**
+ * How long connections to the Elasticsearch cluster can be kept idle.
+ *
+ * Expects a positive Long value of milliseconds, such as 60000,
+ * or a String that can be parsed into such Integer value.
+ *
+ * If the response from an Elasticsearch cluster contains a {@code Keep-Alive} header,
+ * then the effective max idle time will be whichever is lower:
+ * the duration from the {@code Keep-Alive} header or the value of this property (if set).
+ *
+ * If this property is not set, only the {@code Keep-Alive} header is considered,
+ * and if it's absent, idle connections will be kept forever.
+ */
+ public static final String MAX_KEEP_ALIVE = "max_keep_alive";
+
+ /**
+ * A {@link ElasticsearchHttpClientConfigurer} that defines custom HTTP client configuration.
+ *
+ * It can be used for example to tune the SSL context to accept self-signed certificates.
+ * It allows overriding other HTTP client settings, such as {@link ElasticsearchBackendClientCommonSettings#USERNAME} or {@link #MAX_CONNECTIONS_PER_ROUTE}.
+ *
+ * Expects a reference to a bean of type {@link ElasticsearchHttpClientConfigurer}.
+ *
+ * Defaults to no value.
+ */
+ public static final String CLIENT_CONFIGURER = "client.configurer";
+
+ /**
+ * Default values for the different settings if no values are given.
+ */
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+
+ public static final int READ_TIMEOUT = 30000;
+ public static final int CONNECTION_TIMEOUT = 1000;
+ public static final int MAX_CONNECTIONS = 40;
+ public static final int MAX_CONNECTIONS_PER_ROUTE = 20;
+ public static final boolean DISCOVERY_ENABLED = false;
+ public static final int DISCOVERY_REFRESH_INTERVAL = 10;
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/spi/ClientOpenSearchElasticsearchBackendClientSpiSettings.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/spi/ClientOpenSearchElasticsearchBackendClientSpiSettings.java
new file mode 100644
index 00000000000..30ade540944
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/cfg/spi/ClientOpenSearchElasticsearchBackendClientSpiSettings.java
@@ -0,0 +1,55 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.cfg.spi;
+
+import org.hibernate.search.engine.cfg.EngineSettings;
+
+/**
+ * Configuration properties for the Elasticsearch backend that are considered SPI (and not API).
+ */
+public final class ClientOpenSearchElasticsearchBackendClientSpiSettings {
+
+ /**
+ * The prefix expected for the key of every Hibernate Search configuration property.
+ */
+ public static final String PREFIX = EngineSettings.PREFIX + "backend.";
+
+ /**
+ * An external Elasticsearch client instance that Hibernate Search should use for all requests to Elasticsearch.
+ *
+ * If this is set, Hibernate Search will not attempt to create its own Elasticsearch,
+ * and all other client-related configuration properties
+ * (hosts/uris, authentication, discovery, timeouts, max connections, configurer, ...)
+ * will be ignored.
+ *
+ * Expects a reference to a bean of type {@link org.opensearch.client.RestClient}.
+ *
+ * Defaults to nothing: if no client instance is provided, Hibernate Search will create its own.
+ *
+ * WARNING - Incubating API: the underlying client class may change without prior notice.
+ *
+ * @see org.hibernate.search.engine.cfg The core documentation of configuration properties,
+ * which includes a description of the "bean reference" properties and accepted values.
+ */
+ public static final String CLIENT_INSTANCE = "client.instance";
+
+ private ClientOpenSearchElasticsearchBackendClientSpiSettings() {
+ }
+
+ /**
+ * Configuration property keys without the {@link #PREFIX prefix}.
+ */
+ public static class Radicals {
+
+ private Radicals() {
+ }
+ }
+
+ public static final class Defaults {
+
+ private Defaults() {
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ByteBufferDataStreamChannel.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ByteBufferDataStreamChannel.java
new file mode 100644
index 00000000000..9ce04edc378
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ByteBufferDataStreamChannel.java
@@ -0,0 +1,59 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+
+final class ByteBufferDataStreamChannel implements ContentEncoder, DataStreamChannel {
+ private final ByteBuffer buffer;
+ private boolean complete = false;
+
+ ByteBufferDataStreamChannel(ByteBuffer buffer) {
+ this.buffer = buffer;
+ if ( !buffer.hasArray() ) {
+ throw new IllegalArgumentException( getClass().getName() + " requires a ByteBuffer backed by an array." );
+ }
+ }
+
+ @Override
+ public void requestOutput() {
+
+ }
+
+ @Override
+ public int write(ByteBuffer src) {
+ int toWrite = Math.min( src.remaining(), buffer.remaining() );
+ src.get( buffer.array(), buffer.arrayOffset() + buffer.position(), toWrite );
+ buffer.position( buffer.position() + toWrite );
+ return toWrite;
+ }
+
+ @Override
+ public void endStream() throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void endStream(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void complete(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public boolean isCompleted() {
+ return complete;
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClient.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClient.java
new file mode 100644
index 00000000000..eeb7dd493bc
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClient.java
@@ -0,0 +1,271 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.JsonLogHelper;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientLog;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchRequestLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchResponse;
+import org.hibernate.search.backend.elasticsearch.client.common.util.spi.ElasticsearchClientUtils;
+import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
+import org.hibernate.search.engine.common.timing.Deadline;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.util.common.impl.Closer;
+import org.hibernate.search.util.common.impl.Futures;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.util.Timeout;
+import org.opensearch.client.Request;
+import org.opensearch.client.RequestOptions;
+import org.opensearch.client.Response;
+import org.opensearch.client.ResponseException;
+import org.opensearch.client.ResponseListener;
+import org.opensearch.client.RestClient;
+import org.opensearch.client.sniff.Sniffer;
+
+public class ClientOpenSearchElasticsearchClient implements ElasticsearchClientImplementor {
+
+ private final BeanHolder extends RestClient> restClientHolder;
+
+ private final Sniffer sniffer;
+
+ private final SimpleScheduledExecutor timeoutExecutorService;
+
+ private final Optional requestTimeoutMs;
+
+ private final Gson gson;
+ private final JsonLogHelper jsonLogHelper;
+
+ ClientOpenSearchElasticsearchClient(BeanHolder extends RestClient> restClientHolder, Sniffer sniffer,
+ SimpleScheduledExecutor timeoutExecutorService,
+ Optional requestTimeoutMs,
+ Gson gson, JsonLogHelper jsonLogHelper) {
+ this.restClientHolder = restClientHolder;
+ this.sniffer = sniffer;
+ this.timeoutExecutorService = timeoutExecutorService;
+ this.requestTimeoutMs = requestTimeoutMs;
+ this.gson = gson;
+ this.jsonLogHelper = jsonLogHelper;
+ }
+
+ @Override
+ public CompletableFuture submit(ElasticsearchRequest request) {
+ CompletableFuture result = Futures.create( () -> send( request ) )
+ .thenApply( this::convertResponse );
+ if ( ElasticsearchRequestLog.INSTANCE.isDebugEnabled() ) {
+ long startTime = System.nanoTime();
+ result.thenAccept( response -> log( request, startTime, response ) );
+ }
+ return result;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T unwrap(Class clientClass) {
+ if ( RestClient.class.isAssignableFrom( clientClass ) ) {
+ return (T) restClientHolder.get();
+ }
+ throw ElasticsearchClientLog.INSTANCE.clientUnwrappingWithUnknownType( clientClass, RestClient.class );
+ }
+
+ private CompletableFuture send(ElasticsearchRequest elasticsearchRequest) {
+ CompletableFuture completableFuture = new CompletableFuture<>();
+
+ HttpEntity entity;
+ try {
+ entity = GsonHttpEntity.toEntity( gson, elasticsearchRequest );
+ }
+ catch (IOException | RuntimeException e) {
+ completableFuture.completeExceptionally( e );
+ return completableFuture;
+ }
+
+ restClientHolder.get().performRequestAsync(
+ toRequest( elasticsearchRequest, entity ),
+ new ResponseListener() {
+ @Override
+ public void onSuccess(Response response) {
+ completableFuture.complete( response );
+ }
+
+ @Override
+ public void onFailure(Exception exception) {
+ if ( exception instanceof ResponseException ) {
+ /*
+ * The client tries to guess what's an error and what's not, but it's too naive.
+ * A 404 on DELETE is not always important to us, for instance.
+ * Thus we ignore the exception and do our own checks afterwards.
+ */
+ completableFuture.complete( ( (ResponseException) exception ).getResponse() );
+ }
+ else {
+ completableFuture.completeExceptionally( exception );
+ }
+ }
+ }
+ );
+
+ Deadline deadline = elasticsearchRequest.deadline();
+ if ( deadline == null && !requestTimeoutMs.isPresent() ) {
+ // no need to schedule a client side timeout
+ return completableFuture;
+ }
+
+ long currentTimeoutValue =
+ deadline == null ? Long.valueOf( requestTimeoutMs.get() ) : deadline.checkRemainingTimeMillis();
+
+ /*
+ * TODO HSEARCH-3590 maybe the callback should also cancel the request?
+ * In any case, the RestClient doesn't return the Future> from Apache HTTP client,
+ * so we can't do much until this changes.
+ */
+ ScheduledFuture> timeout = timeoutExecutorService.schedule(
+ () -> {
+ if ( !completableFuture.isDone() ) {
+ RuntimeException cause = ElasticsearchClientLog.INSTANCE.requestTimedOut(
+ Duration.ofNanos( TimeUnit.MILLISECONDS.toNanos( currentTimeoutValue ) ),
+ elasticsearchRequest );
+ completableFuture.completeExceptionally(
+ deadline != null ? deadline.forceTimeoutAndCreateException( cause ) : cause
+ );
+ }
+ },
+ currentTimeoutValue, TimeUnit.MILLISECONDS
+ );
+ completableFuture.thenRun( () -> timeout.cancel( false ) );
+
+ return completableFuture;
+ }
+
+ private Request toRequest(ElasticsearchRequest elasticsearchRequest, HttpEntity entity) {
+ Request request = new Request( elasticsearchRequest.method(), elasticsearchRequest.path() );
+ setPerRequestSocketTimeout( elasticsearchRequest, request );
+
+ for ( Entry parameter : elasticsearchRequest.parameters().entrySet() ) {
+ request.addParameter( parameter.getKey(), parameter.getValue() );
+ }
+
+ request.setEntity( entity );
+
+ return request;
+ }
+
+ private void setPerRequestSocketTimeout(ElasticsearchRequest elasticsearchRequest, Request request) {
+ Deadline deadline = elasticsearchRequest.deadline();
+ if ( deadline == null ) {
+ return;
+ }
+
+ long timeToHardTimeout = deadline.checkRemainingTimeMillis();
+
+ // set a per-request socket timeout
+ int generalRequestTimeoutMs = ( timeToHardTimeout <= Integer.MAX_VALUE ) ? Math.toIntExact( timeToHardTimeout ) : -1;
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectionRequestTimeout( Timeout.DISABLED ) //Disable lease handling for the connection pool! See also HSEARCH-2681
+ .setResponseTimeout( generalRequestTimeoutMs, TimeUnit.MILLISECONDS )
+ .build();
+
+ RequestOptions.Builder requestOptions = RequestOptions.DEFAULT.toBuilder()
+ .setRequestConfig( requestConfig );
+
+ request.setOptions( requestOptions );
+ }
+
+ private ElasticsearchResponse convertResponse(Response response) {
+ try {
+ JsonObject body = parseBody( response );
+ return new ElasticsearchResponse(
+ response.getHost().toHostString(),
+ response.getStatusLine().getStatusCode(),
+ response.getStatusLine().getReasonPhrase(),
+ body );
+ }
+ catch (IOException | RuntimeException e) {
+ throw ElasticsearchClientLog.INSTANCE.failedToParseElasticsearchResponse( response.getStatusLine().getStatusCode(),
+ response.getStatusLine().getReasonPhrase(), e.getMessage(), e );
+ }
+ }
+
+ private JsonObject parseBody(Response response) throws IOException {
+ HttpEntity entity = response.getEntity();
+ if ( entity == null ) {
+ return null;
+ }
+
+ Charset charset = getCharset( entity );
+ try ( InputStream inputStream = entity.getContent();
+ Reader reader = new InputStreamReader( inputStream, charset ) ) {
+ return gson.fromJson( reader, JsonObject.class );
+ }
+ }
+
+ private static Charset getCharset(HttpEntity entity) {
+ ContentType contentType = ContentType.parse( entity.getContentType() );
+ Charset charset = contentType.getCharset();
+ return charset != null ? charset : StandardCharsets.UTF_8;
+ }
+
+ private void log(ElasticsearchRequest request, long start, ElasticsearchResponse response) {
+ boolean successCode = ElasticsearchClientUtils.isSuccessCode( response.statusCode() );
+ if ( !ElasticsearchRequestLog.INSTANCE.isTraceEnabled() && successCode ) {
+ return;
+ }
+ long executionTimeNs = System.nanoTime() - start;
+ long executionTimeMs = TimeUnit.NANOSECONDS.toMillis( executionTimeNs );
+ if ( successCode ) {
+ ElasticsearchRequestLog.INSTANCE.executedRequest( request.method(), response.host(), request.path(),
+ request.parameters(),
+ request.bodyParts().size(), executionTimeMs,
+ response.statusCode(), response.statusMessage(),
+ jsonLogHelper.toString( request.bodyParts() ),
+ jsonLogHelper.toString( response.body() ) );
+ }
+ else {
+ ElasticsearchRequestLog.INSTANCE.executedRequestWithFailure( request.method(), response.host(), request.path(),
+ request.parameters(),
+ request.bodyParts().size(), executionTimeMs,
+ response.statusCode(), response.statusMessage(),
+ jsonLogHelper.toString( request.bodyParts() ),
+ jsonLogHelper.toString( response.body() ) );
+ }
+ }
+
+ @Override
+ public void close() {
+ try ( Closer closer = new Closer<>() ) {
+ /*
+ * There's no point waiting for timeouts: we'll just expect the RestClient to cancel all
+ * currently running requests when closing.
+ */
+ closer.push( Sniffer::close, this.sniffer );
+ // The BeanHolder is responsible for calling close() on the client if necessary.
+ closer.push( BeanHolder::close, this.restClientHolder );
+ }
+ catch (RuntimeException | IOException e) {
+ throw ElasticsearchClientLog.INSTANCE.unableToShutdownClient( e.getMessage(), e );
+ }
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientBeanConfigurer.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientBeanConfigurer.java
new file mode 100644
index 00000000000..9133fa8f093
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientBeanConfigurer.java
@@ -0,0 +1,20 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurationContext;
+import org.hibernate.search.engine.environment.bean.spi.BeanConfigurer;
+
+public class ClientOpenSearchElasticsearchClientBeanConfigurer implements BeanConfigurer {
+ @Override
+ public void configure(BeanConfigurationContext context) {
+ context.define(
+ ElasticsearchClientFactory.class, "opensearch-rest-client",
+ beanResolver -> BeanHolder.of( new ClientOpenSearchElasticsearchClientFactory() )
+ );
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientFactory.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientFactory.java
new file mode 100644
index 00000000000..df23e61b47a
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchClientFactory.java
@@ -0,0 +1,358 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.net.SocketAddress;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientFactory;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchClientImplementor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.opensearch.ElasticsearchHttpClientConfigurer;
+import org.hibernate.search.backend.elasticsearch.client.opensearch.cfg.ClientOpenSearchElasticsearchBackendClientSettings;
+import org.hibernate.search.backend.elasticsearch.client.opensearch.cfg.spi.ClientOpenSearchElasticsearchBackendClientSpiSettings;
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.cfg.spi.ConfigurationProperty;
+import org.hibernate.search.engine.cfg.spi.OptionalConfigurationProperty;
+import org.hibernate.search.engine.common.execution.spi.SimpleScheduledExecutor;
+import org.hibernate.search.engine.environment.bean.BeanHolder;
+import org.hibernate.search.engine.environment.bean.BeanReference;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+import org.hibernate.search.engine.environment.thread.spi.ThreadProvider;
+import org.hibernate.search.util.common.impl.SuppressingCloser;
+
+import org.apache.hc.client5.http.auth.AuthScope;
+import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer;
+import org.apache.hc.core5.util.Timeout;
+import org.opensearch.client.RestClient;
+import org.opensearch.client.RestClientBuilder;
+import org.opensearch.client.sniff.NodesSniffer;
+import org.opensearch.client.sniff.OpenSearchNodesSniffer;
+import org.opensearch.client.sniff.Sniffer;
+import org.opensearch.client.sniff.SnifferBuilder;
+
+
+/**
+ * @author Gunnar Morling
+ */
+public class ClientOpenSearchElasticsearchClientFactory implements ElasticsearchClientFactory {
+
+ private static final OptionalConfigurationProperty> CLIENT_INSTANCE =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSpiSettings.CLIENT_INSTANCE )
+ .asBeanReference( RestClient.class )
+ .build();
+
+ private static final OptionalConfigurationProperty> HOSTS =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.HOSTS )
+ .asString().multivalued()
+ .build();
+
+ private static final OptionalConfigurationProperty PROTOCOL =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PROTOCOL )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty> URIS =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.URIS )
+ .asString().multivalued()
+ .build();
+
+ private static final ConfigurationProperty PATH_PREFIX =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PATH_PREFIX )
+ .asString()
+ .withDefault( ElasticsearchBackendClientCommonSettings.Defaults.PATH_PREFIX )
+ .build();
+
+ private static final OptionalConfigurationProperty USERNAME =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.USERNAME )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty PASSWORD =
+ ConfigurationProperty.forKey( ElasticsearchBackendClientCommonSettings.PASSWORD )
+ .asString()
+ .build();
+
+ private static final OptionalConfigurationProperty REQUEST_TIMEOUT =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.REQUEST_TIMEOUT )
+ .asIntegerStrictlyPositive()
+ .build();
+
+ private static final ConfigurationProperty READ_TIMEOUT =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.READ_TIMEOUT )
+ .asIntegerPositiveOrZeroOrNegative()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.READ_TIMEOUT )
+ .build();
+
+ private static final ConfigurationProperty CONNECTION_TIMEOUT =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.CONNECTION_TIMEOUT )
+ .asIntegerPositiveOrZeroOrNegative()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.CONNECTION_TIMEOUT )
+ .build();
+
+ private static final ConfigurationProperty MAX_TOTAL_CONNECTION =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.MAX_CONNECTIONS )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS )
+ .build();
+
+ private static final ConfigurationProperty MAX_TOTAL_CONNECTION_PER_ROUTE =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.MAX_CONNECTIONS_PER_ROUTE )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.MAX_CONNECTIONS_PER_ROUTE )
+ .build();
+
+ private static final ConfigurationProperty DISCOVERY_ENABLED =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.DISCOVERY_ENABLED )
+ .asBoolean()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.DISCOVERY_ENABLED )
+ .build();
+
+ private static final ConfigurationProperty DISCOVERY_REFRESH_INTERVAL =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.DISCOVERY_REFRESH_INTERVAL )
+ .asIntegerStrictlyPositive()
+ .withDefault( ClientOpenSearchElasticsearchBackendClientSettings.Defaults.DISCOVERY_REFRESH_INTERVAL )
+ .build();
+
+ private static final OptionalConfigurationProperty<
+ BeanReference extends ElasticsearchHttpClientConfigurer>> CLIENT_CONFIGURER =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.CLIENT_CONFIGURER )
+ .asBeanReference( ElasticsearchHttpClientConfigurer.class )
+ .build();
+
+ private static final OptionalConfigurationProperty MAX_KEEP_ALIVE =
+ ConfigurationProperty.forKey( ClientOpenSearchElasticsearchBackendClientSettings.MAX_KEEP_ALIVE )
+ .asLongStrictlyPositive()
+ .build();
+
+ @Override
+ public ElasticsearchClientImplementor create(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ SimpleScheduledExecutor timeoutExecutorService,
+ GsonProvider gsonProvider) {
+ Optional requestTimeoutMs = REQUEST_TIMEOUT.get( propertySource );
+
+ Optional> providedRestClientHolder = CLIENT_INSTANCE.getAndMap(
+ propertySource, beanResolver::resolve );
+
+ BeanHolder extends RestClient> restClientHolder;
+ Sniffer sniffer;
+ if ( providedRestClientHolder.isPresent() ) {
+ restClientHolder = providedRestClientHolder.get();
+ sniffer = null;
+ }
+ else {
+ ServerUris hosts = ServerUris.fromOptionalStrings( PROTOCOL.get( propertySource ),
+ HOSTS.get( propertySource ), URIS.get( propertySource ) );
+ restClientHolder = createClient( beanResolver, propertySource, threadProvider, threadNamePrefix,
+ hosts, PATH_PREFIX.get( propertySource ) );
+ sniffer = createSniffer( propertySource, restClientHolder.get(), hosts );
+ }
+
+ return new ClientOpenSearchElasticsearchClient(
+ restClientHolder, sniffer, timeoutExecutorService, requestTimeoutMs,
+ gsonProvider.getGson(), gsonProvider.getLogHelper()
+ );
+ }
+
+ private BeanHolder extends RestClient> createClient(BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ ServerUris hosts, String pathPrefix) {
+ RestClientBuilder builder = RestClient.builder( hosts.asHostsArray() );
+ if ( !pathPrefix.isEmpty() ) {
+ builder.setPathPrefix( pathPrefix );
+ }
+
+ Optional extends BeanHolder extends ElasticsearchHttpClientConfigurer>> customConfig = CLIENT_CONFIGURER
+ .getAndMap( propertySource, beanResolver::resolve );
+
+ RestClient client = null;
+ List> httpClientConfigurerReferences =
+ beanResolver.allConfiguredForRole( ElasticsearchHttpClientConfigurer.class );
+ List> requestInterceptorProviderReferences =
+ beanResolver.allConfiguredForRole( ElasticsearchRequestInterceptorProvider.class );
+ try ( BeanHolder> httpClientConfigurersHolder =
+ beanResolver.resolve( httpClientConfigurerReferences );
+ BeanHolder> requestInterceptorProvidersHodler =
+ beanResolver.resolve( requestInterceptorProviderReferences ) ) {
+ client = builder
+ .setRequestConfigCallback( b -> customizeRequestConfig( b, propertySource ) )
+ .setHttpClientConfigCallback(
+ b -> customizeHttpClientConfig(
+ b,
+ beanResolver, propertySource,
+ threadProvider, threadNamePrefix,
+ hosts, httpClientConfigurersHolder.get(), requestInterceptorProvidersHodler.get(),
+ customConfig
+ )
+ )
+ .build();
+ return BeanHolder.ofCloseable( client );
+ }
+ catch (RuntimeException e) {
+ new SuppressingCloser( e )
+ .push( client );
+ throw e;
+ }
+ finally {
+ if ( customConfig.isPresent() ) {
+ // Assuming that #customizeHttpClientConfig has been already executed
+ // and therefore the bean has been already used.
+ customConfig.get().close();
+ }
+ }
+ }
+
+ private Sniffer createSniffer(ConfigurationPropertySource propertySource,
+ RestClient client, ServerUris hosts) {
+ boolean discoveryEnabled = DISCOVERY_ENABLED.get( propertySource );
+ if ( discoveryEnabled ) {
+ SnifferBuilder builder = Sniffer.builder( client )
+ .setSniffIntervalMillis(
+ DISCOVERY_REFRESH_INTERVAL.get( propertySource ) * 1_000 // The configured value is in seconds
+ );
+
+ // https discovery support
+ if ( hosts.isSslEnabled() ) {
+ NodesSniffer hostsSniffer = new OpenSearchNodesSniffer(
+ client,
+ OpenSearchNodesSniffer.DEFAULT_SNIFF_REQUEST_TIMEOUT, // 1sec
+ OpenSearchNodesSniffer.Scheme.HTTPS );
+ builder.setNodesSniffer( hostsSniffer );
+ }
+ return builder.build();
+ }
+ else {
+ return null;
+ }
+ }
+
+ private HttpAsyncClientBuilder customizeHttpClientConfig(HttpAsyncClientBuilder builder,
+ BeanResolver beanResolver, ConfigurationPropertySource propertySource,
+ ThreadProvider threadProvider, String threadNamePrefix,
+ ServerUris hosts, Iterable configurers,
+ Iterable requestInterceptorProviders,
+ Optional extends BeanHolder extends ElasticsearchHttpClientConfigurer>> customConfig) {
+ builder.setThreadFactory( threadProvider.createThreadFactory( threadNamePrefix + " - Transport thread" ) );
+
+ PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .setMaxConnTotal( MAX_TOTAL_CONNECTION.get( propertySource ) )
+ .setMaxConnPerRoute( MAX_TOTAL_CONNECTION_PER_ROUTE.get( propertySource ) );
+
+ if ( !hosts.isSslEnabled() ) {
+ // In this case disable the SSL capability as it might have an impact on
+ // bootstrap time, for example consuming entropy for no reason
+ // connectionManagerBuilder.setTlsStrategy(
+ // ClientTlsStrategyBuilder.create()
+ // .setSslContext(
+ // SSLContextBuilder.create()
+ // .loadTrustMaterial(null, new TrustAllStrategy())
+ // .buildAsync()
+ // )
+ // .setHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ // .build()
+ // );
+ connectionManagerBuilder.setTlsStrategy( NoopTlsStrategy.INSTANCE );
+ }
+
+ builder.setConnectionManager(
+ connectionManagerBuilder
+ .setDefaultConnectionConfig(
+ ConnectionConfig.copy( ConnectionConfig.DEFAULT )
+ .setConnectTimeout( CONNECTION_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS )
+ .setSocketTimeout( READ_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS )
+ .build()
+ )
+ .build()
+ );
+
+ Optional username = USERNAME.get( propertySource );
+ if ( username.isPresent() ) {
+ Optional password = PASSWORD.get( propertySource ).map( String::toCharArray );
+ if ( password.isPresent() && !hosts.isSslEnabled() ) {
+ ElasticsearchClientConfigurationLog.INSTANCE.usingPasswordOverHttp();
+ }
+
+ BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+ credentialsProvider.setCredentials(
+ new AuthScope( null, null, -1, null, null ),
+ new UsernamePasswordCredentials( username.get(), password.orElse( null ) )
+ );
+
+ builder.setDefaultCredentialsProvider( credentialsProvider );
+ }
+
+ Optional maxKeepAlive = MAX_KEEP_ALIVE.get( propertySource );
+ if ( maxKeepAlive.isPresent() ) {
+ builder.setKeepAliveStrategy( new CustomConnectionKeepAliveStrategy( maxKeepAlive.get() ) );
+ }
+
+ ClientOpenSearchElasticsearchHttpClientConfigurationContext clientConfigurationContext =
+ new ClientOpenSearchElasticsearchHttpClientConfigurationContext( beanResolver, propertySource, builder );
+
+ for ( ElasticsearchHttpClientConfigurer configurer : configurers ) {
+ configurer.configure( clientConfigurationContext );
+ }
+ for ( ElasticsearchRequestInterceptorProvider interceptorProvider : requestInterceptorProviders ) {
+ Optional requestInterceptor =
+ interceptorProvider.provide( clientConfigurationContext );
+ if ( requestInterceptor.isPresent() ) {
+ builder.addRequestInterceptorLast( new ClientOpenSearchHttpRequestInterceptor( requestInterceptor.get() ) );
+ }
+ }
+ if ( customConfig.isPresent() ) {
+ BeanHolder extends ElasticsearchHttpClientConfigurer> customConfigBeanHolder = customConfig.get();
+ customConfigBeanHolder.get().configure( clientConfigurationContext );
+ }
+
+ return builder;
+ }
+
+ private RequestConfig.Builder customizeRequestConfig(RequestConfig.Builder builder,
+ ConfigurationPropertySource propertySource) {
+ return builder
+ .setConnectionRequestTimeout( Timeout.DISABLED ) //Disable lease handling for the connection pool! See also HSEARCH-2681
+ .setResponseTimeout( READ_TIMEOUT.get( propertySource ), TimeUnit.MILLISECONDS );
+ }
+
+ private static class NoopTlsStrategy implements TlsStrategy {
+ private static final NoopTlsStrategy INSTANCE = new NoopTlsStrategy();
+
+ private NoopTlsStrategy() {
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public boolean upgrade(TransportSecurityLayer sessionLayer, HttpHost host, SocketAddress localAddress,
+ SocketAddress remoteAddress, Object attachment, Timeout handshakeTimeout) {
+ throw new UnsupportedOperationException( "upgrade is not supported." );
+ }
+
+ @Override
+ public void upgrade(TransportSecurityLayer sessionLayer, NamedEndpoint endpoint, Object attachment,
+ Timeout handshakeTimeout, FutureCallback callback) {
+ if ( callback != null ) {
+ callback.completed( sessionLayer );
+ }
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchHttpClientConfigurationContext.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchHttpClientConfigurationContext.java
new file mode 100644
index 00000000000..4d45960c801
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchElasticsearchHttpClientConfigurationContext.java
@@ -0,0 +1,44 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptorProvider;
+import org.hibernate.search.backend.elasticsearch.client.opensearch.ElasticsearchHttpClientConfigurationContext;
+import org.hibernate.search.engine.cfg.ConfigurationPropertySource;
+import org.hibernate.search.engine.environment.bean.BeanResolver;
+
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+
+final class ClientOpenSearchElasticsearchHttpClientConfigurationContext
+ implements ElasticsearchHttpClientConfigurationContext, ElasticsearchRequestInterceptorProvider.Context {
+ private final BeanResolver beanResolver;
+ private final ConfigurationPropertySource configurationPropertySource;
+ private final HttpAsyncClientBuilder clientBuilder;
+
+ ClientOpenSearchElasticsearchHttpClientConfigurationContext(
+ BeanResolver beanResolver,
+ ConfigurationPropertySource configurationPropertySource,
+ HttpAsyncClientBuilder clientBuilder) {
+ this.beanResolver = beanResolver;
+ this.configurationPropertySource = configurationPropertySource;
+ this.clientBuilder = clientBuilder;
+ }
+
+ @Override
+ public BeanResolver beanResolver() {
+ return beanResolver;
+ }
+
+ @Override
+ public ConfigurationPropertySource configurationPropertySource() {
+ return configurationPropertySource;
+ }
+
+ @Override
+ public HttpAsyncClientBuilder clientBuilder() {
+ return clientBuilder;
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchHttpRequestInterceptor.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchHttpRequestInterceptor.java
new file mode 100644
index 00000000000..eda90a14caf
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ClientOpenSearchHttpRequestInterceptor.java
@@ -0,0 +1,142 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequestInterceptor;
+import org.hibernate.search.util.common.AssertionFailure;
+
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpEntityContainer;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpRequestInterceptor;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.net.URIBuilder;
+
+record ClientOpenSearchHttpRequestInterceptor(ElasticsearchRequestInterceptor elasticsearchRequestInterceptor)
+ implements HttpRequestInterceptor {
+
+ @Override
+ public void process(HttpRequest request, EntityDetails entity, HttpContext context) throws IOException {
+ elasticsearchRequestInterceptor.intercept(
+ new ClientOpenSearchRequestContext( request, entity, context )
+ );
+ }
+
+ private record ClientOpenSearchRequestContext(HttpRequest request, EntityDetails entity, HttpClientContext clientContext)
+ implements ElasticsearchRequestInterceptor.RequestContext {
+ private ClientOpenSearchRequestContext(HttpRequest request, EntityDetails entity, HttpContext context) {
+ this( request, entity, HttpClientContext.cast( context ) );
+ }
+
+ @Override
+ public boolean hasContent() {
+ return entity != null;
+ }
+
+ @Override
+ public InputStream content() throws IOException {
+ HttpEntity localEntity = null;
+ if ( entity instanceof HttpEntity httpEntity ) {
+ localEntity = httpEntity;
+ }
+ else if ( request instanceof HttpEntityContainer entityContainer ) {
+ localEntity = entityContainer.getEntity();
+ }
+
+ if ( localEntity != null ) {
+ if ( !localEntity.isRepeatable() ) {
+ throw new AssertionFailure( "Cannot sign AWS requests with non-repeatable entities" );
+ }
+ return localEntity.getContent();
+ }
+
+ if ( entity instanceof AsyncEntityProducer producer ) {
+ if ( !producer.isRepeatable() ) {
+ throw new AssertionFailure( "Cannot sign AWS requests with non-repeatable entities" );
+ }
+ return new HttpAsyncEntityProducerInputStream( producer, 1024 );
+ }
+ return null;
+ }
+
+ @Override
+ public String scheme() {
+ return clientContext.getHttpRoute().getTargetHost().getSchemeName();
+ }
+
+ @Override
+ public String host() {
+ return clientContext.getHttpRoute().getTargetHost().getHostName();
+ }
+
+ @Override
+ public Integer port() {
+ return clientContext.getHttpRoute().getTargetHost().getPort();
+ }
+
+ @Override
+ public String method() {
+ return request.getMethod();
+ }
+
+ @Override
+ public String path() {
+ try {
+ return request.getUri().getPath();
+ }
+ catch (URISyntaxException e) {
+ return request.getPath();
+ }
+ }
+
+ @Override
+ public Map queryParameters() {
+ try {
+ List queryParameters = new URIBuilder( request.getUri() ).getQueryParams();
+ Map map = new HashMap<>();
+ for ( NameValuePair parameter : queryParameters ) {
+ map.put( parameter.getName(), parameter.getValue() );
+ }
+ return map;
+ }
+ catch (URISyntaxException e) {
+ return Map.of();
+ }
+ }
+
+ @Override
+ public void overrideHeaders(Map> headers) {
+ for ( Map.Entry> header : headers.entrySet() ) {
+ String name = header.getKey();
+ boolean first = true;
+ for ( String value : header.getValue() ) {
+ if ( first ) {
+ request.setHeader( name, value );
+ first = false;
+ }
+ else {
+ request.addHeader( name, value );
+ }
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return request.toString();
+ }
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CountingOutputStream.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CountingOutputStream.java
new file mode 100644
index 00000000000..12b5e8e7c55
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CountingOutputStream.java
@@ -0,0 +1,46 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+final class CountingOutputStream extends FilterOutputStream {
+
+ private long bytesWritten = 0L;
+
+ public CountingOutputStream(OutputStream out) {
+ super( out );
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ out.write( b );
+ count( 1 );
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ write( b, 0, b.length );
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ out.write( b, off, len );
+ count( len );
+ }
+
+ void count(int written) {
+ if ( written > 0 ) {
+ bytesWritten += written;
+ }
+ }
+
+ public long getBytesWritten() {
+ return bytesWritten;
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CustomConnectionKeepAliveStrategy.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CustomConnectionKeepAliveStrategy.java
new file mode 100644
index 00000000000..3ed33918615
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/CustomConnectionKeepAliveStrategy.java
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
+import org.apache.hc.client5.http.impl.DefaultConnectionKeepAliveStrategy;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.TimeValue;
+
+final class CustomConnectionKeepAliveStrategy implements ConnectionKeepAliveStrategy {
+
+ private final TimeValue maxKeepAlive;
+ private final long maxKeepAliveMilliseconds;
+
+ CustomConnectionKeepAliveStrategy(long maxKeepAlive) {
+ this.maxKeepAlive = TimeValue.of( maxKeepAlive, TimeUnit.MILLISECONDS );
+ this.maxKeepAliveMilliseconds = maxKeepAlive;
+ }
+
+ @Override
+ public TimeValue getKeepAliveDuration(HttpResponse response, HttpContext context) {
+ // get a keep alive from a request header if one is present
+ TimeValue keepAliveDuration = DefaultConnectionKeepAliveStrategy.INSTANCE.getKeepAliveDuration( response, context );
+ long keepAliveDurationMilliseconds = keepAliveDuration.toMilliseconds();
+ // if the keep alive timeout from a request is less than configured one - let's honor it:
+ if ( keepAliveDurationMilliseconds > 0 && keepAliveDurationMilliseconds < maxKeepAliveMilliseconds ) {
+ return keepAliveDuration;
+ }
+ return maxKeepAlive;
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntity.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntity.java
new file mode 100644
index 00000000000..63900b05260
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntity.java
@@ -0,0 +1,321 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+
+import org.hibernate.search.backend.elasticsearch.client.common.spi.ElasticsearchRequest;
+import org.hibernate.search.util.common.impl.Contracts;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import org.apache.hc.core5.function.Supplier;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+/**
+ * Optimised adapter to encode GSON objects into HttpEntity instances.
+ * The naive approach was using various StringBuilders; the objects we
+ * need to serialise into JSON might get large and this was causing the
+ * internal StringBuilder buffers to need frequent resizing and cause
+ * problems with excessive allocations.
+ *
+ * Rather than trying to guess reasonable default sizes for these buffers,
+ * we can defer the serialisation to write directly into the ByteBuffer
+ * of the HTTP client, this has the additional benefit of making the
+ * intermediary buffers short lived.
+ *
+ * The one complexity to watch for is flow control: when writing into
+ * the output buffer chances are that not all bytes are accepted; in
+ * this case we have to hold on the remaining portion of data to
+ * be written when the flow control is re-enabled.
+ *
+ * A side effect of this strategy is that the total content length which
+ * is being produced is not known in advance. Not reporting the length
+ * in advance to the Apache Http client causes it to use chunked-encoding,
+ * which is great for large blocks but not optimal for small messages.
+ * For this reason we attempt to start encoding into a small buffer
+ * upfront: if all data we need to produce fits into that then we can
+ * report the content length; if not the encoding completion will be deferred
+ * but not resetting so to avoid repeating encoding work.
+ *
+ * @author Sanne Grinovero (C) 2017 Red Hat Inc.
+ */
+final class GsonHttpEntity implements HttpEntity, AsyncEntityProducer {
+
+ private static final Charset CHARSET = StandardCharsets.UTF_8;
+
+ private static final String CONTENT_TYPE = ContentType.APPLICATION_JSON.toString();
+
+ /**
+ * The size of byte buffer pages in {@link ProgressiveCharBufferWriter}
+ * It's a rather large size: a tradeoff for very large JSON
+ * documents as we do heavy bulking, and not too large to
+ * be a penalty for small requests.
+ * 1024 has been shown to produce reasonable, TLAB only garbage.
+ */
+ private static final int BYTE_BUFFER_PAGE_SIZE = 1024;
+
+ /**
+ * We want the char buffer and byte buffer pages of approximately
+ * the same size, however one is in characters and the other in bytes.
+ * Considering we hardcoded UTF-8 as encoding, which has an average
+ * conversion ratio of almost 1.0, this should be close enough.
+ */
+ private static final int CHAR_BUFFER_SIZE = BYTE_BUFFER_PAGE_SIZE;
+
+ public static HttpEntity toEntity(Gson gson, ElasticsearchRequest request) throws IOException {
+ final List bodyParts = request.bodyParts();
+ if ( bodyParts.isEmpty() ) {
+ return null;
+ }
+ return new GsonHttpEntity( gson, bodyParts );
+ }
+
+ private final Gson gson;
+ private final List bodyParts;
+
+ /**
+ * We don't want to compute the length in advance as it would defeat the optimisations
+ * for large bulks.
+ * Still it's possible that we happen to find out, for example if a Digest from all
+ * content needs to be computed, or if the content is small enough as we attempt
+ * to serialise at least one page.
+ */
+ private long contentLength;
+
+ /**
+ * We can lazily compute the contentLength, but we need to avoid changing the value
+ * we report over time as this confuses the Apache HTTP client as it initially defines
+ * the encoding strategy based on this, then assumes it can rely on this being
+ * a constant.
+ * After the {@link #getContentLength()} was invoked at least once, freeze
+ * the value.
+ */
+ private boolean contentLengthWasProvided = false;
+
+ /**
+ * Since flow control might hint to stop producing data,
+ * while we can't interrupt the rendering of a single JSON body
+ * we can avoid starting the rendering of any subsequent JSON body.
+ * So keep track of the next body which still needs to be rendered;
+ * to allow the output to be "repeatable" we also need to reset this
+ * at the end.
+ */
+ private int nextBodyToEncodeIndex = 0;
+
+ /**
+ * Adaptor from string output rendered into the actual output sink.
+ * We keep this as a field level attribute as we might have
+ * partially rendered JSON stored in its buffers while flow control
+ * refuses to accept more bytes.
+ */
+ private ProgressiveCharBufferWriter writer =
+ new ProgressiveCharBufferWriter( CHARSET, CHAR_BUFFER_SIZE, BYTE_BUFFER_PAGE_SIZE );
+
+ public GsonHttpEntity(Gson gson, List bodyParts) throws IOException {
+ Contracts.assertNotNull( gson, "gson" );
+ Contracts.assertNotNull( bodyParts, "bodyParts" );
+ this.gson = gson;
+ this.bodyParts = bodyParts;
+ this.contentLength = -1;
+ attemptOnePassEncoding();
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return true;
+ }
+
+ @Override
+ public void failed(Exception cause) {
+
+ }
+
+ @Override
+ public boolean isChunked() {
+ return false;
+ }
+
+ @Override
+ public Set getTrailerNames() {
+ return Set.of();
+ }
+
+ @Override
+ public long getContentLength() {
+ this.contentLengthWasProvided = true;
+ return this.contentLength;
+ }
+
+ @Override
+ public String getContentType() {
+ return CONTENT_TYPE;
+ }
+
+ @Override
+ public String getContentEncoding() {
+ //Apparently this is the correct value:
+ return null;
+ }
+
+ @Override
+ public InputStream getContent() {
+ return new HttpAsyncEntityProducerInputStream( this, BYTE_BUFFER_PAGE_SIZE );
+ }
+
+ @Override
+ public void writeTo(OutputStream out) throws IOException {
+ /*
+ * For this method we use no pagination, so ignore the mutable fields.
+ *
+ * Note we don't close the counting stream or the writer,
+ * because we must not close the output stream that was passed as a parameter.
+ */
+ CountingOutputStream countingStream = new CountingOutputStream( out );
+ Writer outWriter = new OutputStreamWriter( countingStream, CHARSET );
+ for ( JsonObject bodyPart : bodyParts ) {
+ gson.toJson( bodyPart, outWriter );
+ outWriter.append( '\n' );
+ }
+ outWriter.flush();
+ //Now we finally know the content size in bytes:
+ hintContentLength( countingStream.getBytesWritten() );
+ }
+
+ @Override
+ public boolean isStreaming() {
+ return false;
+ }
+
+ @Override
+ public Supplier> getTrailers() {
+ return null;
+ }
+
+ @Override
+ public void close() {
+ //Nothing to close but let's make sure we re-wind the stream
+ //so that we can start from the beginning if needed
+ this.nextBodyToEncodeIndex = 0;
+ //Discard previous buffers as they might contain in-process content:
+ this.writer = new ProgressiveCharBufferWriter( CHARSET, CHAR_BUFFER_SIZE, BYTE_BUFFER_PAGE_SIZE );
+ }
+
+ /**
+ * Let's see if we can fully encode the content with a minimal write,
+ * i.e. only one body part.
+ * This will allow us to keep the memory consumption reasonable
+ * while also being able to hint the client about the {@link #getContentLength()}.
+ * Incidentally, having this information would avoid chunked output encoding
+ * which is ideal precisely for small messages which can fit into a single buffer.
+ *
+ * @throws IOException This is unlikely to be caused by a real IO operation as there's no output buffer yet,
+ * but it could also be triggered by the UTF8 encoding operations.
+ */
+ private void attemptOnePassEncoding() throws IOException {
+ // Essentially attempt to use the writer without going NPE on the output sink
+ // as it's not set yet.
+ triggerFullWrite();
+ if ( nextBodyToEncodeIndex == bodyParts.size() ) {
+ writer.flush();
+ // The buffer's content length so far is the final content length,
+ // as we know the entire content has been encoded already.
+ hintContentLength( writer.contentLength() );
+ }
+ }
+
+ /**
+ * Higher level write loop. It will start writing the JSON objects
+ * from either the beginning or the next object which wasn't written yet
+ * but simply stop and return as soon as the sink can't accept more data.
+ * Checking state of writer.flowControlPushingBack will reveal if everything
+ * was written.
+ * @throws IOException If writing fails.
+ */
+ private void triggerFullWrite() throws IOException {
+ while ( nextBodyToEncodeIndex < bodyParts.size() ) {
+ JsonObject bodyPart = bodyParts.get( nextBodyToEncodeIndex++ );
+ gson.toJson( bodyPart, writer );
+ writer.append( '\n' );
+ writer.flush();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+ }
+ }
+
+ @Override
+ public int available() {
+ return 0;
+ }
+
+ @Override
+ public void produce(DataStreamChannel channel) throws IOException {
+ Contracts.assertNotNull( channel, "channel" );
+ // Warning: this method is possibly invoked multiple times, depending on the output buffers
+ // to have available space !
+ // Production of data is expected to complete only after we invoke ContentEncoder#complete.
+
+ //Re-set the encoder as it might be a different one than a previously used instance:
+ writer.setOutput( channel );
+
+ //First write unfinished business from previous attempts
+ writer.resumePendingWrites();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+
+ triggerFullWrite();
+
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+ writer.flushToOutput();
+ if ( writer.isFlowControlPushingBack() ) {
+ //Just quit: return control to the caller and trust we'll be called again.
+ return;
+ }
+
+ // If we haven't aborted yet, we finished!
+
+ // The buffer's content length so far is the final content length,
+ // as we know the entire content has been encoded already.
+ // Hint at the content length.
+ // Note this is only useful if produceContent was called by some process
+ // that is not the HTTP client itself (e.g. for request signing),
+ // because the HTTP Client itself will request the size before it starts writing content.
+ hintContentLength( writer.contentLength() );
+
+ channel.endStream();
+ this.releaseResources();
+ }
+
+ private void hintContentLength(long contentLength) {
+ if ( !contentLengthWasProvided ) {
+ this.contentLength = contentLength;
+ }
+ }
+
+ @Override
+ public void releaseResources() {
+ close();
+ }
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/HttpAsyncEntityProducerInputStream.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/HttpAsyncEntityProducerInputStream.java
new file mode 100644
index 00000000000..97f532e96ad
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/HttpAsyncEntityProducerInputStream.java
@@ -0,0 +1,83 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+
+
+final class HttpAsyncEntityProducerInputStream extends InputStream {
+ private final AsyncEntityProducer entityProducer;
+ private final ByteBuffer buffer;
+ private final ByteBufferDataStreamChannel contentEncoder;
+
+ public HttpAsyncEntityProducerInputStream(AsyncEntityProducer entityProducer, int bufferSize) {
+ this.entityProducer = entityProducer;
+ this.buffer = ByteBuffer.allocate( bufferSize );
+ this.buffer.limit( 0 );
+ this.contentEncoder = new ByteBufferDataStreamChannel( buffer );
+ }
+
+ @Override
+ public int read() throws IOException {
+ int read = readFromBuffer();
+ if ( read < 0 && !contentEncoder.isCompleted() ) {
+ writeToBuffer();
+ read = readFromBuffer();
+ }
+ return read;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int offset = off;
+ int length = len;
+ while ( length > 0 && ( buffer.remaining() > 0 || !contentEncoder.isCompleted() ) ) {
+ if ( buffer.remaining() == 0 ) {
+ writeToBuffer();
+ }
+ int bytesRead = readFromBuffer( b, offset, length );
+ offset += bytesRead;
+ length -= bytesRead;
+ }
+ int totalBytesRead = offset - off;
+ if ( totalBytesRead == 0 && contentEncoder.isCompleted() ) {
+ return -1;
+ }
+ return totalBytesRead;
+ }
+
+ @Override
+ public void close() {
+ entityProducer.releaseResources();
+ }
+
+ private void writeToBuffer() throws IOException {
+ buffer.clear();
+ entityProducer.produce( contentEncoder );
+ buffer.flip();
+ }
+
+ private int readFromBuffer() {
+ if ( buffer.hasRemaining() ) {
+ return buffer.get();
+ }
+ else {
+ return -1;
+ }
+ }
+
+ private int readFromBuffer(byte[] bytes, int offset, int length) {
+ int toRead = Math.min( buffer.remaining(), length );
+ if ( toRead > 0 ) {
+ buffer.get( bytes, offset, toRead );
+ }
+ return toRead;
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ProgressiveCharBufferWriter.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ProgressiveCharBufferWriter.java
new file mode 100644
index 00000000000..e25330c4542
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ProgressiveCharBufferWriter.java
@@ -0,0 +1,288 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CoderResult;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+/**
+ * A writer to a ContentEncoder, using an automatically growing, paged buffer
+ * to store input when flow control pushes back.
+ *
+ * To be used when your input source is not reactive (uses {@link Writer}),
+ * but you have multiple elements to write and thus could take advantage of
+ * reactive output to some extent.
+ *
+ * @author Sanne Grinovero
+ */
+class ProgressiveCharBufferWriter extends Writer {
+
+ private final CharsetEncoder charsetEncoder;
+
+ /**
+ * Size of buffer pages.
+ */
+ private final int pageSize;
+
+ /**
+ * A higher-level buffer for chars, so that we don't have
+ * to wrap every single incoming char[] into a CharBuffer.
+ */
+ private final CharBuffer charBuffer;
+
+ /**
+ * Filled buffer pages to be written, in write order.
+ */
+ private final Deque needWritingPages = new ArrayDeque<>( 5 );
+
+ /**
+ * Current buffer page, potentially null,
+ * which may have some content but isn't full yet.
+ */
+ private ByteBuffer currentPage;
+
+ /**
+ * Initially null: must be set before writing is started and each
+ * time it's resumed as it might change between writes during
+ * chunked encoding.
+ */
+ private DataStreamChannel channel;
+
+ /**
+ * Set this to true when we detect clogging, so we can stop trying.
+ * Make sure to reset this when the HTTP Client hints so.
+ * It's never dangerous to re-enable, just not efficient to try writing
+ * unnecessarily.
+ */
+ private boolean flowControlPushingBack = false;
+
+ private int contentLength = 0;
+
+ public ProgressiveCharBufferWriter(Charset charset, int charBufferSize, int pageSize) {
+ this.charsetEncoder = charset.newEncoder();
+ this.pageSize = pageSize;
+ this.charBuffer = CharBuffer.allocate( charBufferSize );
+ }
+
+ /**
+ * Set the encoder to write to when buffers are full.
+ */
+ public void setOutput(DataStreamChannel channel) {
+ this.channel = channel;
+ }
+
+ // Overrides super.write(int) to remove the synchronized() wrapper.
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(int c) throws IOException {
+ if ( 1 > charBuffer.remaining() ) {
+ flush();
+ }
+ charBuffer.put( (char) c );
+ }
+
+ // Overrides super.write(String, int, int) to remove the synchronized() wrapper.
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(String str, int off, int len) throws IOException {
+ // See write(char[], int, int) for comments.
+ if ( len > charBuffer.capacity() ) {
+ flush();
+ writeToByteBuffer( CharBuffer.wrap( str, off, off + len ) );
+ }
+ else if ( len > charBuffer.remaining() ) {
+ flush();
+ charBuffer.put( str, off, off + len );
+ }
+ else {
+ charBuffer.put( str, off, off + len );
+ }
+ }
+
+ // WARNING: when you update this method, make sure to update ALL write(...) methods.
+ @Override
+ public void write(char[] cbuf, int off, int len) throws IOException {
+ if ( len > charBuffer.capacity() ) {
+ /*
+ * "cbuf" won't fit in our char buffer, so we'll just write
+ * everything to the byte buffer (first the pending chars in the
+ * char buffer, then "cbuf").
+ */
+ flush();
+ writeToByteBuffer( CharBuffer.wrap( cbuf, off, len ) );
+ }
+ else if ( len > charBuffer.remaining() ) {
+ /*
+ * We flush the buffer before writing anything in this case.
+ *
+ * If we did not, we'd run the risk of splitting a 3 or 4-byte
+ * character in two parts (one at the end of the buffer before
+ * flushing it, and the other at the beginning after flushing it),
+ * and the encoder would fail when encoding the second part.
+ *
+ * See HSEARCH-2886.
+ */
+ flush();
+ charBuffer.put( cbuf, off, len );
+ }
+ else {
+ charBuffer.put( cbuf, off, len );
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ if ( charBuffer.position() == 0 ) {
+ return;
+ }
+ charBuffer.flip();
+ writeToByteBuffer( charBuffer );
+ charBuffer.clear();
+
+ // don't flush byte buffers to output as we want to control that flushing independently.
+ }
+
+ @Override
+ public void close() {
+ // Nothing to do
+ }
+
+ /**
+ * Send all full buffer pages to the {@link #setOutput(DataStreamChannel) output}.
+ *
+ * Flow control may push back, in which case this method or {@link #flushToOutput()}
+ * should be called again later.
+ *
+ * @throws IOException when {@link ContentEncoder#write(ByteBuffer)} fails.
+ */
+ public void resumePendingWrites() throws IOException {
+ flush();
+ flowControlPushingBack = false;
+ attemptFlushPendingBuffers( false );
+ }
+
+ /**
+ * @return {@code true} if the {@link #setOutput(DataStreamChannel) output} pushed
+ * back the last time a write was attempted, {@code false} otherwise.
+ */
+ public boolean isFlowControlPushingBack() {
+ return flowControlPushingBack;
+ }
+
+ /**
+ * Send all buffer pages to the {@link #setOutput(DataStreamChannel) output},
+ * Even those that are not full yet
+ *
+ * Flow control may push back, in which case this method should be called again later.
+ *
+ * @throws IOException when {@link ContentEncoder#write(ByteBuffer)} fails.
+ */
+ public void flushToOutput() throws IOException {
+ flush();
+ flowControlPushingBack = false;
+ attemptFlushPendingBuffers( true );
+ }
+
+ /**
+ * @return The length of the content stored in the byte buffers so far, in bytes.
+ * This does include the content that has already been written to the {@link #setOutput(DataStreamChannel) output},
+ * but not the content of the char buffer (which can be flushed to byte buffers using {@link #flush()}).
+ */
+ public int contentLength() {
+ return contentLength;
+ }
+
+ private void writeToByteBuffer(CharBuffer input) throws IOException {
+ while ( true ) {
+ if ( currentPage == null ) {
+ currentPage = ByteBuffer.allocate( pageSize );
+ }
+ int initialPagePosition = currentPage.position();
+ CoderResult coderResult = charsetEncoder.encode( input, currentPage, false );
+ contentLength += ( currentPage.position() - initialPagePosition );
+ if ( coderResult.equals( CoderResult.UNDERFLOW ) ) {
+ return;
+ }
+ else if ( coderResult.equals( CoderResult.OVERFLOW ) ) {
+ // Avoid storing buffers if we can simply flush them
+ attemptFlushPendingBuffers( true );
+ if ( currentPage != null ) {
+ /*
+ * We couldn't flush the current page, but it's full,
+ * so let's move it out of the way.
+ */
+ currentPage.flip();
+ needWritingPages.add( currentPage );
+ currentPage = null;
+ }
+ }
+ else {
+ //Encoding exception
+ coderResult.throwException();
+ return; //Unreachable
+ }
+ }
+ }
+
+ /**
+ * @return {@code true} if this buffer contains content to be written, {@code false} otherwise.
+ */
+ private boolean hasRemaining() {
+ return !needWritingPages.isEmpty() || currentPage != null && currentPage.position() > 0;
+ }
+
+ private void attemptFlushPendingBuffers(boolean flushCurrentPage) throws IOException {
+ if ( channel == null ) {
+ flowControlPushingBack = true;
+ }
+ if ( flowControlPushingBack || !hasRemaining() ) {
+ // Nothing to do
+ return;
+ }
+ Iterator iterator = needWritingPages.iterator();
+ while ( iterator.hasNext() && !flowControlPushingBack ) {
+ ByteBuffer buffer = iterator.next();
+ boolean written = write( buffer );
+ if ( written ) {
+ iterator.remove();
+ }
+ else {
+ flowControlPushingBack = true;
+ }
+ }
+ if ( flushCurrentPage && !flowControlPushingBack && currentPage != null && currentPage.position() > 0 ) {
+ // The encoder still accepts some input, and we are allowed to flush the current page. Let's do.
+ currentPage.flip();
+ boolean written = write( currentPage );
+ if ( !written ) {
+ flowControlPushingBack = true;
+ needWritingPages.add( currentPage );
+ }
+ currentPage = null;
+ }
+ }
+
+ private boolean write(ByteBuffer buffer) throws IOException {
+ final int toWrite = buffer.remaining();
+ // We should never do 0-length writes, see HSEARCH-2854
+ if ( toWrite == 0 ) {
+ return true;
+ }
+ final int actuallyWritten = channel.write( buffer );
+ return toWrite == actuallyWritten;
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ServerUris.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ServerUris.java
new file mode 100644
index 00000000000..8ca151e9709
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/ServerUris.java
@@ -0,0 +1,126 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import org.hibernate.search.backend.elasticsearch.client.common.cfg.ElasticsearchBackendClientCommonSettings;
+import org.hibernate.search.backend.elasticsearch.client.common.logging.spi.ElasticsearchClientConfigurationLog;
+
+import org.apache.hc.core5.http.HttpHost;
+
+
+final class ServerUris {
+
+ private final HttpHost[] hosts;
+ private final boolean sslEnabled;
+
+ private ServerUris(HttpHost[] hosts, boolean sslEnabled) {
+ this.hosts = hosts;
+ this.sslEnabled = sslEnabled;
+ }
+
+ static ServerUris fromOptionalStrings(Optional protocol, Optional> hostAndPortStrings,
+ Optional> uris) {
+ if ( !uris.isPresent() ) {
+ String protocolValue =
+ ( protocol.isPresent() ) ? protocol.get() : ElasticsearchBackendClientCommonSettings.Defaults.PROTOCOL;
+ List hostAndPortValues =
+ ( hostAndPortStrings.isPresent() )
+ ? hostAndPortStrings.get()
+ : ElasticsearchBackendClientCommonSettings.Defaults.HOSTS;
+ return fromStrings( protocolValue, hostAndPortValues );
+ }
+
+ if ( protocol.isPresent() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndProtocol( uris.get(), protocol.get() );
+ }
+
+ if ( hostAndPortStrings.isPresent() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.uriAndHosts( uris.get(), hostAndPortStrings.get() );
+ }
+
+ return fromStrings( uris.get() );
+ }
+
+ private static ServerUris fromStrings(List serverUrisStrings) {
+ if ( serverUrisStrings.isEmpty() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfUris();
+ }
+
+ HttpHost[] hosts = new HttpHost[serverUrisStrings.size()];
+ Boolean https = null;
+ for ( int i = 0; i < serverUrisStrings.size(); ++i ) {
+ String uri = serverUrisStrings.get( i );
+ try {
+ HttpHost host = HttpHost.create( uri );
+ hosts[i] = host;
+ String scheme = host.getSchemeName();
+ boolean currentHttps = "https".equals( scheme );
+ if ( https == null ) {
+ https = currentHttps;
+ }
+ else if ( currentHttps != https ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.differentProtocolsOnUris( serverUrisStrings );
+ }
+ }
+ catch (URISyntaxException e) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidUri( uri, e.getMessage(), e );
+ }
+ }
+
+ return new ServerUris( hosts, https );
+ }
+
+ private static ServerUris fromStrings(String protocol, List hostAndPortStrings) {
+ if ( hostAndPortStrings.isEmpty() ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.emptyListOfHosts();
+ }
+
+ HttpHost[] hosts = new HttpHost[hostAndPortStrings.size()];
+ // Note: protocol and URI scheme are not the same thing,
+ // but for HTTP/HTTPS both the protocol and URI scheme are named HTTP/HTTPS.
+ String scheme = protocol.toLowerCase( Locale.ROOT );
+ for ( int i = 0; i < hostAndPortStrings.size(); ++i ) {
+ HttpHost host = createHttpHost( scheme, hostAndPortStrings.get( i ) );
+ hosts[i] = host;
+ }
+ return new ServerUris( hosts, "https".equals( scheme ) );
+ }
+
+ private static HttpHost createHttpHost(String scheme, String hostAndPort) {
+ if ( hostAndPort.indexOf( "://" ) >= 0 ) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, null );
+ }
+ String host;
+ int port = -1;
+ final int portIdx = hostAndPort.lastIndexOf( ':' );
+ if ( portIdx < 0 ) {
+ host = hostAndPort;
+ }
+ else {
+ try {
+ port = Integer.parseInt( hostAndPort.substring( portIdx + 1 ) );
+ }
+ catch (final NumberFormatException e) {
+ throw ElasticsearchClientConfigurationLog.INSTANCE.invalidHostAndPort( hostAndPort, e );
+ }
+ host = hostAndPort.substring( 0, portIdx );
+ }
+ return new HttpHost( scheme, host, port );
+ }
+
+ HttpHost[] asHostsArray() {
+ return hosts;
+ }
+
+ boolean isSslEnabled() {
+ return sslEnabled;
+ }
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/logging/impl/ElasticsearchClientLog.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/logging/impl/ElasticsearchClientLog.java
new file mode 100644
index 00000000000..3ac0d6b0d03
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/logging/impl/ElasticsearchClientLog.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.logging.impl;
+
+import java.lang.invoke.MethodHandles;
+
+import org.hibernate.search.util.common.logging.CategorizedLogger;
+import org.hibernate.search.util.common.logging.impl.LoggerFactory;
+import org.hibernate.search.util.common.logging.impl.MessageConstants;
+
+import org.jboss.logging.annotations.MessageLogger;
+
+@CategorizedLogger(
+ category = ElasticsearchClientLog.CATEGORY_NAME,
+ description = """
+ Logs information on low-level Elasticsearch backend operations.
+ +
+ This may include warnings about misconfigured Elasticsearch REST clients or index operations.
+ """
+)
+@MessageLogger(projectCode = MessageConstants.PROJECT_CODE)
+public interface ElasticsearchClientLog {
+ String CATEGORY_NAME = "org.hibernate.search.elasticsearch.client";
+
+ ElasticsearchClientLog INSTANCE = LoggerFactory.make( ElasticsearchClientLog.class, CATEGORY_NAME, MethodHandles.lookup() );
+
+}
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/package-info.java b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/package-info.java
new file mode 100644
index 00000000000..c5c9f3c1d70
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/java/org/hibernate/search/backend/elasticsearch/client/opensearch/package-info.java
@@ -0,0 +1 @@
+package org.hibernate.search.backend.elasticsearch.client.opensearch;
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer b/backend/elasticsearch-client/opensearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
new file mode 100644
index 00000000000..31236902b96
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/main/resources/META-INF/services/org.hibernate.search.engine.environment.bean.spi.BeanConfigurer
@@ -0,0 +1 @@
+org.hibernate.search.backend.elasticsearch.client.opensearch.impl.ClientOpenSearchElasticsearchClientBeanConfigurer
diff --git a/backend/elasticsearch-client/opensearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntityTest.java b/backend/elasticsearch-client/opensearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntityTest.java
new file mode 100644
index 00000000000..1193b3ad3ad
--- /dev/null
+++ b/backend/elasticsearch-client/opensearch-rest-client/src/test/java/org/hibernate/search/backend/elasticsearch/client/opensearch/impl/GsonHttpEntityTest.java
@@ -0,0 +1,343 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.search.backend.elasticsearch.client.opensearch.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.hibernate.search.backend.elasticsearch.client.common.gson.spi.GsonProvider;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.nio.ContentEncoder;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+
+class GsonHttpEntityTest {
+
+ public static List extends Arguments> params() {
+ List params = new ArrayList<>();
+ Gson gson = GsonProvider.create( GsonBuilder::new, true ).getGson();
+
+ JsonObject bodyPart1 = JsonParser.parseString( "{ \"foo\": \"bar\" }" ).getAsJsonObject();
+ JsonObject bodyPart2 = JsonParser.parseString( "{ \"foobar\": 235 }" ).getAsJsonObject();
+ JsonObject bodyPart3 = JsonParser.parseString( "{ \"obj1\": " + bodyPart1.toString()
+ + ", \"obj2\": " + bodyPart2.toString() + "}" ).getAsJsonObject();
+
+ for ( List jsonObjects : Arrays.>asList(
+ Collections.emptyList(),
+ Collections.singletonList( bodyPart1 ),
+ Collections.singletonList( bodyPart2 ),
+ Collections.singletonList( bodyPart3 ),
+ Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ),
+ Arrays.asList( bodyPart3, bodyPart2, bodyPart1 )
+ ) ) {
+ params.add( Arguments.of( jsonObjects.toString(), jsonObjects ) );
+ }
+ params.add( Arguments.of(
+ "50 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 50 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "200 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 200 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "10,000 small objects",
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 10_000 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "200 large objects",
+ Stream.generate( () -> {
+ // Generate one large object
+ JsonObject object = new JsonObject();
+ JsonArray array = new JsonArray();
+ object.add( "array", array );
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 1_000 ).forEach( array::add );
+ return object;
+ } )
+ // Reproduce the large object multiple times
+ .limit( 200 ).collect( Collectors.toList() )
+ ) );
+ params.add( Arguments.of(
+ "1 very large object",
+ Stream.generate( () -> {
+ JsonObject object = new JsonObject();
+ JsonArray array = new JsonArray();
+ object.add( "array", array );
+ Stream.generate( () -> Arrays.asList( bodyPart1, bodyPart2, bodyPart3 ) )
+ .flatMap( List::stream ).limit( 100_000 ).forEach( array::add );
+ return object;
+ } )
+ // Reproduce the large object multiple times
+ .limit( 1 ).collect( Collectors.toList() )
+ ) );
+
+ params.add( Arguments.of(
+ "Reproducer for HSEARCH-4239",
+ // Yes these objects are weird, but then this is a weird edge-case.
+ Arrays.asList(
+ gson.fromJson( "{\"index\":{\"_index\":\"indexname-write\",\"_id\":\"0\"}}", JsonObject.class ),
+ gson.fromJson( "{\"content\":\"..........................................................."
+ + "...........—.....................—.......—....—....................................."
+ + "...—.....................................................................——........."
+ + "...................................................................................."
+ + "........................—..........................................................."
+ + "...................................................................................."
+ + "...........................................—........................................"
+ + "............—............—.........................................................."
+ + "...................................................................................."
+ + "................................................................——.....—............"
+ + ".................—.................................................................."
+ + ".............................——....................................................."
+ + "..........................................\",\"_entity_type\":\"indexNameType\"}",
+ JsonObject.class )
+ )
+ ) );
+
+ params.add( Arguments.of(
+ "Reproducer for HSEARCH-4254",
+ Arrays.asList(
+ gson.fromJson(
+ // This is randomly generated content that happens to reproduce the problem.
+ // If it means anything, that's purely coincidental.
+ // I'm sorry if it's offensive,
+ // but if I change even one character the problem no longer appears,
+ // so I'm stuck with this content.
+ "{\"content\":\"\u010D\uFC7A\uCBED\uD857\uDFC9\uB5FA\u17B2\uD83E\uDD21\u15C5\uFF3D\uD397\u3F7B\u7822\uD84F\uDDB2\uD859\uDD21\uD871\uDFCC\uD852\uDDC3\uD848\uDCFD\uD85E\uDEAF\uD81E\uDD10\uD855\uDDF1\u2D67\uD859\uDC62\uD85C\uDC89\u7D75\uD82F\uDC09\uD800\uDFAA\uD803\uDC35\u1BE9\u4183\u068A\uC61E\uD85D\uDDC7\uD3CC\uD873\uDFD6\u3DA6\uD842\uDE91\uD85A\uDC3D\uD865\uDCF0\uD81C\uDDEE\u718A\uD84D\uDEB3\uC2C2\uD86C\uDF47\u8F9D\u4801\uD84C\uDED9\uD86B\uDE9C\uC759\uD862\uDF42\uCE83\u7B02\u526D\u978E\u3936\u7C94\u6746\uD866\uDF98\uD844\uDFC8\uD851\uDF8E\u4A98\u789E\uD85B\uDF01\uD855\uDE5B\uD87A\uDF2F\uD869\uDF29\u2AFF\uD821\uDD33\uD86C\uDE14\uD87E\uDCD7\uFF1F\uD853\uDEA4\uD847\uDD4B\uD85A\uDD5D\uD81A\uDCF1\u7F23\u7A6C\uD848\uDC48\uD84D\uDD06\uD821\uDED2\u2ED6\uD83E\uDD56\u591B\uD84C\uDD84\uD85D\uDE1B\uD877\uDEFE\uC624\u6DEA\u73BC\uD821\uDE30\u23A7\uD7DB\uD862\uDD93\u3B3C\u0ED4\uD846\uDC7B\u0959\uD86D\uDE2A\uD86B\uDC2B\uD86D\uDE87\u76A3\uA10F\uD872\uDEB1\uA604\uA852\uD1D5\uD856\uDEBD\uD844\uDD82\uD820\uDEB4\\u0006\uD81E\uDF6F\uD870\uDD2D\uD856\uDF4E\uD875\uDE78\uB7DB\uCF98\uD856\uDEAF\uD87A\uDEC9\u04A8\uD848\uDFEF\uD852\uDE49\uC933\u12AE\uD84F\uDC63\uD850\uDECE\uD874\uDDAC\uD872\uDC7D\u7F76\uD869\uDE37\uD2A0\uD4C7\uD868\uDDBC\uD848\uDC31\u86EA\u7276\u48B9\u8412\uB216\uD848\uDF0D\u7102\uD869\uDC63\u9018\u2FFA\uD844\uDD1E\u91DD\uFAA3\uD877\uDF17\u8617\uD821\uDEC9\uD860\uDCA7\u66B1\uD808\uDCF5\uD807\uDC85\u483B\u1CE0\u7DF3\uD878\uDDA4\u14BA\u3558\uB82D\uD845\uDE49\uD871\uDEFC\uD87E\uDDCC\uA1C2\uB195\uD874\uDE28\u3AD4\uD87A\uDF69\u12E4\u6787\uD850\uDC86\u414D\uD84B\uDCFC\u14DB\u3259\uD85A\uDD6B\u15A8\uD87E\uDCB3\uD868\uDE40\uD81D\uDF4B\uD821\uDDAF\uD81F\uDC9E\u1BC8\uD805\uDE1A\uD84B\uDE33\uC4E3\u9D76\uD849\uDEA5\u1230\u9DE0\u2F65\u2BBD\u604E\uD848\uDC6B\uD835\uDF6B\u27E6\uD846\uDF6C\u77AB\uD865\uDD55\u15EC\u380D\uD857\uDEA7\uD859\uDEA3\uD841\uDC58\uD86D\uDC68\uD85B\uDCCC\uD864\uDE04\uD874\uDEEB\uD879\uDC9A\uD874\uDFF5\uD83C\uDF9A\uD879\uDD5E\u4C5C\uD80C\uDE45\uCAB3\uD81F\uDFFB\uD81F\uDDC1\uD844\uDF64\u3DB6\uD862\uDF7D\uD870\uDC1B\uD853\uDC91\uD864\uDE99\uD845\uDD16\uD84D\uDCC5\uD870\uDF76\uD86C\uDCA2\uD821\uDC02\uD820\uDFF9\uD865\uDF2C\u75B5\u9AA4\uD87A\uDDCE\u3004\u8355\uA2D8\uD84B\uDCED\uD809\uDD01\u763F\uD845\uDE39\u610B\uD84A\uDDD8\u89DC\uD842\uDE17\u9797\uD842\uDFC2\uD83E\uDC70\uD804\uDD72\u732E\uD86C\uDEA0\uAE24\uD822\uDD4F\uD86A\uDDF1\uD847\uDDA7\uD876\uDDB9\uD85F\uDC41\u6428\uD85A\uDCE4\uD84F\uDE6C\uD868\uDE62\uD860\uDEDC\uD83E\uDC05\uD83A\uDC3A\uC710\u332D\uD17F\u3CBF\uC2C0\uD86A\uDE20\uD858\uDCB3\uD858\uDDDE\uD855\uDFF6\u5E77\uD860\uDD85\u16D8\uD867\uDCD2\uD84E\uDFB1\u3978\uD853\uDD69\u07D4\uD874\uDC5B\uD84F\uDFDC\uD86A\uDF87\uCA0C\u3FE1\uD84A\uDF3F\u2EEA\uD821\uDDA7\uAADD\uD80C\uDF61\uA8D3\uD860\uDFFB\u1386\uD86B\uDE4B\uD874\uDF01\uD855\uDE10\u104A\u8C65\uD85E\uDEC4\uD85E\uDD20\u810A\uD81A\uDCD0\u2B3D\uD842\uDEFF\uD821\uDC28\uD877\uDE3E\uD856\uDE83\uD854\uDFC9\uD87A\uDE84\u01D4\uD85F\uDED8\uC53E\uD841\uDF04\uD857\uDE31\uCDB9\uD877\uDF5A\u99E2\uC3A4\uD83E\uDD2E\u1BA9\uD852\uDD5B\uD848\uDE9D\uD870\uDED3\uD849\uDD9F\u3D3F\uD857\uDEB0\u4193\u053A\u5B90\uD852\uDFBA\uD878\uDE00\u738C\uD878\uDF7E\uD868\uDF86\u7A08\uD868\uDFF1\uD81A\uDF1A\uD80C\uDD52\uD85C\uDE22\uD870\uDD12\uD81C\uDC35\uD871\uDD2C\uD877\uDE45\uD84F\uDD4B\u37DB\uD86C\uDC11\u9E19\uD85B\uDDA7\uD84D\uDEE8\uD86F\uDF6D\uD86A\uDDFC\u4CD5\u4459\uD85A\uDD6E\uD844\uDF3C\uCB39\uD809\uDD36\uA240\uD84B\uDC56\uD847\uDD46\uD852\uDD78\uD84A\uDC25\u316E\uD85C\uDF62\uD86E\uDD3E\u720D\u3C94\u99ED\uD489\u66A3\u4325\u663D\uB04B\u60D3\uCFB2\uD840\uDEF8\uD83A\uDCAE\uD843\uDCC0\u97C6\uD84D\uDC8E\uD858\uDCEF\uD87A\uDE11\uD84D\uDC23\uD86F\uDCE1\uD875\uDF57\uD84F\uDD00\u9AAA\uD835\uDF15\uD84E\uDC0E\uD822\uDC79\uFC38\u6D95\uD876\uDE83\uD84B\uDEAB\uC7A6\uD849\uDEA8\uD866\uDCE5\uD836\uDD60\uCD9E\uD802\uDC78\uD867\uDEFA\uD800\uDCE1\uD112\uD804\uDE23\u74A7\uD86A\uDC5F\u8E99\uD811\uDDFA\uD848\uDCF0\uD875\uDC60\u38D9\uD808\uDD5A\uD820\uDF59\uD86E\uDDD2\u95A5\uA72E\u2756\uD845\uDF91\u610C\uD820\uDEF5\u6F33\uD868\uDFE3\uD859\uDE88\uD874\uDC80\uD85D\uDE43\uD85E\uDD15\uD875\uDEAE\u8DD8\u8BAD\u5D7E\u3491\uD858\uDE1E\u2A4F\u95EE\uD806\uDE5F\u2995\uD869\uDDDE\uD81A\uDF74\uFDB4\u8EAD\uD802\uDDE3\uD841\uDFA9\u9B32\u0F03\uD863\uDFC3\u20E6\uD85E\uDDB0\u928E\uB839\uD86C\uDDC7\uD840\uDDFF\uD870\uDED5\uD873\uDC91\uD850\uDECB\uD84A\uDD73\uD854\uDCFB\uBB90\uBA0D\uD834\uDD2F\u38CB\uD805\uDC83\u5AB3\uD847\uDCC4\uD85B\uDC14\u73A1\u13CE\uD846\uDF08\u136E\uA032\uF956\uA1B9\uD820\uDD41\uD85E\uDDC3\uD870\uDEB0\uD86D\uDC05\uD86F\uDECB\uD85E\uDF16\uD802\uDC1E\uC74E\u581C\uD808\uDF01\u5991\uD805\uDE0D\uD86F\uDE88\uD851\uDC40\uD86A\uDD24\uD865\uDFA8\u6DE1\uD804\uDCF4\uD84E\uDE5A\uD86E\uDFB3\u4F8E\u7F02\uD84F\uDCD1\uD863\uDC15\uD859\uDC82\uD834\uDE40\uD863\uDF4D\uD86C\uDC3A\uC599\uD01E\u82DB\uD871\uDF39\uD840\uDF44\uD81F\uDCA1\uD85E\uDDEE\u9745\uD845\uDC31\uD856\uDD79\uAB72\uD66D\uB29F\u37B0\u96DD\uB781\uD85A\uDF54\uCD02\uD86C\uDE6E\u2CFF\u367D\u7DA2\uD854\uDE05\u7D83\uD808\uDE70\uD85A\uDE12\uD853\uDD97\u2ACC\uD85C\uDEE5\uD872\uDE5C\uD85A\uDEBA\uD875\uDC4F\u7008\uD867\uDFC3\u1C13\u9888\u9DCA\u9FE9\uD84D\uDCA8\u5530\uD855\uDEE2\uD841\uDD5D\uD877\uDE59\uD867\uDD7A\uD85C\uDF97\uD80C\uDDD3\uD847\uDF4A\uD876\uDF1A\uC279\uD874\uDF6F\u1339\uD86A\uDC20\uD841\uDFAB\u09CC\uD811\uDC98\u4228\u5DCF\u2A82\uD86B\uDCF4\u069F\uD860\uDF40\u93F0\uD808\uDF0B\uD841\uDC86\uD870\uDD41\u610B\u63EE\uD81A\uDCBE\u9A85\uD809\uDC91\uD852\uDDAA\u6974\uD847\uDDA3\uD85B\uDDBA\uD840\uDE4C\u56BA\uD876\uDED3\uD867\uDDF8\uD808\uDC2F\uD867\uDC05\uD84B\uDC14\uD867\uDDB4\u4DF0\u82E6\uD845\uDEE2\uD81C\uDE14\uD802\uDD83\uBC7E\uD85D\uDC2C\uD846\uDD1E\uD873\uDCCC\uD860\uDC8A\uD878\uDC92\uD874\uDE65\uD861\uDC28\uD854\uDC3E\uD84C\uDC11\uD856\uDD11\uD81D\uDEB0\uB07A\u89A4\uD85A\uDED9\uD81C\uDEE2\uD805\uDC0D\uD834\uDC22\uD864\uDC4F\u240B\uD86A\uDE80\uD876\uDFC4\uD85E\uDCFF\uD871\uDE7C\u1987\uD835\uDC8F\u1990\uD863\uDFE1\uD81A\uDF26\uD802\uDC0E\uD853\uDE0B\uD841\uDE95\u59E7\u1E02\uD864\uDF58\uD877\uDEE1\uD82C\uDEC0\uD86B\uDD8A\u391D\u03BB\uD863\uDE29\uD84B\uDEDB\u8F02\uD2A0\u22ED\uD863\uDF74\u70A9\uD856\uDE3A\uBC76\uD861\uDD78\uD868\uDD9C\uD851\uDFF6\u8860\uD841\uDD85\uFB84\u110B\uD850\uDCD4\uD875\uDE6D\uD845\uDD20\uDB40\uDD9D\uD853\uDD7A\uD841\uDEA3\u0D05\uD869\uDDC0\uD856\uDDA8\uD870\uDD1F\uD86C\uDCF9\uD80C\uDF2A\uD804\uDCD4\uD85B\uDDF5\u9EF2\uD844\uDF27\u8536\uD85B\uDFE7\u581F\u6C39\uD856\uDE79\uA50D\uD864\uDCD9\uD879\uDEF7\uD841\uDE38\u3D49\u1B50\uD807\uDC8D\uD811\uDDAF\u82CC\u3F64\u851E\uD847\uDF0B\u0923\u3000\uD85A\uDE7E\uAC0D\u9344\uD801\uDEE5\u02EF\uD0D1\uD877\uDEDD\uD861\uDE7E\u9910\uD86B\uDEC1\uD85D\uDF3F\u6CBF\u27B2\u7869\uD822\uDC60\uD57E\uD860\uDD92\uD85F\uDCC0\uD86E\uDD44\uD872\uDE59\uD81D\uDE21\u956A\uD85F\uDD7A\u3A62\u95F5\uD878\uDF26\u5F0E\u2934\uD82C\uDC91\uD862\uDDA7\uD848\uDFD4\uD81D\uDE6F\uD86A\uDD5B\uD845\uDF26\u9C4F\uD82C\uDEBF\uD87A\uDE9B\uD849\uDC8A\u13D8\uD248\uBF92\uD853\uDDD6\uD862\uDEEE\uD7DA\uD863\uDCB6\uD860\uDDE9\uD859\uDCD6\uD856\uDCE8\u7612\uD84B\uDE38\uD82C\uDD70\uD85B\uDF2B\uD869\uDC4A\u396C\u7728\u41F4\uD859\uDC5D\uD83E\uDC3E\u648C\uD877\uDD93\u7358\uD845\uDE51\uD857\uDF95\uD870\uDE49\u93D4\uBDF1\u5E2E\u8197\u24E3\uD856\uDE54\u55CF\uD81F\uDF7A\uB6AC\uD865\uDF8B\uD81F\uDDD8\u6F19\uD84B\uDC82\uD867\uDD94\uB377\uD847\uDD74\uD875\uDFC4\uD86E\uDEF9\uD87A\uDD0C\u25C6\uD877\uDCF0\uD864\uDEE8\uD804\uDDC0\uD359\uD835\uDFC0\u813D\u2AC1\uD86C\uDD80\uD807\uDC58\u3750\uD805\uDCC7\u51D0\uD845\uDC5D\uD847\uDEB2\uD821\uDD63\uD869\uDE68\uD811\uDD5F\u76C0\uB127\u9D0D\u88DE\u163E\uD865\uDC75\uD85F\uDE50\uD857\uDF48\uD865\uDF68\uD87A\uDFCE\uD802\uDC89\uD86E\uDDE2\uD808\uDF58\uD850\uDC8F\u0714\uC90D\uD845\uDF49\u8EC9\uD856\uDE91\uD804\uDC61\uD847\uDE44\uD5FA\uD811\uDDB6\uD840\uDE71\uD38F\uFF3B\uD866\uDF1B\uD845\uDF61\uD871\uDFC5\u858A\uA12D\u53A8\uD820\uDF73\uD843\uDCCE\uD857\uDC68\uD84E\uDD33\uD867\uDC17\u26D1\u3573\u5701\u7BE5\uD870\uDF52\uD853\uDECD\u427E\uD873\uDF75\u51ED\uD857\uDF19\uD852\uDF3D\uD85F\uDC4E\uD84C\uDC13\uD842\uDDDE\uD868\uDDB4\uD871\uDEC5\uD871\uDC24\u8C19\uD85A\uDEF1\uD863\uDDDE\u2967\uD86A\uDEA6\uD857\uDC74\uD85B\uDF23\u3294\u7DC1\u7A9C\u7B1F\uD83B\uDE68\uD86D\uDCFC\uD80C\uDE3F\u2880\u488F\uD84C\uDE42\u70D2\uD861\uDC7D\uD821\uDFE3\uD848\uDEBD\uD841\uDD80\u69B6\uD848\uDEB5\u916E\uD803\uDC36\uD84E\uDE70\uD86E\uDC95\u0438\u95FC\uD870\uDE9A\uD7C5\uD875\uDF29\uCB39\uD860\uDDAF\uD86A\uDCBA\u1321\uD811\uDDD7\uD871\uDF2E\u35DF\uD873\uDCBB\u5373\u9A9A\u3055\uD84E\uDE62\uD85E\uDE7E\u7467\uB87D\uC42C\uD82C\uDD91\uD857\uDD05\uD84A\uDF2D\u9A18\uD81F\uDD42\u50FA\u8C62\uD84A\uDC70\u49AD\uD871\uDFD9\uD850\uDEA3\u3AF4\u198C\uB6FF\uA697\uD855\uDFEC\uD81D\uDD1A\uD878\uDC50\u9F9C\u1D34\u4FAF\uDB40\uDC64\u75C1\uD835\uDDF1\uD854\uDCEA\uD842\uDFC7\uD853\uDFD1\uD81C\uDD99\uD874\uDD2F\uBC5A\uD873\uDE4F\uD872\uDF8E\uD852\uDDF1\uD85F\uDC4C\uD842\uDF01\uD86D\uDD9A\uD84F\uDC70\uD867\uDD15\u0788\uD820\uDE7D\uDB40\uDDD3\uD82C\uDCCF\uD871\uDC2D\uD834\uDCD0\u9669\uD877\uDE12\uD872\uDDAB\uD843\uDC43\uD84E\uDC8D\u5840\uD840\uDD5D\uD858\uDFF2\uD855\uDF37\uB5A5\uD821\uDE6E\uD805\uDDCF\uD7DB\uD86A\uDC9F\uD861\uDC05\uD81E\uDE8B\u0718\uD852\uDEFE\uD80C\uDE1A\uD83D\uDC0D\uD86E\uDFBC\uD834\uDDCA\uFA86\u2E04\u0568\uC32B\uD871\uDE34\uD854\uDEC4\uD867\uDC93\uD85E\uDD31\u46A7\u8205\uD843\uDE2A\u0771\uD83C\uDE38\uD808\uDDD3\uD867\uDC11\uFD9F\uD84F\uDF7B\u552F\uA586\uD82F\uDC28\uD87A\uDCA9\u32B7\u39A4\uD802\uDC0F\uD806\uDE8B\u5E70\uBCA8\uD81D\uDCA2\uD840\uDC46\u8F5D\uD83D\uDF70\u47EF\uD85F\uDD3D\uA34E\uFE33\uD872\uDF71\u49D7\uD862\uDD8F\uD81D\uDE4A\uAF06\uCF0E\uD862\uDF2A\uD86E\uDE3B\uD867\uDE05\uD772\uD856\uDEC3\uD86F\uDC2A\uD878\uDCED\uD84C\uDF2E\u83F7\uD866\uDDD0\uD800\uDFB3\u33D4\uD81C\uDEAA\u0653\u7E17\uD858\uDEAB\u70BB\uD821\uDE30\uD85C\uDC09\uD856\uDC6E\u8D9E\uD854\uDF1D\uD86D\uDFF1\uAB78\u8511\uD858\uDE6C\uD820\uDE40\uBC40\u6F4C\u7F3E\uD858\uDE13\uC515\uB0D4\uD842\uDD61\uD852\uDFD8\uD821\uDCD1\uD025\uD858\uDF86\uD875\uDD75\uD86B\uDF7E\uCC14\u8184\uD855\uDF8C\uD844\uDF84\u486F\u7C5F\u607F\uD871\uDF9F\uD85D\uDCBE\u35A4\uD867\uDD8B\uD821\uDE68\uD84E\uDEC4\uC454\uD836\uDCFE\u41CE\uD861\uDFFE\uD852\uDD45\uD821\uDE08\u6B23\uD834\uDD8F\u1A62\uD843\uDF2D\uD864\uDD6A\uD871\uDD5E\uBA1E\uD802\uDF86\uD879\uDD5A\uD840\uDF1E\uD806\uDE00\u2954\uD86D\uDC96\u69A7\uD873\uDC6B\u97C4\uD862\uDE7C\uD872\uDD8E\u4655\uD871\uDC87\uD852\uDECD\uD80C\uDE09\uD84F\uDE14\uD80C\uDC55\uA462\uD872\uDCA9\u8FF6\u112F\uD867\uDDC1\uD874\uDDE7\uB42B\uD84E\uDCA6\uD87A\uDD28\uD876\uDE08\uD844\uDC59\u3339\uD2B2\u1846\uD84F\uDF01\uD843\uDC35\uD844\uDFCF\u3454\uD834\uDF19\uD864\uDFE1\uD81D\uDDDF\uD811\uDD67\uD875\uDC96\u249A\uD802\uDC55\uB8CA\u36E9\uD85D\uDEC2\u554B\uD867\uDEE7\u51AA\uD864\uDC4E\uFDAF\uD869\uDC06\uD854\uDC33\uADF4\u961C\uD861\uDF19\uD869\uDCDB\uD845\uDE48\uD800\uDF5D\uD853\uDFB7\uD86A\uDF06\uD875\uDD36\u7FF0\u5E62\uD865\uDD3A\uD860\uDFAC\uD821\uDEB3\uD862\uDCDC\uBEAA\uD87A\uDEBC\uD811\uDCDC\u85E4\u0361\u2034\u7908\uD845\uDD86\uD874\uDF9A\uD863\uDE70\u7BEE\uD85A\uDFE5\uD836\uDD4F\uC8AB\uD859\uDD0B\u4E29\uD86A\uDC59\uD83D\uDFC6\uD853\uDE9A\uD859\uDC89\u8F4E\u7B05\uD856\uDE6F\uD801\uDEA0\uD861\uDCA2\uA9CC\uD866\uDCAB\uD804\uDE29\uD845\uDF88\u4E60\u2C88\u6647\u7778\uA4AE\uD848\uDFB4\uD872\uDFCC\uD840\uDC2F\uD836\uDC88\uD873\uDDAE\uD849\uDC79\u560F\uD850\uDF73\uD85C\uDF65\uD135\u3BEE\uD84F\uDE98\uD848\uDEAC\uD854\uDD9A\uD877\uDCA7\uD84D\uDE3C\uCBBF\u4540\uD86E\uDD73\u64A0\uD836\uDC2D\uD861\uDF2D\uD81D\uDF78\uD867\uDC15\u250C\uD85F\uDDF0\u2FA3\uBF93\uD870\uDDAA\uD83C\uDCF3\uD86C\uDDE2\uD857\uDF46\uD852\uDE3D\uD868\uDFAC\uD846\uDDCC\uD81E\uDC20\uD86D\uDCFC\u1BB4\uB299\uD820\uDD71\uD867\uDFF3\uD840\uDF70\uD868\uDE0C\uD850\uDCE4\uD868\uDE55\uD85B\uDD96\u7655\uD80C\uDDBA\uD84C\uDF25\uD85C\uDD4B\uD861\uDF4C\uD84E\uDC4F\uD861\uDD1D\uD86E\uDD52\uD802\uDD9F\uD844\uDEB3\u6A9E\uD86F\uDFA0\uB34B\uD80C\uDF65\uD866\uDDF2\uD85F\uDCCA\uB9F3\uD85E\uDE08\uD84E\uDD9E\u183B\uD849\uDF5E\uD85A\uDC39\uD84A\uDD4D\uD852\uDC47\u6858\u011F\uD875\uDF8A\u84DA\u368B\uD876\uDE95\uD81F\uDEFE\uD87A\uDDE4\uA756\u037D\uD855\uDC7A\uD867\uDF03\uAFFC\u5FE2\uD86D\uDC47\uD872\uDE7C\uD856\uDD97\u3AE1\uD874\uDD8C\uD861\uDE70\uD860\uDC9D\uD698\uD81E\uDDF3\u35F0\u93C3\u2793\uD836\uDDBC\uD861\uDD09\uD85E\uDEEE\u9AEF\u1A16\uD849\uDE6D\u813F\uBA8B\uD821\uDC2E\uC8BE\uD83D\uDE4B\u833C\uD855\uDC8F\uD820\uDC58\u1ECD\u5ED5\uD85D\uDC49\u41F2\uD856\uDCBA\u4B3C\u246D\uD81D\uDD4C\u8A06\uD86F\uDD04\u19F6\uD85D\uDDB3\uD82C\uDE45\uD845\uDE39\uD846\uDD6C\u9D84\u1FBF\uD81C\uDF8B\uD872\uDE66\uD848\uDE60\uB7D0\uD859\uDC7A\uD822\uDC9B\u6337\uD855\uDD30\u4E11\u11DA\uD87A\uDCAD\uD811\uDCDE\uD83E\uDC94\uD6EC\u291D\uD81F\uDDE2\uD853\uDFE5\uD856\uDF8A\u4380\u26C1\uD848\uDED7\uD840\uDED8\u9B25\uD873\uDD9F\u302E\uD856\uDEB6\u8A9C\u8AE4\u9243\uD822\uDC9B\uD857\uDD59\uD86A\uDF91\u93BA\uD858\uDCC3\uD85E\uDD7D\uD873\uDCC8\uD85B\uDE59\uD855\uDD6E\\u2029\uD849\uDCB4\uD859\uDFF4\uD81C\uDC89\uD840\uDE15\u16E1\uD834\uDDA4\u689C\u1C70\uD85C\uDFAF\uD84C\uDE96\u6A60\uD851\uDD68\u3783\uD873\uDC9E\u35E1\u8C93\u1B6C\uD821\uDDC1\uC5E0\uDB40\uDD46\uD844\uDC60\u72FC\uD811\uDCCB\uD2F6\uD850\uDE8E\uF992\uD7E2\uB5B6\uD843\uDD4A\uD874\uDC3B\u40E4\u4F3A\u2B63\uD841\uDC98\u90A5\uD878\uDC20\uD821\uDE10\uD81A\uDF0C\uD853\uDF8C\uD836\uDE82\uD877\uDF38\uD867\uDFAF\uD846\uDC72\u2EEB\uD858\uDC64\uD851\uDC29\uD85B\uDCEB\uD851\uDD90\u50B0\uCF83\uD858\uDC19\uD855\uDE6C\uD84F\uDC96\uD878\uDF50\uFEA1\uD873\uDDA8\u0B71\uD820\uDE87\uD866\uDC3C\uD84E\uDF1B\uD855\uDEC8\uD875\uDDCB\u112E\u9220\uD863\uDD49\uB12C\uD84E\uDC02\uD865\uDC4B\uD869\uDF09\u936C\uD800\uDEBF\uD1FD\uD843\uDE31\u4D72\uA279\uD86A\uDC28\u8D16\uD858\uDEAF\u64A7\u100F\uA926\uD85A\uDC90\u86CF\u0664\u9DF3\u3D52\u22AF\uD86B\uDF1E\uD804\uDE80\uCEB3\u6265\u7519\uD81F\uDE52\uC4DB\uD865\uDD91\uD875\uDD25\uD867\uDE8E\uD85A\uDCEC\u67ED\uD857\uDC30\uD849\uDC78\uD841\uDC19\uD874\uDE96\uD841\uDEB1\uD874\uDE7D\uB05F\uD86A\uDC8D\uC01B\uA1E5\uD879\uDC39\uD861\uDC27\uD84D\uDD46\uBD5B\u6619\u7A05\uD851\uDEEB\uD835\uDF3C\uD81E\uDEB9\uD879\uDD0B\u092D\uD874\uDC91\uD864\uDFD6\uD848\uDEBC\uD847\uDDE8\u47BC\uD84B\uDC3F\u503D\uD862\uDF7A\uD851\uDC3E\u36B0\u7459\uA742\uD859\uDD91\uD85A\uDE3E\uC59C\uAAE6\u877C\uD840\uDCB4\uD859\uDC99\uD805\uDF1D\u98F8\uC819\uD85E\uDE2E\uD850\uDFB3\uD874\uDF7C\uD850\uDE51\u7484\uD873\uDCAA\uD857\uDC15\uD84C\uDCDF\u4B44\uD855\uDD04\uD863\uDED1\u47F0\uD858\uDF64\u01C4\u1504\uCF5C\u2E05\uD859\uDFE9\uD843\uDE85\uD846\uDE59\uD848\uDEB1\uD86F\uDEB0\uD873\uDF43\uA8A6\uD842\uDF95\uD878\uDC79\uD877\uDF0F\u4BB8\u663B\uA245\u5299\uD86A\uDF07\uD843\uDF2E\uD84D\uDE1F\u35DD\u87DF\uD83C\uDDF1\u79E4\uD84B\uDD87\uD84D\uDFF4\uD84B\uDE31\uD81C\uDC5B\uD855\uDE1C\u9AA0\u88BB\uD862\uDE94\u6BB7\uD86D\uDDC5\uD869\uDE77\u2D0C\uD801\uDD52\u300A\uD81C\uDE01\uD841\uDE12\uD81F\uDCB2\uD86E\uDDF0\uD820\uDC27\uD860\uDF12\u3C58\uD855\uDDE0\uD870\uDD44\u66BA\uD811\uDE32\uD878\uDC7C\uFF6B\uD846\uDE61\u8F4A\uD84B\uDEC8\uD865\uDF83\u8623\u8499\uD873\uDF41\u62B7\u61AF\uCFD3\uD843\uDC92\u33E5\uD85E\uDFD3\uD86B\uDF32\uD85E\uDF40\u4F9E\uD81E\uDF0F\uD847\uDE10\u6D8F\uD85F\uDFA0\uD87E\uDD2C\u13B3\uB70F\uD872\uDE3C\uD802\uDEC8\uD806\uDCE4\uD848\uDE9D\uD800\uDF30\u83FC\u776A\u2402\uD843\uDF67\uD867\uDCA8\uB6F7\u833E\uD820\uDFCE\uD87A\uDCF6\uD835\uDEED\uB3A3\uBDDE\uD851\uDF4E\uD867\uDF7A\uD84C\uDCDA\uC01B\uD879\uDD17\uD84F\uDDF9\uD878\uDE74\uD855\uDFA0\u2608\u30AF\uD859\uDDAE\uD874\uDDC1\u659E\uD873\uDF8B\uBA09\uD873\uDE87\uD879\uDD29\uD874\uDDC4\uD875\uDC3E\uFBFC\u047C\u5C00\u3EED\u5613\u2D07\uFF8D\u8AC4\uD855\uDDFD\uA153\uB518\u2B91\uD809\uDC6C\uD86A\uDF17\uD1F9\uD802\uDC2C\uD861\uDDD3\uFF38\uD80C\uDD65\uB375\uBE10\uD869\uDF49\uD862\uDDB0\uD86B\uDD7F\uD85E\uDEEE\uD874\uDF44\uD85E\uDD38\u1929\uD872\uDF71\uD855\uDC49\uAD04\uCCED\u3FB7\u1B03\u55E8\uD84E\uDE46\uD853\uDEFB\uD870\uDE40\uD849\uDFAF\uD875\uDF65\u07AF\uD863\uDFE6\uD802\uDEEE\uB8D1\u49E8\uD82C\uDD17\u5104\uD875\uDDAC\uD86B\uDDF6\uD846\uDF30\uD86E\uDFAA\u7BDE\u581A\uD81A\uDDBA\u7017\u2A45\uAC50\uD86D\uDDED\u6562\uD81D\uDF9D\uD84D\uDEFE\u9F00\uA566\uFCEF\u4B7C\u511B\u1C71\u8B8C\uD870\uDD18\uD81F\uDCEB\uD836\uDD4A\u6483\uD869\uDC59\uD86E\uDFC7\uD80C\uDD92\uD510\uD85C\uDFED\uA048\uD862\uDF75\uD877\uDDBB\uD865\uDEA8\uD867\uDF02\uD841\uDDAB\uD86B\uDEB6\uB009\u69E6\uD81C\uDDBA\uD844\uDD69\uD840\uDF41\uD811\uDCB5\uD85E\uDF5B\u78EA\uD86E\uDC52\uD84E\uDE70\uD879\uDDBB\uD10D\uC930\uD769\uA0DE\uD860\uDD27\uD84A\uDC4A\uD834\uDDCF\u8BE6\uBEAD\uD81D\uDE16\uD820\uDF52\uD80C\uDFB1\uD84C\uDDD6\uD870\uDCAF\u8886\uA105\uD808\uDCA9\u6758\uD879\uDF66\u4595\uD86C\uDC68\uD841\uDEC7\uD862\uDD65\u8A16\u712F\uC730\u5A0F\uD864\uDD8E\uD857\uDD5C\uD864\uDEA1\uD878\uDE43\uD87A\uDE0F\uD82F\uDC16\uD870\uDC99\uD800\uDC2E\u738B\u137C\u4EEA\uD852\uDD2E\uD860\uDEF3\uD876\uDC6D\uD802\uDEE2\uD804\uDDC6\u37DB\u7AF4\uD86F\uDDBD\u64FF\u056A\uD84F\uDEE2\uC93E\uD869\uDED5\uD800\uDF3F\u7F4A\u6FB4\uD861\uDF0C\uD846\uDE2A\u6641\u47AA\uD81D\uDCFB\u7419\uD84A\uDE98\uD809\uDC53\u6310\uD845\uDDBF\uD873\uDD5F\uD844\uDDD7\u3595\uD841\uDD4B\uD840\uDCFC\uD846\uDC1E\uB128\uD85A\uDD04\u4B93\uD87A\uDEF0\uD86F\uDF70\uD854\uDE47\uD808\uDF2A\uD836\uDDD2\uD878\uDF70\uD2CC\u9BFB\uD873\uDF95\uD81C\uDD3E\u8AC6\uD85E\uDCE1\u5369\uD853\uDDEC\uD803\uDE6A\u3E63\u85BD\uD82F\uDC9E\u0519\uD879\uDDB1\uD855\uDE15\u6401\uD871\uDF39\uD841\uDC8A\uD857\uDC4D\uD872\uDCB6\u8911\uD822\uDE0F\uD811\uDDD0\uD866\uDF4C\uD870\uDF8C\uD83E\uDC09\uD834\uDE16\uD820\uDE23\u8990\u3C7B\uD872\uDD61\uD849\uDDA4\u1243\uD845\uDF31\uD856\uDDD5\u2AD4\uD81F\uDF0C\uFAB5\uD809\uDD3B\u03E1\uD821\uDC12\uFE85\u4FFF\u2A22\uD876\uDDB2\uD876\uDE9F\uD870\uDCEE\uD855\uDCEF\uD865\uDC96\uD84D\uDDD9\uD86A\uDD62\uD844\uDF86\u5516\uD82F\uDC55\uD847\uDF31\uD868\uDFA2\u4C1F\uCDDA\uD875\uDDCB\u67CD\u852B\uB616\uD808\uDD8B\uD85F\uDF91\uAEC4\uD82F\uDC02\u7BC1\uD879\uDEE9\uD843\uDFB5\uD804\uDE0E\uD85E\uDFFA\u4AE9\uD848\uDD51\uD808\uDF49\uD862\uDCAF\uD81A\uDDDB\uD856\uDFC6\u6603\uD87E\uDDF1\u3F71\uD841\uDD8D\uD868\uDE1B\uD853\uDF6A\uD83B\uDE2D\uD87A\uDE90\u66E1\uD84A\uDC05\uD843\uDDD7\uD876\uDD21\uD862\uDFB6\u7640\u0092\uD845\uDC11\uD840\uDDE6\uD85A\uDE06\uD845\uDEF1\u2DD8\uD850\uDDD5\u526C\u0943\uD871\uDFEA\u08E7\uC9A9\uA0C8\uD873\uDDFF\u7D06\uD873\uDD5D\u146E\uCE31\u52E9\uD858\uDE16\uD849\uDD24\uD875\uDED2\uD868\uDDF9\u3E00\uA98F\uD81D\uDC5A\uD847\uDD1D\u85EF\uD806\uDE1C\u35A4\uD836\uDEAF\uA899\u7138\uD836\uDD4C\uD874\uDC5E\u7192\u69C9\uB01C\u8B54\uD842\uDF3F\uD84A\uDED3\uD85A\uDD34\uD86E\uDF76\u0B5D\uD875\uDD2B\uD873\uDDEA\u41ED\uD863\uDFD8\u3EEE\uD83C\uDF45\uD87A\uDC68\uD850\uDCFC\u2C83\uD801\uDC3D\uD855\uDC8D\uD86A\uDD77\u9986\uD822\uDE91\u3D03\u8CD9\uCCDC\uD821\uDC34\uD87E\uDCD5\uD81E\uDCC2\uD802\uDF28\u6310\uB32E\uBCA1\uD865\uDF5A\u5143\uD843\uDD31\u9C49\uD873\uDE8C\u2797\uD85A\uDE34\uD872\uDF6E\uD879\uDD70\u3A54\uD854\uDE26\uD86B\uDF20\uD81F\uDE8B\uD865\uDDA2\u75E0\uD80C\uDC3B\uFE7E\u1D08\uD84D\uDCE1\u628F\u2C31\uCD70\uD847\uDF86\uD84D\uDDC5\uD85E\uDD0F\uD879\uDEF3\uD845\uDE3C\uD866\uDD3B\uD864\uDE41\u3E2E\uD81F\uDD9A\uD848\uDE7C\uD861\uDC48\u7D00\u3C08\u7A91\uD868\uDCE0\uD86B\uDD16\uD84F\uDF51\uD80C\uDECA\uD853\uDFB3\uD85A\uDEA0\uD862\uDC59\uD874\uDCAA\uD879\uDD71\uBE48\u3D8E\u79F4\uCA2A\u8043\u43E6\u557E\uD86C\uDD29\uD873\uDE86\uD875\uDE80\uD835\uDE2D\uD83E\uDC34\u2E91\uD870\uDEA1\uAF93\u3A90\u8941\u1A5B\uD845\uDC02\u81B8\uD852\uDFEF\uD84D\uDF43\uD859\uDF73\u0D37\uD81F\uDD2D\uA241\uAC55\uD85C\uDDBF\u6477\uD863\uDDB7\uD86D\uDEE2\uD85F\uDD26\uD84C\uDE0B\uD76F\u205B\u8B92\uD84E\uDFA6\u9228\u7966\uD86A\uDF5D\u2606\uD84F\uDD7F\uD858\uDD7A\u581B\uD874\uDCCD\uD804\uDECF\u64BD\uD863\uDD22\uD822\uDC21\u9C88\u689E\uD86E\uDC4E\u857D\u0E39\uD83D\uDFAD\uD81E\uDF9A\u6338\uD869\uDC89\uD85B\uDC4D\u4774\uD81A\uDF8D\uC736\uD85A\uDF4C\uD836\uDDEB\uD850\uDFFB\u4424\uD84F\uDEFC\uBDAC\uD821\uDE90\uD85E\uDF4D\uD81F\uDCEC\uD033\uD840\uDEAE\uD85A\uDD6C\uD86F\uDF76\uD864\uDF1A\uD857\uDCDF\uFA73\uD852\uDF73\u052F\u9525\uD809\uDD2F\uD83D\uDD10\uD845\uDC8D\uD84D\uDE9D\uD84F\uDC2C\uD858\uDC4B\uD84F\uDD0B\uD87A\uDF09\uD877\uDFE9\uD859\uDD15\uD801\uDC69\uD81D\uDEB7\uD86C\uDCDC\uD846\uDC75\uD846\uDD5E\u9EDA\u7E8F\uDB40\uDDBA\uD85E\uDD0D\uD84F\uDF82\u20ED\u4C5C\u8DDA\uD81C\uDEBC\uD847\uDE7B\uD842\uDD69\uD858\uDD6F\u8FFB\u3741\uD863\uDF98\uD850\uDE38\uD869\uDC6A\uD87A\uDFC5\uD849\uDEC1\u3992\u2287\uD870\uDFDA\uBE71\uD840\uDDF7\uD843\uDD27\u37AB\uD808\uDCD0\uD865\uDD04\uD853\uDE2A\uD849\uDC92\uD86A\uDCDA\uD866\uDF24\uD862\uDF41\uD853\uDF8A\u63E4\uD852\uDDC3\uD858\uDF5E\uD86C\uDEA5\uD84C\uDD12\uD86D\uDDB0\u6E21\uD804\uDD40\uD574\uD869\uDD4D\uD840\uDEE0\u83F1\uD85D\uDFFB\uD13B\u600D\uBC1B\u2C85\uD851\uDD41\u6AAD\u407C\u8F1F\uAC4B\uDB40\uDDC1\uD83D\uDCB0\uD848\uDEEF\uD861\uDC62\uD85A\uDCE8\uD86B\uDC85\uD873\uDFA4\u484B\u1524\uD878\uDD8C\uA9E7\u8171\u86A2\u97A5\u239B\uD835\uDFAF\u73BF\uD85A\uDE79\uC17B\uD85A\uDD66\uD85C\uDCA4\uD834\uDE0D\uD83D\uDF3E\u8755\u6193\uD862\uDC35\uD86B\uDE7B\u9D92\uD822\uDC7E\uD879\uDF43\uFE23\uD86E\uDE5C\uB917\uD86F\uDECC\uD846\uDDFB\uD81A\uDCA5\uA8D2\uD81B\uDF3F\uD81C\uDFAC\uCCF2\uD84B\uDF81\u8391\uD866\uDC69\uD875\uDCCC\uD1B9\uD860\uDDD9\uAB01\uFCBF\u4807\uD859\uDECB\uD86E\uDE24\u71E8\uD857\uDFBB\uD802\uDC68\uD843\uDD71\uD865\uDEC9\u1695\u9246\uD845\uDFED\uD83D\uDF6C\u053B\uD84C\uDFBC\uD86F\uDE33\u2526\uD83C\uDD93\uD874\uDD20\uD85E\uDF48\uD83C\uDF29\uD846\uDE9F\uD86D\uDF57\u91ED\uD87A\uDDA8\u86EC\uD851\uDD42\uD876\uDC27\uD873\uDE0F\u58A6\uD862\uDEC7\u7535\uD860\uDFFC\uB5E9\u3D34\u32F3\uD841\uDC92\uD66E\uD83C\uDF4C\uD855\uDD20\u7FE3\uD807\uDC58\uD822\uDE45\uD84D\uDDF1\uBD8F\u26F2\uD848\uDC35\uD81C\uDE37\uA8B7\uD84E\uDDA2\uD866\uDD60\u6433\uD86B\uDFBE\u431B\u43E5\uD861\uDC8A\uD849\uDCC0\uD862\uDCD5\uD844\uDFBA\uD864\uDEBD\uD804\uDEA5\uD878\uDD37\uD87A\uDFA1\uD856\uDCFF\u6EBC\uD87E\uDD2E\uD848\uDE9B\u55B8\uD846\uDCED\uD81C\uDC50\uD845\uDCEF\uD86F\uDD66\u0C08\uD801\uDEF6\uD85C\uDE64\uC6BE\uD84B\uDE9B\u3320\uD861\uDD3F\uD81F\uDFDC\uD859\uDD76\uD851\uDDE0\u261F\uD835\uDCF6\u3400\uD853\uDEBE\u9F4D\u311A\uFE0E\uD85E\uDFCD\uD865\uDD09\uD852\uDEB8\u070D\uD847\uDEF8\u04E1\u53FF\uD842\uDE64\u55C7\uD85A\uDE5F\uD87A\uDE0F\uD835\uDC1F\uD842\uDE63\u6F3D\u1744\uD847\uDE23\uD873\uDD5A\uA990\u04DE\uD835\uDCB3\uD840\uDE4E\u4E63\uD845\uDDA1\uD86F\uDCCF\uD842\uDF08\uD85B\uDE72\uD84B\uDFB6\uD846\uDE0F\uD809\uDD1B\uD855\uDF29\uD84A\uDCE9\uD864\uDC38\uD81B\uDF2F\uA2EF\uD853\uDCE5\u14BA\u9435\u7FD2\uD875\uDE7E\uD81A\uDC8E\uD80C\uDFF4\uD820\uDD92\u7967\uD841\uDEEE\u8393\u3C83\uD87E\uDC8A\u844A\uD84A\uDE53\u4895\uA34E\uD861\uDD73\uD854\uDE98\u88DC\u1BA4\u7B6B\u0F1C\uD873\uDDD7\uD86C\uDF01\uD168\uAD5A\uD876\uDF29\uD860\uDC40\uD80C\uDF2C\uD879\uDEB5\uD801\uDEF0\u0544\u8421\uD83D\uDF08\uD857\uDF51\uD867\uDD9D\uD867\uDFB1\uB404\u4724\uB132\u081E\uD86A\uDE8D\u3597\u9DDA\u751E\uC58C\uB2E7\uD875\uDD65\uD82C\uDCF0\uD842\uDEE6\uD81A\uDE64\uD848\uDF86\u9AA0\u87A1\uD866\uDFD4\uD855\uDD29\uD871\uDDD0\u45C1\u8AE2\uD851\uDF00\u62DE\uD855\uDDEE\uD84A\uDC9C\uD84D\uDFCB\uD805\uDE58\uD820\uDEED\uD846\uDE6E\u7B32\uD878\uDEE2\uD84A\uDC92\uD808\uDE7C\uD84E\uDE47\uD805\uDCB8\uCD1B\uD804\uDD1E\uD803\uDCA3\uD808\uDF1F\uD846\uDD23\uD860\uDF17\uD84C\uDE52\u9C27\u2C53\uD85D\uDF8E\u8A55\uD800\uDE96\uD85A\uDF07\u7E8F\uD858\uDD45\uD85F\uDF5A\uD854\uDD14\u5C1D\uD861\uDFE6\uD842\uDCC2\uD81A\uDD70\u8128\uD86D\uDEFE\uD86D\uDDC4\uD844\uDFB7\uD862\uDF98\uD85C\uDF21\uD86D\uDF9B\u0DA5\uAF3F\u966B\uD81C\uDD62\uD83E\uDC83\uD86B\uDFAF\u9CF6\uD85F\uDE74\u47E9\u25FE\uD864\uDC07\u3108\u7467\uD85C\uDE1B\uD83D\uDC9B\u83F6\u37DE\u12F9\uD851\uDC05\uD83C\uDCA6\uA223\u344A\u6039\u2361\uD855\uDC44\u1439\u4758\uC9FD\u7820\uD855\uDE93\u3D7D\u5F8A\uD83C\uDE13\uA643\uD85F\uDC9D\u8550\uD85A\uDC95\uD835\uDEC8\u541E\uD802\uDC0F\uAF1B\uCFEC\uD855\uDEE5\uA0EB\uD821\uDC2C\u406F\u5739\uD87E\uDC91\u23DE\uD870\uDF41\uD110\u516F\uD843\uDC58\uD846\uDD53\uD81C\uDD62\uD834\uDF19\uD835\uDD7E\uD808\uDEEC\uD86D\uDD72\uD878\uDC92\u8273\uD85B\uDE1C\uD867\uDC1E\u2B1D\uD870\uDC31\uD851\uDF1F\uD85E\uDF7C\u324B\uCC6C\uD85E\uDE44\u7F85\uD841\uDFD8\uD851\uDC71\uD879\uDEDC\uD87A\uDF05\uD801\uDE5F\uD864\uDF5B\u119E\u29B3\uD844\uDED6\uD85B\uDE57\u853D\uD856\uDFD0\u486F\uD869\uDC7C\uD875\uDD73\uD87A\uDDED\uCE3C\uD81A\uDC28\uC85E\uD85C\uDDFC\u373C\uC174\uD868\uDE6D\uD83D\uDE28\uD808\uDC39\u74B1\u6BE3\uDB40\uDDC3\u8907\u4DF5\uD860\uDEB6\uD864\uDFAB\u0F55\uD84B\uDE0A\u1851\uD81D\uDDDB\uD850\uDD7E\uFF71\uD82C\uDE36\uD859\uDE0F\uD841\uDE0D\uD83C\uDFB4\uD848\uDF8B\u3476\uD80C\uDF25\uD7E9\uD85B\uDC9B\uD80C\uDC78\u38A0\uD84D\uDE0F\uD871\uDC10\uD86F\uDEAA\uD802\uDD98\uD84A\uDFF2\uD822\uDDC2\uB39B\u301D\uD84D\uDDF5\uBDC5\uD865\uDD56\uD879\uDE8E\uD855\uDE0A\uD821\uDC85\uC25D\uD84F\uDDA6\uD841\uDC52\uD878\uDCD5\u4C4F\u2E9C\uD821\uDC6C\u6083\u719F\uD84C\uDC47\u8D6B\uD86B\uDFE8\u056E\u4703\u60EF\uFF21\uFE9C\u6B09\uD85A\uDD10\uD213\uDB40\uDD23\uFBF8\uD86D\uDC88\uD854\uDD1E\u8FB2\uD841\uDFA0\uD857\uDFFE\u9618\uD870\uDF1C\uB3B7\uD861\uDEF6\uD854\uDCC6\uA2B8\uB8D1\u95E1\uD84E\uDEE8\u3253\uA58D\uD851\uDDF4\u6C4C\u9963\uD86D\uDF98\uD868\uDF4F\uD820\uDC90\uD86D\uDCC1\u6ACC\uD804\uDC28\uD877\uDC66\uD86A\uDD7B\uD876\uDEA4\u3E90\uD80D\uDC01\uDB40\uDD2A\u8813\uD850\uDCC0\u02D5\uD1F9\uD86D\uDFFF\uD850\uDFF7\uD81C\uDC5B\u97C6\u6C4E\uD83D\uDF6D\uD848\uDF70\u75DA\uD861\uDE21\uD841\uDEB4\uD84A\uDF01\uD842\uDE83\uB65E\u8367\u22E8\u5885\uD82C\uDC27\uCC2E\uD85C\uDD77\uD858\uDCD0\uD876\uDFF0\u0670\u4ED4\uBF9C\uD83E\uDD48\uD841\uDDA2\uD674\u9A95\uD862\uDFF0\uAEFD\uAE14\uC0C5\uD84C\uDC39\uD850\uDD59\u4FEB\u5F87\u825C\uD845\uDF61\u5B57\uB83F\uD82C\uDCF8\uD84D\uDF12\uD86A\uDCB9\uD854\uDC37\uD849\uDEC3\uD848\uDD2D\u7CD9\uD877\uDEE7\uAF4A\uD81E\uDFC3\uD87A\uDF33\u90AC\uD85E\uDC14\uD836\uDD7A\uD81A\uDF10\uD867\uDD36\uD844\uDCDD\u2467\u39E9\uD85D\uDC7C\u9721\u88E6\uDB40\uDC3B\uD860\uDFAD\uA375\uD83B\uDE8C\uBB67\uD873\uDEF3\u4571\uD864\uDFEF\uC9B5\uAF14\uD86D\uDD8F\u7799\uB5C2\uD81C\uDCD9\uD872\uDE5D\uD82C\uDC33\uD86B\uDD40\u5D1D\uBE98\uD811\uDC4E\u4E84\uD87A\uDFCF\uD809\uDD30\uAD3D\u3A83\u643D\u4521\uD86E\uDF23\u3665\uD873\uDD9C\u2DED\uD820\uDF5F\uD3B2\uD862\uDD37\u0269\uBB59\u0AB2\uCB7A\uD847\uDD0C\u5BD3\uD83D\uDD32\u29A4\uD836\uDC84\u6F08\u4E4A\uD878\uDDE8\uD876\uDC69\u16F6\u21E9\uD804\uDC5C\u4265\u5C3F\uD851\uDCE7\u547B\uD848\uDDE7\uD855\uDF7F\uD865\uDC73\uD85A\uDEAA\uD849\uDFA4\uD84F\uDE0B\uD861\uDF83\uD87A\uDF2A\u0989\uD875\uDE78\u9E1E\uD841\uDC45\u832F\uD836\uDDB7\uD85A\uDF6A\u70EE\uD81E\uDFCA\uD85F\uDFED\u07C3\uD85F\uDDE7\u9312\uD807\uDC10\uD86F\uDE41\uC88A\uD86E\uDD85\uD811\uDD10\u0CC2\u96E9\uD85A\uDC52\u8A9C\uD879\uDE7B\uD80C\uDCFC\uD85F\uDEB9\uD851\uDC50\uD85D\uDFAA\u4780\uD835\uDC8C\uD866\uDC10\uD868\uDD03\u0352\uC2C0\u7CF7\uADF9\uD82C\uDC78\uD86C\uDE47\uD848\uDF7E\uD855\uDC11\uD846\uDD34\u7A5E\uD875\uDD13\uD84A\uDC0B\uD2CA\uD835\uDC5A\uD81F\uDDD1\uD84B\uDE88\u7036\uABCA\uD865\uDF42\uC62B\uC51C\uD84B\uDEF2\u8136\u3C33\uD871\uDC9B\uAEA1\u7784\u5CB1\uD81D\uDF0E\u4971\uD849\uDCDE\uD86D\uDE44\uD85B\uDC86\uD847\uDD0C\u17E2\uD85A\uDC55\uD84C\uDED6\u9649\u62A9\u33E2\uD836\uDE4D\uD44B\u7BFE\uD836\uDE79\u5C62\uD865\uDE86\uD858\uDDE0\uD80C\uDF54\uD84F\uDEA1\uD843\uDDEF\uD803\uDC44\u2123\uD87A\uDF55\uD877\uDDBA\u5CD9\uD834\uDCA8\uD836\uDCBB\uD808\uDC65\u16DC\u1F6F\uD87E\uDC12\u4CF3\uD81D\uDD46\uD821\uDCB6\uD86E\uDF12\uA539\u0837\u76F4\u1108\u95D1\u34CD\uD805\uDEB4\u3766\u03BE\uD81C\uDCBB\u8312\uD865\uDC63\u4A8B\uD804\uDE36\uD820\uDF88\uB3EB\u9584\uD86F\uDD25\uD843\uDCBB\uDB40\uDC4A\u3B3E\uD84D\uDC6B\uFA9F\u15E2\uC5B8\uD856\uDC76\uD867\uDE6E\u9CC0\u7353\uD855\uDC47\uD854\uDFFC\uD873\uDF6B\uD855\uDD49\uD85F\uDC00\u8B73\uD841\uDDA5\u9238\u589C\uD855\uDEB7\uD85E\uDD4A\uD846\uDD96\uD808\uDE62\uD82C\uDCF9\uD81E\uDD7C\u6940\uD844\uDECC\uD861\uDEE4\uD87A\uDE04\u5D8B\uD84F\uDC28\u1BB5\uD846\uDFF7\u8D69\uD860\uDEBB\uD85D\uDF3A\uD85A\uDE53\uD864\uDD95\uD85F\uDFA3\uC23F\u4739\uD878\uDEF7\uD800\uDE88\uD86B\uDDDC\uD840\uDE18\uD854\uDFA1\u654D\uD842\uDDD6\uD834\uDF55\uD821\uDD33\uB3B4\uD85B\uDEC5\u7A0A\uD862\uDDC3\uD876\uDDF9\uD875\uDE5F\uD877\uDC9E\uD804\uDC9C\uD878\uDFA2\u7BB7\uA576\uD848\uDECB\uD81C\uDF99\uD85C\uDFAB\uD835\uDC26\uD84E\uDD0A\uD860\uDE5A\u82BB\uD857\uDEEB\uD81F\uDF05\u0974\uD860\uDD22\uD876\uDD43\uD84A\uDC59\uD834\uDDB0\uD81F\uDE09\uD87A\uDC53\uD875\uDE1F\u28B6\uD870\uDFEB\uD840\uDEAC\uAFA6\uD83D\uDEBB\u082A\u8A68\u3517\u6411\uD856\uDDA4\uD86F\uDE37\uD855\uDD82\uD854\uDEA4\uD835\uDC02\uD871\uDE24\uD821\uDC4B\uD81C\uDEBE\uD862\uDE5E\u56BE\u89CC\uBF6F\uD877\uDF59\uD81E\uDE8C\u9FC2\uD808\uDDB3\uD874\uDE47\u169A\uD875\uDCCB\uC1C9\uD835\uDEBF\u67C0\u676B\uD836\uDDA4\uD841\uDC14\u8569\uD81C\uDEEB\uD856\uDCE6\uD85C\uDEFA\u88ED\uD847\uDD12\uD873\uDED8\uD850\uDC14\u61FD\uD85B\uDF1B\u5C37\uB979\uD840\uDC78\uD860\uDE41\uD84E\uDFE8\uD808\uDC9B\u47CC\u7928\uD86A\uDFDC\uD850\uDE9B\uD821\uDC95\uD811\uDD42\uD863\uDE15\u73B4\uD852\uDCB6\u1662\u96CB\uD866\uDCF7\uD84E\uDE0C\uD83E\uDD8E\u68D6\uD85A\uDCD3\u4F31\u8A54\uD821\uDED2\uD871\uDF20\u5EEA\u4DF6\uD859\uDE43\u2C7D\u2166\uD868\uDD22\uD835\uDE6B\u0854\uD85E\uDE0F\uD84B\uDEB9\uD871\uDD60\uD848\uDFDD\u961B\uCD9C\uD86C\uDD57\u8491\u13A6\uD807\uDD1C\u25A9\u22A6\uD84E\uDCC1\uD81C\uDE8F\uD821\uDF0F\uD85F\uDCDE\u2E1A\u23EB\uD85F\uDD24\uD81D\uDDA5\uD84C\uDFE3\uC41D\u76C7\uD85E\uDF8C\uD81D\uDED0\uD864\uDE62\uD861\uDF24\uAC4F\uD872\uDCC7\uD846\uDEBD\uD84E\uDD6C\uD5CC\uD836\uDC01\uD847\uDEB7\uD841\uDFEF\uD84C\uDD80\uD809\uDCD9\u6DA0\uD869\uDC90\u0331\uD7E1\u9720\u193B\uD879\uDF8C\uD857\uDF72\uD848\uDEF1\uD834\uDD16\u04B6\uBE33\uD878\uDF6C\uD843\uDEE6\uD808\uDF5B\uD855\uDC4A\u118A\uD85C\uDD92\uFAC0\uD851\uDDA1\u1C7D\u80D8\uC33E\u6C68\uD876\uDE42\u40DE\uD84B\uDC9A\uD855\uDCE3\uD844\uDEF6\uD873\uDFF2\u8F62\uD84E\uDD59\u43EC\uD841\uDFCB\uD851\uDE76\u6F27\uD857\uDD2B\u9821\uD836\uDE3C\uD820\uDE02\u45CE\u2B81\u07AB\uD84D\uDC6A\uD81C\uDFF4\u6F91\u11CD\uD808\uDD6C\uD849\uDE1C\uD843\uDCDA\u5180\uD86B\uDF40\uD862\uDD1C\uD808\uDF04\uD852\uDD14\uD872\uDDF2\uD848\uDC43\u42E1\u0607\uD81D\uDC18\uD83A\uDC04\uD855\uDF29\uD820\uDED0\uD85E\uDE13\uA78E\uD860\uDC73\uD806\uDE5A\uD864\uDEA2\uD848\uDECF\uB6A7\uD860\uDD12\uA8A9\u29A6\u638A\uD867\uDCAB\uD874\uDCD0\uD85D\uDF05\uD81D\uDD2B\uD843\uDEA9\uCCEF\u636A\u8445\u5B47\uD869\uDE66\uD84C\uDE79\uD86D\uDDEF\uD870\uDFEE\u30DB\uD86E\uDD53\uD85B\uDCA1\uD868\uDE4C\uAB24\uD852\uDD5B\uD851\uDE0D\u7525\uD84E\uDF26\uD86E\uDD8C\u1D38\uD85F\uDF73\uD86E\uDD63\uD866\uDEFA\u3E6F\u1D19\uD843\uDF8C\uFF88\u205D\u858A\uD85A\uDEAC\uD86F\uDC19\uD874\uDE7B\uD879\uDE47\uB1A3\u878B\uD85D\uDCF2\uD862\uDCCF\uD876\uDDD2\uD847\uDCF0\uD845\uDF22\u909E\uD84D\uDDA9\uD859\uDDDA\uADD5\u4B9E\uBB44\u745F\u67A9\uD803\uDC96\u7A4D\uD85E\uDE46\uD87A\uDC6B\uB69E\uCF38\uD851\uDF62\uD801\uDF50\uD82F\uDC77\u8932\uD875\uDE7B\u1610\uD52A\u6A51\uD5B4\uD852\uDD63\u0C60\uD86D\uDCD8\uD822\uDE11\uC621\uD802\uDCFD\u8D9E\u98B4\u6F03\uD849\uDCFB\u1B30\u1922\uD846\uDFF9\uD855\uDF14\uDB40\uDD16\uD867\uDC1D\uD848\uDD5E\uD850\uDC3D\u3E97\uD840\uDE88\uD86C\uDC85\u485C\u8A3C\u5C45\uBBDE\uAA41\u6AEA\uD855\uDF96\u8381\uD84F\uDFC0\u7381\uD81C\uDF14\uD86B\uDF20\u1985\uD879\uDCAA\uD80C\uDD5A\uD872\uDC36\uA3CD\u7499\uD85D\uDCF2\u18F4\uD857\uDDB7\uD851\uDDE6\uD834\uDDD0\u3372\uD877\uDCFE\u9DF7\u6C12\uD851\uDC70\uA53D\uBC94\u5449\uD849\uDFE4\u8D71\u23A7\uD85E\uDE58\\u000b\uD85E\uDFCE\uD2DA\uD859\uDCF0\uD873\uDC08\u9C20\u2AB6\u7488\uD879\uDE04\u3487\uD86E\uDFD2\u80DA\u445F\uD801\uDCDF\uD84D\uDD95\u6683\u91C8\uD841\uDD28\uD81D\uDE3B\uD821\uDC21\uD853\uDF02\uA9AC\uD81C\uDDE7\uD846\uDD74\uD854\uDD56\uD802\uDC35\uD846\uDF52\u4DA1\uD875\uDCF7\uCCE7\uD854\uDD16\uD869\uDC44\u4E6C\uD85D\uDC13\uD873\uDCBC\uD852\uDF11\uD809\uDC8A\u09A1\uD834\uDC56\uD840\uDE4C\uD878\uDCE6\u600F\uD85D\uDF5A\u59A5\uA8B1\uD86F\uDC4A\u63A8\uD86C\uDEE0\u9DFD\uD86D\uDC26\uD81F\uDCA5\uD84D\uDC97\uD86D\uDC55\u3A08\uD81C\uDC58\uD821\uDCBB\uD800\uDEB4\u28E6\uD860\uDF98\uD801\uDCE5\u7E28\uD864\uDFF4\uD821\uDC39\uD81E\uDCD8\uD81A\uDCE2\uC7B9\uD83C\uDF26\uD847\uDDCB\uD84D\uDE60\uD811\uDE38\u7532\uD81A\uDC16\u5513\u30F6\u3C8E\uC116\uD811\uDDA9\uD846\uDEC2\u52F4\u5C1C\uD80C\uDDC9\u10A6\uCABD\uD844\uDFE3\u094C\uD863\uDF3C\uD84D\uDD9D\uD821\uDDF6\uD855\uDCBC\uD822\uDC12\u70E0\uD859\uDC5F\u623A\uD877\uDF0F\uD85D\uDD75\uD866\uDFE2\uD86A\uDF1C\u4D12\uD822\uDC6F\u81E1\uD877\uDC3A\u4B42\uD872\uDC66\uD87A\uDCD9\uD850\uDF84\uD878\uDE8B\u2941\u2F62\u8A09\uD81C\uDC5C\uD836\uDE88\uD84F\uDE7D\u5152\u188A\uD80C\uDF17\uD82C\uDDEF\uD86A\uDE2D\uD868\uDCDC\uD81F\uDF1A\u9249\u8D10\uFE44\uD858\uDEFF\uD850\uDEBA\uD853\uDE41\uA863\uD803\uDE72\uD879\uDCF5\u050E\uD846\uDD5F\u5124\uA2C5\uD86E\uDED3\u6FED\uD820\uDF3E\uA502\uD846\uDE7C\uD453\uD866\uDE6E\u05DE\uD855\uDD5F\uD848\uDD52\uD84C\uDF2B\uD846\uDE2D\uD863\uDCAC\uD869\uDE0B\uD86E\uDC67\uD821\uDE37\uD842\uDC2A\uD802\uDE65\uD862\uDC66\u919D\uD83C\uDC43\u6A39\uD866\uDCCC\uB78D\u5F30\uD874\uDE53\u29DB\u3432\uC9BA\uD83A\uDC90\uD821\uDDA7\uD851\uDEF4\uF9C3\uD85F\uDE34\uD81E\uDEC3\uD866\uDCCE\uD835\uDD9E\uD87A\uDE2D\uD86C\uDD15\u0449\u8128\uD86A\uDC4F\uD874\uDD3E\uD84C\uDED9\uD85C\uDFEC\uD851\uDE65\uD856\uDEA9\uD802\uDDA6\uD85B\uDFC3\u5E4F\uD835\uDCD7\uD86E\uDE7A\uD84B\uDC33\uD3F9\uD878\uDC56\uD846\uDFE7\uD867\uDF70\u4325\u3CE0\u95F3\uA6A1\u690B\u012A\uD81A\uDC11\u55E8\uD850\uDC04\u8321\uD879\uDE6D\u58D0\uD853\uDD57\uFDF5\u76E3\uD805\uDE8A\uD81C\uDCBF\uD81B\uDF71\uD849\uDDDA\uD866\uDD72\uD869\uDF7A\uD83C\uDC5C\uD867\uDD0B\u2CF1\uD84D\uDD0E\u94E4\u67AE\uD876\uDF2A\uD85F\uDEEE\uD834\uDC31\uCDFD\uD811\uDDFA\uD85B\uDD2C\uD822\uDE71\uD81F\uDE1A\uD83D\uDDCC\u03E2\uD84F\uDEB5\uD86A\uDEE9\uD2D7\uD873\uDD0E\u24F3\uD871\uDC53\uA93B\uD857\uDD08\uD859\uDEF7\uD822\uDD0B\uD869\uDCCF\u870F\uD86E\uDCD7\u2C00\uD85E\uDF01\uD866\uDC8E\u749B\uD85C\uDF13\uD868\uDD84\uD865\uDFF4\uD87E\uDD9B\uD846\uDDF9\u99E7\uC088\uD85D\uDFD8\u875E\uD855\uDD33\uD806\uDCB0\uD856\uDD87\uC92D\uD85F\uDEA2\u88EB\uD85B\uDCF3\uD863\uDE3F\uD809\uDC89\uD81F\uDDCC\uD859\uDF39\u24E3\uCF70\uD83D\uDC45\uD86F\uDF21\uD821\uDD2C\u4C90\uD83D\uDFCB\uD845\uDED7\uD806\uDE7B\uD84D\uDFDA\u11FC\uD850\uDEB9\uD84C\uDE00\uAB65\u6519\uD841\uDF8F\u28F1\uB12D\uD859\uDF5D\uD863\uDF90\\\"\uD85B\uDD13\uD858\uDF26\u5964\u8F03\u3A46\uD806\uDED1\uD804\uDD76\uD878\uDF66\uD81F\uDD6B\uD878\uDE2C\uD81C\uDD81\uD874\uDC8A\uD862\uDE29\uD800\uDEB4\u0CCD\u547C\uD844\uDDA8\uA88E\uD81C\uDD12\uD81A\uDDD9\uD834\uDF31\u46CE\u6A78\uD841\uDF71\uD86C\uDD02\u3FFC\u9752\uD868\uDEEA\u673C\uD86D\uDC48\u4863\uD857\uDDF9\uD800\uDEC2\uD861\uDF1E\uD81F\uDE93\u9E97\u5EE0\uD84C\uDC52\uC500\u076C\u9381\uD83C\uDC24\uD840\uDFDD\uD840\uDD29\uD855\uDC74\uD859\uDF2E\uD86A\uDE25\u58FE\u4AEC\uD844\uDE6E\u2625\uFD33\u1617\u701C\uD871\uDD2B\uD840\uDE3B\uD861\uDF43\uD86E\uDDFF\uD80C\uDF66\uD85F\uDC58\uD879\uDF62\uD865\uDF9C\uD84C\uDCB6\uD840\uDF3E\u85DC\u2CE2\uD84B\uDD5F\u1EB0\uD86F\uDF9F\uD840\uDD14\uD859\uDD10\u6092\u4CE5\uD850\uDFD2\uD87A\uDFAC\uD84E\uDD3E\u384B\uD81E\uDF1E\u585C\uD873\uDE74\uD83D\uDEC6\uC989\uD860\uDE84\u72ED\uD85D\uDFAD\uD86C\uDFE9\uD81E\uDD2C\uD859\uDCAD\uD840\uDC8E\uD868\uDC17\uD875\uDFD3\uD801\uDD16\u3236\uD844\uDF96\uD874\uDF3B\uC7F3\uCF3C\u6870\u04FD\u2535\uD86C\uDE22\u91C5\uD811\uDD0C\uD86A\uDF1D\uD866\uDD85\uD862\uDD8A\uD860\uDF22\uD83C\uDCA1\uD84C\uDFBD\uD842\uDF84\uD874\uDCBD\uD861\uDE8A\uD85F\uDC97\u5F0C\uD845\uDFE1\uB8A7\u7721\u43BF\uD84E\uDF93\uD867\uDF26\uD854\uDCD9\uD852\uDFFF\uD86D\uDC11\uD879\uDEE9\u950E\uD866\uDE28\uD863\uDF13\u67F4\uD81F\uDF5A\uD85E\uDC8E\u0446\uD868\uDC39\uD81F\uDDE7\u9FAD\uD820\uDC40\uB55E\uD859\uDC6C\uD848\uDECD\uABB1\uD860\uDCBD\u80B6\u9028\u6611\uD844\uDCE1\uD86E\uDCB8\uD81F\uDE1B\uD85C\uDCC2\uD844\uDE78\uD858\uDF2E\uD847\uDF92\uD805\uDC45\uD84C\uDD34\u73D6\uAF33\uDB40\uDD2E\uD877\uDECE\u8B16\uD85D\uDD55\uD878\uDF4A\u4954\uD86D\uDD5B\uD870\uDD63\u29FE\uAFFB\uFE87\u7187\uA0F6\uD807\uDC3C\uD854\uDE4F\uD86D\uDCBC\uD846\uDF9A\uD853\uDF06\uD81F\uDE23\uD842\uDF24\uD821\uDED0\uCF54\uD85F\uDDA1\uD864\uDFC7\uD879\uDD4F\uAD88\uD859\uDEA7\uD861\uDFC9\uD3C5\uD864\uDD81\u3811\u02A8\uD848\uDD8D\uD834\uDD54\uD86F\uDD20\uD868\uDE95\uD83D\uDD7D\u388B\uC799\uD86E\uDC18\u5780\uD84F\uDF5C\u6557\uD84B\uDEA8\uD845\uDE8F\uD856\uDF31\uD865\uDD07\uD84E\uDD7D\uD83E\uDD83\uD856\uDD35\uD857\uDEBA\uD845\uDF42\u68DB\uD850\uDC1D\uCCC0\u996A\uD862\uDC2E\uD861\uDF3A\u288D\uD81F\uDEC7\uD820\uDC94\uD852\uDDD3\uD84A\uDDD2\u6E1A\u9973\uD844\uDC4D\uD81E\uDEA3\uD84D\uDEBC\uD843\uDC64\uD858\uDEB0\uD86E\uDD95\uD81E\uDD63\uD85D\uDCDB\uD821\uDC99\uD87E\uDC6D\uD852\uDEDD\uA7FC\uD85A\uDF0A\u42A9\uD84E\uDE37\uD81E\uDCDB\uD868\uDF71\uD820\uDC1D\uD850\uDDD4\u083D\u5069\uB72A\uAD32\uD83C\uDC62\uD83C\uDF37\uC809\uAE51\u5BEE\uD874\uDEA9\uD874\uDC88\uD842\uDF8C\uA14E\u583F\uD85B\uDF78\uD86B\uDFCE\uD86D\uDC96\u2910\uD864\uDFB6\uD861\uDDDB\uC9D3\uD821\uDFE6\uD85A\uDDF0\u58DA\uD866\uDFF5\uD846\uDC7C\uBCBB\uD875\uDF3B\uD862\uDFDC\uD811\uDCBA\uC649\uD859\uDE21\uD867\uDC6B\uD874\uDC99\u1123\uD840\uDF87\uA6B5\uD868\uDE1D\uD84E\uDCC1\u371C\uD802\uDEE4\u9DDC\uD875\uDD50\uD86E\uDFE4\u94BB\uD821\uDE4F\uD86F\uDE77\uD84A\uDE25\uAB56\u4907\uB212\uD81A\uDF2E\u1DF8\uD86D\uDD5D\uD80C\uDE84\uB594\uD80C\uDF76\uD866\uDD85\uD805\uDCA9\uD840\uDCF5\uD862\uDC6C\uD85F\uDD6C\u8A0B\uD874\uDC24\uB94E\uD81C\uDC20\u5E1C\uD844\uDF81\uD80C\uDC3E\uD844\uDFFC\uD81D\uDE1B\uD866\uDCF4\u80D4\uD853\uDD24\u17C5\uD808\uDE0B\uFBA2\u67F3\u8D19\u9C3E\u66BE\uD87E\uDD65\uB122\uD874\uDE31\u6B21\uD851\uDF1E\uD849\uDD03\u8033\uD81F\uDF03\uD875\uDD29\uD820\uDC07\uD86E\uDD67\u9108\uD83D\uDF65\uD803\uDC0F\uD82C\uDC4A\uDB40\uDD24\uD800\uDCED\u4F14\uD869\uDD2D\uAA12\uD842\uDE86\u1FD2\u98EE\u20A5\uD82C\uDCCB\uC926\uD850\uDD99\uD851\uDE9F\uD860\uDC07\uD868\uDE9D\uD451\u7282\uD86C\uDD47\uD870\uDF45\uDB40\uDD60\u4D27\u4D57\uD876\uDCA4\u8D1D\u6C1C\uD844\uDC66\uD86C\uDD06\u9E6B\u4AB3\uAE21\uD81A\uDF2A\u7CD7\uD854\uDEA6\u05C7\u664A\u4017\uD857\uDC2E\u7551\u0719\uA0F5\u8FD8\uBE6A\u4823\uD879\uDD9E\u16E5\uD862\uDCC5\uD844\uDED3\uA598\uD855\uDD14\uAE3E\uD873\uDFAC\uD865\uDFF0\uD850\uDC83\u9E5E\uD870\uDE74\uD59D\uD83E\uDC6D\u09DD\uD808\uDDD1\uD840\uDF63\uD870\uDF7E\uD875\uDF1A\uD877\uDFE1\uD876\uDF0F\uD802\uDC6B\uD864\uDC4C\uD854\uDFD7\uCF96\uCA8C\u761E\uD872\uDC89\u6F87\uD83A\uDCA9\u5858\uD822\uDCAF\uD821\uDEB6\uD840\uDEAB\uD821\uDC20\uD85D\uDECC\uD867\uDC59\u5F4F\uD84A\uDDB8\uD85D\uDD77\uD801\uDC01\uD86E\uDFFD\uD84E\uDCEC\uD854\uDD61\u30CA\u29B3\uD872\uDC7B\uC52F\uC7C7\u5ED1\uD863\uDF8B\uD855\uDF03\u1B73\uD86B\uDF49\uD862\uDF33\u208E\u1E85\u0F86\u7B67\uD855\uDD03\uD873\uDC8F\u1F8A\uD870\uDC93\u188E\uD859\uDDA4\uD864\uDFFA\u3AED\u1492\uD860\uDE7D\uAD2A\uD835\uDCC5\uD854\uDEA2\uD870\uDF26\u84FD\uAE7B\uD86F\uDC29\u8DA5\u98B0\uD86A\uDCA3\uD81E\uDE65\uCB51\uD85D\uDDD9\u880E\uD867\uDF74\uD873\uDD54\u9F37\uD81F\uDE39\uD845\uDD3D\uFD02\u8FFE\uD847\uDEF2\uD86D\uDE99\uD820\uDD79\uB0EB\uD809\uDCA3\uB6AB\uD805\uDE2B\uD857\uDF8E\uC1C2\uD847\uDE03\u7E09\uA6AB\uD3EE\u32FF\uD874\uDD13\uD835\uDEE1\uD85E\uDC13\uD835\uDD95\uD869\uDC00\u1084\u521E\u5723\u1936\uB338\uD841\uDF8D\u7F49\uD85D\uDC9B\uD877\uDFA8\uD86B\uDD54\uD87A\uDE9C\u6FA5\uD800\uDE94\uD84A\uDF2F\uD851\uDE47\u6BC1\u2713\uD852\uDC0C\uD6DB\uD86B\uDC7D\u812B\uD80C\uDE68\uD85C\uDF8C\uB313\u0F65\uD820\uDEBF\uD822\uDD80\u8C5C\u527D\uD865\uDEF9\u9B09\uD842\uDD96\uD86C\uDCDA\uAD6E\uD84C\uDEAE\uCE9F\uD874\uDC18\u4B30\uD853\uDF10\uD861\uDEA4\uD879\uDCBF\uD859\uDD75\uD86F\uDE14\uD81D\uDE26\u914E\uD820\uDF50\uD84B\uDEDC\uD85E\uDD76\uD856\uDD6B\uC144\uD843\uDE6E\uD83A\uDD2A\u4E93\u27CE\uD862\uDCF5\u54CF\uD879\uDFC8\uD808\uDE24\u3EE4\uFD94\uD86D\uDCA1\uD87E\uDCC7\u791A\u9DF7\u897C\uB85D\uD801\uDC9C\uD84A\uDD48\uD801\uDF20\u7ABC\uD83D\uDC99\uA9F0\u2366\u8E95\uD857\uDF9E\u07DF\uD872\uDD6D\uD854\uDCEF\uD848\uDF2A\u6E7D\uB99D\uD843\uDD20\u9275\uD867\uDE47\uD857\uDCE8\uD820\uDDAB\uC7BD\uFB95\uD81A\uDE50\u37A9\uD82C\uDCC3\u7DE6\u4E23\uD844\uDE6D\uD85C\uDC58\uD802\uDDCE\u8454\uD843\uDDFD\uD840\uDD2F\uD854\uDF77\uD853\uDFA9\uD876\uDF77\u103B\uBC9E\uD82C\uDECD\uD861\uDE96\uD853\uDF48\uD864\uDEAF\u0C30\uD85F\uDC52\uA195\u580F\uD873\uDC63\uD849\uDF9F\u1F9E\uD878\uDF45\u585D\u12FE\uD800\uDD19\uD860\uDC32\uD82C\uDCBB\u33C0\u4D7A\uD84D\uDF4B\u250F\uD857\uDD21\u2CA9\uD81C\uDD5B\uD877\uDD53\u2BA0\uC921\uD85D\uDF79\uD847\uDFBD\u9CA7\uD83C\uDF5B\uD868\uDF75\u3872\uD85E\uDEF2\u6847\uB526\uD849\uDCDC\uD869\uDC1D\u2EBC\uD876\uDEF2\uD868\uDDED\uB5E8\uD848\uDCAC\uD860\uDCF8\uBC8E\u6CAA\u2236\uD869\uDDF7\u21AC\uD801\uDE4B\uBFA5\uD877\uDDA9\uD851\uDDEE\uD85F\uDF33\uD848\uDC8F\uD864\uDCD6\uFF64\uD85E\uDE87\uD856\uDE04\u484C\u3170\uD843\uDDE8\u5A8D\u7EB1\u3F78\uD80C\uDD00\uD801\uDD56\uD857\uDF76\uD874\uDD2E\uD852\uDF28\uD86B\uDFA2\uD808\uDDB5\uB323\uA0C2\uD847\uDD6C\u5988\uD85A\uDF89\uD858\uDC2F\u0732\uD86F\uDFB5\u2623\uD854\uDF73\uD870\uDC50\u6324\u18C8\u8124\uD84B\uDF63\uD83C\uDF36\uD81E\uDE9D\uD86A\uDD71\uFCFB\uD873\uDDDD\uD857\uDF41\uD876\uDC93\u84E5\uD81F\uDFCD\uD869\uDEB3\uD852\uDF7C\uD867\uDDC2\uB0B3\uD81E\uDD10\uD86C\uDE43\uBB58\uD805\uDF38\uD86E\uDEDB\uD866\uDDE4\uBFF4\uD81A\uDCA3\uD858\uDCA2\u26DB\uD865\uDFAB\uAC56\uD835\uDF87\uD86D\uDC20\u8BE5\u9FC5\uD854\uDF98\uCF52\u8911\uD86D\uDD46\uD856\uDDB7\uA576\u0FB2\uD868\uDE23\u191C\uD85A\uDDAD\uD86A\uDEAC\uD849\uDE8D\uD875\uDEE4\u35F0\uD842\uDC92\uD800\uDD33\u2CC0\u23CD\u14E8\uD835\uDD10\u96A8\uD860\uDD4C\uD86C\uDFBD\uD861\uDC52\uD85E\uDEB0\uD81F\uDEC6\uD807\uDC3F\uD858\uDD7E\u24F2\uD863\uDEF7\uD867\uDFDD\u9508\uD859\uDCEC\uD873\uDFED\u39C0\u3251\uFFE0\uD86F\uDE34\uD808\uDC7A\uD85F\uDFD5\uD855\uDDCF\uBF31\uD85D\uDEA7\uD83C\uDC0E\uC629\uD841\uDF6E\uA64F\u863E\uD862\uDF02\uD86D\uDCA2\uD857\uDD2E\u145A\uD805\uDF20\u2018\uD877\uDE36\uD86F\uDC3A\u8CF9\uD86A\uDD51\uD875\uDF8B\uD855\uDCE8\uD3D9\uD83D\uDDF5\uD871\uDC32\u5CF2\uD877\uDEDC\u6567\uD84C\uDD44\u1041\uD867\uDD70\uD844\uDF9B\uD858\uDC9B\u1C1D\uD873\uDFB2\uD862\uDFD7\u7B05\uD863\uDE0A\u47AA\uD80C\uDDEE\u59F9\uD856\uDCAA\uD82C\uDEF9\u3B43\u87EC\uD855\uDDDC\u80A5\uD850\uDC09\u21D5\uD85A\uDFB8\u6E9C\uD86B\uDE2C\uD807\uDD36\uD879\uDE51\u9FA0\uD86E\uDE5C\uD81C\uDEFC\uD847\uDD71\u6440\u2B19\u0084\uA244\uD874\uDF6D\uC75E\uCE78\uD856\uDC24\uA9BD\uD844\uDE5E\uD835\uDCF0\uD83E\uDD64\u54EC\u5614\uD868\uDD4D\uD85C\uDFEB\uD855\uDCF0\uB35D\u86CC\u6C1C\uA2CF\uD86D\uDC1F\u428A\uD803\uDCD0\u3922\u7B1F\uD822\uDDE2\uD860\uDD61\uD811\uDD12\u8A8E\uD801\uDD48\u42FE\uD85A\uDF51\u80CA\uD859\uDF21\uD853\uDE0D\uD86C\uDDE9\uD800\uDE8D\uD853\uDEE8\uD874\uDD49\u763C\uD873\uDDD7\u1B0E\uD85E\uDE78\uD857\uDE23\u4CEE\uD855\uDF16\uB1A0\uCF36\uD801\uDC22\u8151\u166A\uD809\uDCF9\uD85B\uDF00\uD85A\uDF05\uD862\uDF74\uD81E\uDDFA\uC410\u3787\uD863\uDEDC\uD81E\uDEBD\u8A19\u8710\u10A8\uB715\uD806\uDE6D\uD874\uDDEC\uD849\uDC19\uBB7A\uC9D2\uD879\uDFBE\uD865\uDE0C\uD840\uDD35\u3343\u1F9B\uBD98\u7E47\uD841\uDC24\uD864\uDCFD\uD853\uDF20\u4F81\u1A24\u3D41\u3552\uD851\uDC40\uD857\uDD89\u5E21\uD874\uDFCD\uD841\uDDD2\u12CE\u8487\uD875\uDC7A\uD801\uDC62\u73A1\u4D24\u5163\uD836\uDDF1\u5D38\u081E\u24A2\u9EE0\uCC12\uD864\uDC1F\u9BBE\uD878\uDCEF\uD86C\uDCC9\uD842\uDCFB\u814F\uD853\uDDFB\uD861\uDDF9\uD85C\uDDA3\u651F\uD845\uDF52\u1BA2\uD84E\uDDF3\u3244\uD81D\uDFB7\uD84C\uDD28\uD842\uDC3A\uD854\uDF75\u35D2\uD84B\uDC7B\uD855\uDE64\u5384\uD863\uDCDC\uD81A\uDCA5\uD844\uDD91\uDB40\uDC2F\uD86F\uDC45\uD85D\uDFB3\u1B88\uD805\uDF23\uD84B\uDE5F\uD84A\uDFEA\uD84A\uDF9A\uD842\uDCA7\uD862\uDFDF\uD85C\uDD57\u56B6\uD84A\uDE3D\uD87A\uDCE6\u478F\uD877\uDE38\u6C60\uD849\uDE4F\u1D9F\u055A\u3741\uD81F\uDFD3\u69FE\uD84D\uDD18\uDB40\uDC7E\u8AB8\uD81E\uDEF3\u4EDF\uD877\uDD66\uD849\uDDD4\u7C7C\uD854\uDFF3\uD843\uDE55\u6EF4\uD835\uDD09\uD866\uDCA4\u63E2\uD86D\uDF5E\uD81E\uDEC3\uAB09\u529B\uD81B\uDF59\uD84A\uDD19\uD855\uDD04\uD86C\uDF2F\uD863\uDFE6\u783D\u937A\uD84E\uDF0F\uD877\uDCDE\uD845\uDF98\u55C6\uD81A\uDD3F\uD820\uDCAB\uD872\uDC11\uAD96\u5C38\uD338\uD858\uDDF8\uBC6D\uD84D\uDEC9\uD85C\uDF11\uD867\uDCB8\uD834\uDCEE\u306C\u1646\uD842\uDE09\u513B\uD865\uDDCA\u2F1C\uA25F\uD790\uD875\uDF9D\u6791\uD846\uDF46\uD872\uDDF8\uD39C\uD84F\uDE5F\uD83C\uDF32\uD83D\uDF04\u04BB\uD870\uDD56\uD86B\uDE4F\uC028\u9C00\uD81C\uDFE3\uD834\uDD54\uD81E\uDED9\u3A7F\u6D65\u9353\uD793\uD84A\uDD3B\u45FF\uD86A\uDEAB\u141B\uD820\uDF4A\uD857\uDFBB\uD872\uDE41\uD85F\uDC35\u15C3\uACBD\uD81D\uDC2E\uD870\uDC31\uD875\uDE0E\uD861\uDC33\uD871\uDCCD\uD865\uDEEF\uD83E\uDC37\uD1C4\uD846\uDCC1\uD87A\uDDCA\uD821\uDCF7\uD83C\uDC1C\uD81D\uDC4A\uD861\uDCC5\uD869\uDF13\uD840\uDD0E\u23EB\uD851\uDD70\uD847\uDF0E\u4013\uB0EB\u656E\uD865\uDDFD\uD841\uDFF8\u7FB7\u14DE\uD86A\uDD94\uD834\uDCE6\",\"_entity_type\":\"indexNameType\"}",
+ JsonObject.class
+ )
+ )
+ ) );
+
+ return params;
+ }
+
+ private GsonHttpEntity gsonEntity;
+ private String expectedPayloadString;
+ private int expectedContentLength;
+
+ public void init(List payload) throws IOException {
+ Gson gson = GsonProvider.create( GsonBuilder::new, true ).getGson();
+ this.gsonEntity = new GsonHttpEntity( gson, payload );
+ StringBuilder builder = new StringBuilder();
+ for ( JsonObject object : payload ) {
+ gson.toJson( object, builder );
+ builder.append( "\n" );
+ }
+ this.expectedPayloadString = builder.toString();
+ this.expectedContentLength = StandardCharsets.UTF_8.encode( expectedPayloadString ).limit();
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void initialContentLength(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ // The content length cannot be known from the start for large, multi-object payloads
+ assumeTrue( payload.size() <= 1 || expectedContentLength < 1024 );
+
+ assertThat( gsonEntity.getContentLength() ).isEqualTo( expectedContentLength );
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void contentType(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ String contentType = gsonEntity.getContentType();
+ assertThat( contentType ).isEqualTo( "application/json; charset=UTF-8" );
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_noPushBack(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ int pushBackPeriod = Integer.MAX_VALUE;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every5Bytes(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ int pushBackPeriod = 5;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every100Bytes(String ignoredLabel, List payload)
+ throws IOException {
+ init( payload );
+ int pushBackPeriod = 100;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void produceContent_pushBack_every500Bytes(String ignoredLabel, List payload)
+ throws IOException {
+ init( payload );
+ int pushBackPeriod = 500;
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doProduceContent( gsonEntity, pushBackPeriod ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void writeTo(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doWriteTo( gsonEntity ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("params")
+ void getContent(String ignoredLabel, List payload) throws IOException {
+ init( payload );
+ for ( int i = 0; i < 2; i++ ) { // Try several times: the result shouldn't change.
+ assertThat( doGetContent( gsonEntity ) )
+ .isEqualTo( expectedPayloadString );
+ assertThat( gsonEntity.getContentLength() )
+ .isEqualTo( expectedContentLength );
+ }
+ }
+
+ private String doProduceContent(GsonHttpEntity entity, int pushBackPeriod) throws IOException {
+ try ( ByteArrayOutputStream outputStream = new ByteArrayOutputStream() ) {
+ OutputStreamContentEncoder contentEncoder = new OutputStreamContentEncoder( outputStream, pushBackPeriod );
+ while ( !contentEncoder.isCompleted() ) {
+ entity.produce( contentEncoder );
+ }
+ return outputStream.toString( StandardCharsets.UTF_8.name() );
+ }
+ finally {
+ entity.close();
+ }
+ }
+
+ private String doWriteTo(GsonHttpEntity entity) throws IOException {
+ try ( ByteArrayOutputStream outputStream = new ByteArrayOutputStream() ) {
+ entity.writeTo( outputStream );
+ return outputStream.toString( StandardCharsets.UTF_8.name() );
+ }
+ }
+
+ private String doGetContent(GsonHttpEntity entity) throws IOException {
+ try ( InputStream inputStream = entity.getContent();
+ Reader reader = new InputStreamReader( inputStream, StandardCharsets.UTF_8 );
+ BufferedReader bufferedReader = new BufferedReader( reader ) ) {
+ StringBuilder builder = new StringBuilder();
+ int read;
+ while ( ( read = bufferedReader.read() ) >= 0 ) {
+ builder.appendCodePoint( read );
+ }
+ return builder.toString();
+ }
+ }
+
+ private static class OutputStreamContentEncoder implements ContentEncoder, DataStreamChannel {
+ private boolean complete = false;
+ private final OutputStream outputStream;
+ private final int pushBackPeriod;
+
+ private int written = 0;
+ private boolean pushedBack = false;
+
+ private OutputStreamContentEncoder(OutputStream outputStream, int pushBackPeriod) {
+ this.outputStream = outputStream;
+ this.pushBackPeriod = pushBackPeriod;
+ }
+
+ @Override
+ public void requestOutput() {
+
+ }
+
+ @Override
+ public int write(ByteBuffer src) throws IOException {
+ int toWrite = src.remaining();
+ if ( !pushedBack && ( written % pushBackPeriod ) != ( ( written + toWrite ) % pushBackPeriod ) ) {
+ // push back
+ pushedBack = true;
+ return 0;
+ }
+ pushedBack = false;
+ outputStream.write( src.array(), src.arrayOffset(), toWrite );
+ written += toWrite;
+ return toWrite;
+ }
+
+ @Override
+ public void endStream() throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void endStream(List extends Header> trailers) throws IOException {
+ complete = true;
+ }
+
+ @Override
+ public void complete(List extends Header> trailers) {
+ this.complete = true;
+ }
+
+ @Override
+ public boolean isCompleted() {
+ return this.complete;
+ }
+ }
+}
diff --git a/backend/elasticsearch/pom.xml b/backend/elasticsearch/pom.xml
index 75a8c67ede9..126a024abb4 100644
--- a/backend/elasticsearch/pom.xml
+++ b/backend/elasticsearch/pom.xml
@@ -29,12 +29,13 @@
hibernate-search-engine