diff --git a/docs/src/main/asciidoc/hibernate-search-orm-elasticsearch.adoc b/docs/src/main/asciidoc/hibernate-search-orm-elasticsearch.adoc index 520433bbbecdc7..7feaf2b0312478 100644 --- a/docs/src/main/asciidoc/hibernate-search-orm-elasticsearch.adoc +++ b/docs/src/main/asciidoc/hibernate-search-orm-elasticsearch.adoc @@ -1015,6 +1015,112 @@ You can enable AWS request signing in Hibernate Search by adding a dedicated ext See link:{hibernate-search-orm-elasticsearch-aws-guide}#aws-configuration-reference[the documentation for the Hibernate Search ORM + Elasticsearch AWS extension] for more information. +[[management]] +== Management endpoint + +[CAUTION] +==== +Hibernate Search's management endpoint is considered preview. + +In _preview_, backward compatibility and presence in the ecosystem is not guaranteed. +Specific improvements might require changing configuration or APIs, or even storage formats, +and plans to become _stable_ are under way. +Feedback is welcome on our https://groups.google.com/d/forum/quarkus-dev[mailing list] +or as issues in our https://github.com/quarkusio/quarkus/issues[GitHub issue tracker]. +==== + +The Hibernate Search extension provides an HTTP endpoint to reindex your data through the xref:./management-interface-reference.adoc[management interface]. +By default, this endpoint is not available. It can be enabled through configuration properties as shown below. + +[source,properties] +---- +quarkus.management.enabled=true <1> +quarkus.hibernate-search-orm.management.enabled=true <2> +---- +<1> Enable the xref:./management-interface-reference.adoc[management interface]. +<2> Enable Hibernate Search specific management endpoints. + +Once the management is enabled, data can be re-indexed via `/q/hibernate-search/reindex`, where `/q` is the default management root path +and `/hibernate-search` is the default Hibernate Search root management path. +It (`/hibernate-search`) can be changed via configuration property as shown below. + +[source,properties] +---- +quarkus.hibernate-search-orm.management.root-path=custom-root-path <1> +---- +<1> Use a custom `custom-root-path` path for Hibernate Search's management endpoint. +If the default management root path is used then the reindex path becomes `/q/custom-root-path/reindex`. + +This endpoint accepts `POST` requests with `application/json` content type only. +All indexed entities will be re-indexed if an empty request body is submitted. +If only a subset of entities must be re-indexed or +if there is a need to have a custom configuration of the underlying mass indexer +then this information can be passed through the request body as shown below. + +[source,json] +---- +{ + "filter": { + "types": ["EntityName1", "EntityName2", "EntityName3", ...], <1> + }, + "massIndexer":{ + "typesToIndexInParallel": 1, <2> + } +} +---- +<1> An array of entity names that should be re-indexed. If unspecified or empty, all entity types will be re-indexed. +<2> Sets the number of entity types to be indexed in parallel. + +The full list of possible filters and available mass indexer configurations is presented in the example below. + +[source,json] +---- +{ + "filter": { <1> + "types": ["EntityName1", "EntityName2", "EntityName3", ...], <2> + "tenants": ["tenant1", "tenant2", ...] <3> + }, + "massIndexer":{ <4> + "typesToIndexInParallel": 1, <5> + "threadsToLoadObjects": 6, <6> + "batchSizeToLoadObjects": 10, <7> + "cacheMode": "IGNORE", <8> + "mergeSegmentsOnFinish": false, <9> + "mergeSegmentsAfterPurge": true, <10> + "dropAndCreateSchemaOnStart": false, <11> + "purgeAllOnStart": true, <12> + "idFetchSize": 100, <13> + "transactionTimeout": 100000, <14> + } +} +---- +<1> Filter object that allows to limit the scope of reindexing. +<2> An array of entity names that should be re-indexed. If unspecified or empty, all entity types will be re-indexed. +<3> An array of tenant ids, in case of multi-tenancy. If unspecified or empty, all tenants will be re-indexed. +<4> Mass indexer configuration object. +<5> Sets the number of entity types to be indexed in parallel. +<6> Sets the number of threads to be used to load the root entities. +<7> Sets the batch size used to load the root entities. +<8> Sets the cache interaction mode for the data loading tasks. +<9> Whether each index is merged into a single segment after indexing. +<10> Whether each index is merged into a single segment after the initial index purge, just before indexing. +<11> Whether the indexes and their schema (if they exist) should be dropped and re-created before indexing. +<12> Whether all entities are removed from the indexes before indexing. +<13> Specifies the fetch size to be used when loading primary keys if objects to be indexed. +<14> Specifies the timeout of transactions for loading ids and entities to be re-indexed. ++ +Note all the properties in the json are optional, and only those that are needed should be used. + +For more detailed information on mass indexer configuration see the +link:{hibernate-search-docs-url}#indexing-massindexer-parameters[corresponding section of the Hibernate Search reference documentation]. + +Submitting the reindexing request will trigger indexing in the background. Mass indexing progress will appear in the application logs. +For testing purposes, it might be useful to know when the indexing finished. Adding `wait_for=finished` query parameter to the URL +will result in the management endpoint returning a chunked response that will report when the indexing starts and then when it is finished. + +When working with multiple persistence units, the name of the persistence unit to reindex can be supplied through the +`persistence_unit` query parameter: `/q/hibernate-search/reindex?persistence_unit=non-default-persistence-unit`. + == Further reading If you are interested in learning more about Hibernate Search 6, diff --git a/extensions/hibernate-search-orm-elasticsearch/deployment/pom.xml b/extensions/hibernate-search-orm-elasticsearch/deployment/pom.xml index 9f9331be27bbfb..82c1bb736ffff1 100644 --- a/extensions/hibernate-search-orm-elasticsearch/deployment/pom.xml +++ b/extensions/hibernate-search-orm-elasticsearch/deployment/pom.xml @@ -33,6 +33,11 @@ io.quarkus quarkus-vertx-http-dev-ui-spi + + io.quarkus + quarkus-vertx-http-deployment + true + diff --git a/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchElasticsearchProcessor.java b/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchElasticsearchProcessor.java index d89ae9184541a7..df395c0ffd7b37 100644 --- a/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchElasticsearchProcessor.java +++ b/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchElasticsearchProcessor.java @@ -65,8 +65,11 @@ import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchBuildTimeConfigPersistenceUnit.ElasticsearchIndexBuildTimeConfig; import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchRecorder; import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchRuntimeConfig; +import io.quarkus.hibernate.search.orm.elasticsearch.runtime.management.HibernateSearchManagementConfig; import io.quarkus.runtime.configuration.ConfigUtils; import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; @BuildSteps(onlyIf = HibernateSearchEnabled.class) class HibernateSearchElasticsearchProcessor { @@ -435,4 +438,21 @@ void devServicesDropAndCreateAndDropByDefault( } } + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep(onlyIf = HibernateSearchManagementEnabled.class) + void createManagementRoutes(BuildProducer routes, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, + HibernateSearchElasticsearchRecorder recorder, + HibernateSearchManagementConfig managementConfig) { + + routes.produce(nonApplicationRootPathBuildItem.routeBuilder() + .management() + .route( + managementConfig.rootPath() + (managementConfig.rootPath().endsWith("/") ? "" : "/") + + "reindex") + .routeConfigKey("quarkus.hibernate-search-orm.management.root-path") + .handler(recorder.managementHandler()) + .displayOnNotFoundPage() + .build()); + } } diff --git a/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchManagementEnabled.java b/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchManagementEnabled.java new file mode 100644 index 00000000000000..04093b0b3de91c --- /dev/null +++ b/extensions/hibernate-search-orm-elasticsearch/deployment/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/deployment/HibernateSearchManagementEnabled.java @@ -0,0 +1,25 @@ +package io.quarkus.hibernate.search.orm.elasticsearch.deployment; + +import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchBuildTimeConfig; +import io.quarkus.hibernate.search.orm.elasticsearch.runtime.management.HibernateSearchManagementConfig; + +/** + * Supplier that can be used to only run build steps + * if the Hibernate Search extension and its management is enabled. + */ +public class HibernateSearchManagementEnabled extends HibernateSearchEnabled { + + private final HibernateSearchManagementConfig config; + + HibernateSearchManagementEnabled(HibernateSearchElasticsearchBuildTimeConfig config, + HibernateSearchManagementConfig managementConfig) { + super(config); + this.config = managementConfig; + } + + @Override + public boolean getAsBoolean() { + return super.getAsBoolean() && config.enabled(); + } + +} diff --git a/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/HibernateSearchElasticsearchRecorder.java b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/HibernateSearchElasticsearchRecorder.java index 586a3942338d44..b1801a4bdb9913 100644 --- a/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/HibernateSearchElasticsearchRecorder.java +++ b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/HibernateSearchElasticsearchRecorder.java @@ -51,9 +51,12 @@ import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchRuntimeConfigPersistenceUnit.ElasticsearchBackendRuntimeConfig; import io.quarkus.hibernate.search.orm.elasticsearch.runtime.HibernateSearchElasticsearchRuntimeConfigPersistenceUnit.ElasticsearchIndexRuntimeConfig; import io.quarkus.hibernate.search.orm.elasticsearch.runtime.bean.HibernateSearchBeanUtil; +import io.quarkus.hibernate.search.orm.elasticsearch.runtime.management.HibernateSearchManagementHandler; import io.quarkus.hibernate.search.orm.elasticsearch.runtime.mapping.QuarkusHibernateOrmSearchMappingConfigurer; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.configuration.ConfigurationException; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; @Recorder public class HibernateSearchElasticsearchRecorder { @@ -165,6 +168,10 @@ public SearchSession get() { }; } + public Handler managementHandler() { + return new HibernateSearchManagementHandler(); + } + private static final class HibernateSearchIntegrationStaticInitInactiveListener implements HibernateOrmIntegrationStaticInitListener { private HibernateSearchIntegrationStaticInitInactiveListener() { diff --git a/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementConfig.java b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementConfig.java new file mode 100644 index 00000000000000..0235472ff9d51b --- /dev/null +++ b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementConfig.java @@ -0,0 +1,30 @@ +package io.quarkus.hibernate.search.orm.elasticsearch.runtime.management; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.hibernate-search-orm.management") +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public interface HibernateSearchManagementConfig { + + /** + * Root path for reindexing endpoints. + * This value will be resolved as a path relative to `${quarkus.management.root-path}`. + * + * @asciidoclet + */ + @WithDefault("hibernate-search/") + String rootPath(); + + /** + * If management interface is turned on the reindexing endpoints will be published under the management interface. + * This property allows to enable this functionality by setting it to ``true`. + * + * @asciidoclet + */ + @WithDefault("false") + boolean enabled(); + +} diff --git a/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementHandler.java b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementHandler.java new file mode 100644 index 00000000000000..e478fa011f2d3e --- /dev/null +++ b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchManagementHandler.java @@ -0,0 +1,53 @@ +package io.quarkus.hibernate.search.orm.elasticsearch.runtime.management; + +import java.util.Locale; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ManagedContext; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; + +public class HibernateSearchManagementHandler implements Handler { + + @Override + public void handle(RoutingContext routingContext) { + ManagedContext requestContext = Arc.container().requestContext(); + if (requestContext.isActive()) { + doHandle(routingContext); + } else { + requestContext.activate(); + try { + doHandle(routingContext); + } finally { + requestContext.terminate(); + } + } + } + + private void doHandle(RoutingContext ctx) { + HttpServerRequest request = ctx.request(); + + if (!HttpMethod.POST.equals(request.method())) { + errorResponse(ctx, 406, "Http method [" + request.method().name() + "] is not supported. Use [POST] instead."); + return; + } + + String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE); + if (contentType != null && !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) { + errorResponse(ctx, 406, "Content type [" + contentType + " is not supported. Use [application/json] instead."); + return; + } + + new HibernateSearchPostRequestProcessor().process(ctx); + } + + private void errorResponse(RoutingContext ctx, int code, String message) { + ctx.response() + .setStatusCode(code) + .setStatusMessage(message) + .end(); + } +} diff --git a/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchPostRequestProcessor.java b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchPostRequestProcessor.java new file mode 100644 index 00000000000000..35777e89dfebc4 --- /dev/null +++ b/extensions/hibernate-search-orm-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/orm/elasticsearch/runtime/management/HibernateSearchPostRequestProcessor.java @@ -0,0 +1,220 @@ +package io.quarkus.hibernate.search.orm.elasticsearch.runtime.management; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.stream.Collectors; + +import org.hibernate.CacheMode; +import org.hibernate.search.mapper.orm.mapping.SearchMapping; +import org.hibernate.search.mapper.orm.massindexing.MassIndexer; +import org.hibernate.search.mapper.orm.scope.SearchScope; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.hibernate.orm.PersistenceUnit; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; + +class HibernateSearchPostRequestProcessor { + + private static final String QUERY_PARAM_WAIT_FOR = "wait_for"; + private static final String QUERY_PARAM_PERSISTENCE_UNIT = "persistence_unit"; + + public void process(RoutingContext ctx) { + JsonObject config = ctx.body().asJsonObject(); + if (config == null) { + config = new JsonObject(); + } + try (InstanceHandle searchMappingInstanceHandle = searchMappingInstanceHandle(ctx.request())) { + + SearchMapping searchMapping = searchMappingInstanceHandle.get(); + + JsonObject filter = config.getJsonObject("filter"); + List types = getTypesToFilter(filter); + Set tenants = getTenants(filter); + MassIndexer massIndexer; + if (types == null || types.isEmpty()) { + massIndexer = createMassIndexer(searchMapping.scope(Object.class), tenants); + } else { + massIndexer = createMassIndexer(searchMapping.scope(Object.class, types), tenants); + } + + HibernateSearchMassIndexerConfiguration.configure(massIndexer, config.getJsonObject("massIndexer")); + + CompletionStage massIndexerFuture = massIndexer.start(); + + if (WaitFor.STARTED.equals(getWaitForParameter(ctx.request()))) { + ctx.response().end(message(202, "Reindexing started")); + } else { + ctx.response() + .setChunked(true) + .write(message(202, "Reindexing started"), + ignored -> massIndexerFuture.whenComplete((ignored2, throwable) -> { + if (throwable == null) { + ctx.response().end(message(200, "Reindexing succeeded")); + } else { + ctx.response().end(message( + 500, + "Reindexing failed:\n" + Arrays.stream(throwable.getStackTrace()) + .map(Object::toString) + .collect(Collectors.joining("\n")))); + } + })); + } + } + } + + private MassIndexer createMassIndexer(SearchScope scope, Set tenants) { + if (tenants == null || tenants.isEmpty()) { + return scope.massIndexer(); + } else { + return scope.massIndexer(tenants); + } + } + + private List getTypesToFilter(JsonObject filter) { + if (filter == null) { + return null; + } + JsonArray array = filter.getJsonArray("types"); + if (array == null) { + return null; + } + List types = array + .stream() + .map(Object::toString) + .collect(Collectors.toList()); + return types.isEmpty() ? null : types; + } + + private Set getTenants(JsonObject filter) { + if (filter == null) { + return null; + } + JsonArray array = filter.getJsonArray("tenants"); + if (array == null) { + return null; + } + Set types = array + .stream() + .map(Object::toString) + .collect(Collectors.toSet()); + return types.isEmpty() ? null : types; + } + + private WaitFor getWaitForParameter(HttpServerRequest request) { + return WaitFor.valueOf(request.getParam(QUERY_PARAM_WAIT_FOR, WaitFor.STARTED.name()).toUpperCase(Locale.ROOT)); + } + + private InstanceHandle searchMappingInstanceHandle(HttpServerRequest request) { + String pu = request.getParam(QUERY_PARAM_PERSISTENCE_UNIT, PersistenceUnit.DEFAULT); + return Arc.container().instance(SearchMapping.class, new PersistenceUnit.PersistenceUnitLiteral(pu)); + } + + private static String message(int code, String message) { + return JsonObject.of("code", code, "message", message) + "\n"; + } + + private enum WaitFor { + STARTED, + FINISHED; + } + + private static final class HibernateSearchMassIndexerConfiguration { + private HibernateSearchMassIndexerConfiguration() { + } + + /** + * Sets the number of entity types to be indexed in parallel + */ + private static final String TYPES_TO_INDEX_IN_PARALLEL = "typesToIndexInParallel"; + + /** + * Sets the number of threads to be used to load the root entities. + */ + private static final String THREADS_TO_LOAD_OBJECTS = "threadsToLoadObjects"; + + /** + * Sets the batch size used to load the root entities. + */ + private static final String BATCH_SIZE_TO_LOAD_OBJECTS = "batchSizeToLoadObjects"; + + /** + * Sets the cache interaction mode for the data loading tasks. + */ + private static final String CACHE_MODE = "cacheMode"; + + /** + * If each index is merged into a single segment after indexing. + */ + private static final String MERGE_SEGMENTS_ON_FINISH = "mergeSegmentsOnFinish"; + + /** + * If each index is merged into a single segment after the initial index purge, just before indexing. + */ + private static final String MERGE_SEGMENTS_AFTER_PURGE = "mergeSegmentsAfterPurge"; + + /** + * If the indexes and their schema (if they exist) should be dropped and re-created before indexing. + */ + private static final String DROP_AND_CREATE_SCHEMA_ON_START = "dropAndCreateSchemaOnStart"; + + /** + * If all entities are removed from the indexes before indexing. + */ + private static final String PURGE_ALL_ON_START = "purgeAllOnStart"; + + /** + * Specifies the fetch size to be used when loading primary keys if objects to be indexed. + */ + private static final String ID_FETCH_SIZE = "idFetchSize"; + + /** + * Specifies the timeout of transactions for loading ids and entities to be re-indexed. + */ + private static final String TRANSACTION_TIMEOUT = "transactionTimeout"; + + private static MassIndexer configure(MassIndexer massIndexer, JsonObject config) { + if (config == null) { + return massIndexer; + } + if (config.getInteger(TYPES_TO_INDEX_IN_PARALLEL) != null) { + massIndexer.typesToIndexInParallel(config.getInteger(TYPES_TO_INDEX_IN_PARALLEL)); + } + if (config.getInteger(THREADS_TO_LOAD_OBJECTS) != null) { + massIndexer.threadsToLoadObjects(config.getInteger(THREADS_TO_LOAD_OBJECTS)); + } + if (config.getInteger(BATCH_SIZE_TO_LOAD_OBJECTS) != null) { + massIndexer.batchSizeToLoadObjects(config.getInteger(BATCH_SIZE_TO_LOAD_OBJECTS)); + } + if (config.getString(CACHE_MODE) != null) { + massIndexer.cacheMode(CacheMode.valueOf(config.getString(CACHE_MODE))); + } + if (config.getBoolean(MERGE_SEGMENTS_ON_FINISH) != null) { + massIndexer.mergeSegmentsOnFinish(config.getBoolean(MERGE_SEGMENTS_ON_FINISH)); + } + if (config.getBoolean(MERGE_SEGMENTS_AFTER_PURGE) != null) { + massIndexer.mergeSegmentsAfterPurge(config.getBoolean(MERGE_SEGMENTS_AFTER_PURGE)); + } + if (config.getBoolean(DROP_AND_CREATE_SCHEMA_ON_START) != null) { + massIndexer.dropAndCreateSchemaOnStart(config.getBoolean(DROP_AND_CREATE_SCHEMA_ON_START)); + } + if (config.getBoolean(PURGE_ALL_ON_START) != null) { + massIndexer.purgeAllOnStart(config.getBoolean(PURGE_ALL_ON_START)); + } + if (config.getInteger(ID_FETCH_SIZE) != null) { + massIndexer.idFetchSize(config.getInteger(ID_FETCH_SIZE)); + } + if (config.getInteger(TRANSACTION_TIMEOUT) != null) { + massIndexer.transactionTimeout(config.getInteger(TRANSACTION_TIMEOUT)); + } + + return massIndexer; + } + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/Book.java b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/Book.java new file mode 100644 index 00000000000000..9e6a156a01f832 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/Book.java @@ -0,0 +1,92 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.book; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; + +@Entity +@Table(name = "books") +@Indexed +public class Book { + + @Id + @SequenceGenerator(name = "booksSequence", sequenceName = "books_id_seq", allocationSize = 1, initialValue = 10) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "booksSequence") + private Integer id; + + @FullTextField + @Column(length = 40, unique = true) + private String name; + + public Book() { + } + + public Book(String name) { + this.name = name; + } + + public Book(Integer id, String name) { + this.id = id; + this.name = name; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Book other = (Book) obj; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + if (name == null) { + if (other.name != null) + return false; + } else if (!name.equals(other.name)) + return false; + return true; + } + + @Override + public String toString() { + return "Book [id=" + id + ", name=" + name + "]"; + } + +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookResource.java b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookResource.java new file mode 100644 index 00000000000000..8844afa887c6ac --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookResource.java @@ -0,0 +1,53 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.book; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import org.hibernate.search.mapper.orm.session.SearchSession; + +import io.quarkus.hibernate.orm.PersistenceUnit; + +@ApplicationScoped +@Produces("application/json") +@Consumes("application/json") +@Path("/books") +public class BookResource { + + @Inject + @PersistenceUnit("books") + EntityManager entityManager; + @Inject + @PersistenceUnit("books") + SearchSession searchSession; + + @POST + @Path("/") + @Transactional + public Response create(@NotNull Book book) { + searchSession.indexingPlanFilter(context -> context.exclude(Book.class)); + entityManager.persist(book); + return Response.ok(book).status(Response.Status.CREATED).build(); + } + + @GET + @Path("/search") + @Transactional + public Response search(@NotNull @QueryParam("terms") String terms) { + List list = searchSession.search(Book.class) + .where(f -> f.simpleQueryString().field("name").matching(terms)) + .fetchAllHits(); + return Response.status(Response.Status.OK).entity(list).build(); + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookTenantResolver.java b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookTenantResolver.java new file mode 100644 index 00000000000000..04dda244ad8240 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/BookTenantResolver.java @@ -0,0 +1,26 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.book; + +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.enterprise.context.RequestScoped; + +import io.quarkus.hibernate.orm.PersistenceUnitExtension; +import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver; + +@PersistenceUnitExtension("books") +@RequestScoped +public class BookTenantResolver implements TenantResolver { + + public static final AtomicReference TENANT_ID = new AtomicReference<>("company3"); + + @Override + public String getDefaultTenantId() { + return "base"; + } + + @Override + public String resolveTenantId() { + return TENANT_ID.get(); + } + +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/application.properties b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/application.properties index 98944ea31cc0e5..5e2e31cb3b8ef2 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/application.properties +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/application.properties @@ -5,9 +5,22 @@ quarkus.flyway.clean-at-start=true quarkus.hibernate-orm.database.generation=none quarkus.hibernate-orm.multitenant=schema +quarkus.hibernate-orm.packages=io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.fruit quarkus.hibernate-search-orm.elasticsearch.version=8 quarkus.hibernate-search-orm.elasticsearch.hosts=${elasticsearch.hosts:localhost:9200} quarkus.hibernate-search-orm.elasticsearch.protocol=${elasticsearch.protocol:http} quarkus.hibernate-search-orm.schema-management.strategy=drop-and-create-and-drop quarkus.hibernate-search-orm.indexing.plan.synchronization.strategy=sync + + +quarkus.hibernate-orm."books".datasource= +quarkus.hibernate-orm."books".database.generation=none +quarkus.hibernate-orm."books".multitenant=schema +quarkus.hibernate-orm."books".packages=io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.book + +quarkus.hibernate-search-orm."books".elasticsearch.version=8 +quarkus.hibernate-search-orm."books".elasticsearch.hosts=${elasticsearch.hosts:localhost:9200} +quarkus.hibernate-search-orm."books".elasticsearch.protocol=${elasticsearch.protocol:http} +quarkus.hibernate-search-orm."books".schema-management.strategy=drop-and-create-and-drop +quarkus.hibernate-search-orm."books".indexing.plan.synchronization.strategy=sync \ No newline at end of file diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/db/migration/V1.0.0__init.sql b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/db/migration/V1.0.0__init.sql index 1d597c6d5b4e16..48eb1ed39149c5 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/db/migration/V1.0.0__init.sql +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/main/resources/db/migration/V1.0.0__init.sql @@ -1,3 +1,9 @@ +DROP SCHEMA IF EXISTS "base" CASCADE; +DROP SCHEMA IF EXISTS "company1" CASCADE; +DROP SCHEMA IF EXISTS "company2" CASCADE; +DROP SCHEMA IF EXISTS "company3" CASCADE; +DROP SCHEMA IF EXISTS "company4" CASCADE; + CREATE SCHEMA "base"; CREATE TABLE "base".known_fruits ( @@ -21,3 +27,28 @@ CREATE TABLE "company2".known_fruits name VARCHAR(40) ); CREATE SEQUENCE "company2".known_fruits_id_seq START WITH 1; + +-- Books tables: + +CREATE TABLE "base".books +( + id INT, + name VARCHAR(40) +); +CREATE SEQUENCE "base".books_id_seq START WITH 1; + +CREATE SCHEMA "company3"; +CREATE TABLE "company3".books +( + id INT, + name VARCHAR(40) +); +CREATE SEQUENCE "company3".books_id_seq START WITH 1; + +CREATE SCHEMA "company4"; +CREATE TABLE "company4".books +( + id INT, + name VARCHAR(40) +); +CREATE SEQUENCE "company4".books_id_seq START WITH 1; diff --git a/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/HibernateSearchTenancyReindexFunctionalityTest.java b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/HibernateSearchTenancyReindexFunctionalityTest.java new file mode 100644 index 00000000000000..6f085137d0c22e --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch-tenancy/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/multitenancy/book/HibernateSearchTenancyReindexFunctionalityTest.java @@ -0,0 +1,82 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.multitenancy.book; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.core.Response.Status; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.vertx.core.http.HttpHeaders; + +@QuarkusTest +@TestProfile(HibernateSearchTenancyReindexFunctionalityTest.Profile.class) +class HibernateSearchTenancyReindexFunctionalityTest { + public static final TypeRef> BOOK_LIST_TYPE_REF = new TypeRef<>() { + }; + + public static class Profile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("quarkus.management.enabled", "true", + "quarkus.hibernate-search-orm.management.enabled", "true"); + } + } + + @Test + void test() { + String tenant1Id = "company3"; + String tenant2Id = "company4"; + String bookName = "myBook"; + + Book book1 = new Book(bookName); + create(tenant1Id, book1); + assertThat(search(tenant1Id, bookName)).isEmpty(); + Book book2 = new Book(bookName); + create(tenant2Id, book2); + assertThat(search(tenant2Id, bookName)).isEmpty(); + + RestAssured.given() + .queryParam("wait_for", "finished") + .queryParam("persistence_unit", "books") + .header(HttpHeaders.CONTENT_TYPE.toString(), "application/json") + .body("{\"filter\": {\"tenants\": [\"" + tenant1Id + "\"], \"types\": [\"" + Book.class.getName() + "\"]}}") + .post("http://localhost:9001/q/hibernate-search/reindex") + .then().statusCode(200) + .body(Matchers.stringContainsInOrder("Reindex started", "Reindex succeeded")); + assertThat(search(tenant1Id, bookName)).hasSize(1); + assertThat(search(tenant2Id, bookName)).isEmpty(); + } + + private void create(String tenantId, Book book) { + BookTenantResolver.TENANT_ID.set(tenantId); + given().with().body(book).contentType(ContentType.JSON) + .when().post("/books") + .then() + .statusCode(is(Status.CREATED.getStatusCode())); + } + + private List search(String tenantId, String terms) { + BookTenantResolver.TENANT_ID.set(tenantId); + + Response response = given() + .when().get("/books/search?terms={terms}", terms); + if (response.getStatusCode() == Status.OK.getStatusCode()) { + return response.as(BOOK_LIST_TYPE_REF); + } + return List.of(); + } + +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTestResource.java b/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTestResource.java new file mode 100644 index 00000000000000..131cd4f8ae09a3 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTestResource.java @@ -0,0 +1,45 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.management; + +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.hibernate.Session; +import org.hibernate.search.mapper.orm.session.SearchSession; + +@Path("/test/management") +public class HibernateSearchManagementTestResource { + + @Inject + Session session; + + @Inject + SearchSession searchSession; + + @PUT + @Path("/init-data") + @Transactional + public void initData() { + searchSession.indexingPlanFilter(context -> context.exclude(Object.class)); + session.persist(new ManagementTestEntity("name1")); + session.persist(new ManagementTestEntity("name2")); + session.persist(new ManagementTestEntity("name3")); + session.persist(new ManagementTestEntity("name4")); + session.persist(new ManagementTestEntity("name5")); + } + + @GET + @Path("/search-count") + @Produces(MediaType.TEXT_PLAIN) + @Transactional + public long testAnalysisConfigured() { + return searchSession.search(ManagementTestEntity.class) + .select(f -> f.id()) + .where(f -> f.matchAll()) + .fetchTotalHitCount(); + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/ManagementTestEntity.java b/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/ManagementTestEntity.java new file mode 100644 index 00000000000000..9bc64a708e58ed --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/main/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/ManagementTestEntity.java @@ -0,0 +1,44 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.management; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; + +@Entity +@Indexed +public class ManagementTestEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "managementSeq") + private Long id; + + @FullTextField + private String name; + + public ManagementTestEntity() { + } + + public ManagementTestEntity(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/main/resources/application.properties b/integration-tests/hibernate-search-orm-elasticsearch/src/main/resources/application.properties index 17baefd55b1cc4..300811857879b5 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch/src/main/resources/application.properties +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/main/resources/application.properties @@ -21,4 +21,9 @@ quarkus.hibernate-search-orm.indexing.plan.synchronization.strategy=sync # See io.quarkus.it.hibernate.search.orm.elasticsearch.devservices.HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.testHibernateSearch %test.quarkus.hibernate-search-orm.schema-management.strategy=drop-and-create %test.quarkus.hibernate-search-orm.elasticsearch.hosts=${elasticsearch.hosts:localhost:9200} -%test.quarkus.hibernate-search-orm.elasticsearch.protocol=${elasticsearch.protocol:http} \ No newline at end of file +%test.quarkus.hibernate-search-orm.elasticsearch.protocol=${elasticsearch.protocol:http} + +# we want to enable management so that we can access Hibernate Search management endpoints: +quarkus.management.enabled=true +# now enable the Hibernate Search management itself: +quarkus.hibernate-search-orm.management.enabled=true diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementCustomUrlTest.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementCustomUrlTest.java new file mode 100644 index 00000000000000..3b3ab1efa557f1 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementCustomUrlTest.java @@ -0,0 +1,34 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.management; + +import java.util.Map; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import io.vertx.core.http.HttpHeaders; + +@QuarkusTest +@TestProfile(HibernateSearchManagementCustomUrlTest.Profile.class) +class HibernateSearchManagementCustomUrlTest { + + public static class Profile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("quarkus.hibernate-search-orm.management.root-path", "custom-reindex"); + } + } + + @Test + void simple() { + RestAssured.given() + .queryParam("wait_for", "finished") + .header(HttpHeaders.CONTENT_TYPE.toString(), "application/json") + .post("http://localhost:9001/q/custom-reindex/reindex") + .then().statusCode(200) + .body(Matchers.stringContainsInOrder("Reindexing started", "Reindexing succeeded")); + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementIT.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementIT.java new file mode 100644 index 00000000000000..d8d1dfbff19a32 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementIT.java @@ -0,0 +1,11 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.management; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class HibernateSearchManagementIT extends HibernateSearchManagementTest { + @Override + protected String getPrefix() { + return "http://localhost:9000"; // ITs run in prod mode. + } +} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTest.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTest.java new file mode 100644 index 00000000000000..0f6206bdd57bb3 --- /dev/null +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/management/HibernateSearchManagementTest.java @@ -0,0 +1,51 @@ +package io.quarkus.it.hibernate.search.orm.elasticsearch.management; + +import static org.hamcrest.Matchers.is; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.vertx.core.http.HttpHeaders; + +@QuarkusTest +class HibernateSearchManagementTest { + + protected String getPrefix() { + return "http://localhost:9001"; + } + + @Test + void simple() { + RestAssured.given() + .queryParam("wait_for", "finished") + .header(HttpHeaders.CONTENT_TYPE.toString(), "application/json") + .post(getPrefix() + "/q/hibernate-search/reindex") + .then().statusCode(200) + .body(Matchers.stringContainsInOrder("Reindexing started", "Reindexing succeeded")); + } + + @Test + void specificTypeOnly() { + RestAssured.when().put("/test/management/init-data").then() + .statusCode(204); + + RestAssured.get("/test/management/search-count") + .then().statusCode(200) + .body(is("0")); + + RestAssured.given() + .queryParam("wait_for", "finished") + .header(HttpHeaders.CONTENT_TYPE.toString(), "application/json") + .body("{\"filter\": {\"types\": [\"" + ManagementTestEntity.class.getName() + "\"]}}") + .post(getPrefix() + "/q/hibernate-search/reindex") + .then().statusCode(200) + .body(Matchers.stringContainsInOrder("Reindexing started", "Reindexing succeeded")); + + RestAssured.get("/test/management/search-count") + .then().statusCode(200) + .body(is("5")); + } + +}