index-opensearch: add OpenSearch indexing backend libModule This is based on the existing Gerrit module index-elasticsearch. The code is copied and adapted for the index-opensearch module. Change-Id: I81bc5848e1bdffaf4c7b312696e30974baca104b
diff --git a/BUILD b/BUILD new file mode 100644 index 0000000..59aa918 --- /dev/null +++ b/BUILD
@@ -0,0 +1,122 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//tools/bzl:junit.bzl", "junit_tests") +load( + "//tools/bzl:plugin.bzl", + "PLUGIN_DEPS", + "PLUGIN_TEST_DEPS", + "gerrit_plugin", +) + +gerrit_plugin( + name = "index-opensearch", + srcs = glob(["src/main/java/**/*.java"]), + deps = [ + "//java/com/google/gerrit/entities/converter:converters", + "//java/com/google/gerrit/entities/converter:proto_converter", + "@opensearch-rest-client//jar", + "@httpclient5//jar", + "@httpcore5//jar", + "@httpcore5-h2//jar", + "@httpcore5-reactive//jar", + "@reactive-streams//jar", + "@reactor-core//jar", + "@jackson-core//jar", + ], +) + +OPENSEARCH_DEPS = [ + "@docker-java-api//jar", + "@docker-java-transport//jar", + "@docker-java-transport-zerodep//jar", + "@duct-tape//jar", + "@httpclient5//jar", + "@httpcore5//jar", + "@jackson-annotations//jar", + "@jna//jar", + "@testcontainers//jar", + "@opensearch-testcontainers//jar", +] + +java_library( + name = "index-opensearch__plugin_test_deps", + testonly = True, + srcs = [], + visibility = ["//visibility:public"], + exports = OPENSEARCH_DEPS, +) + +java_library( + name = "opensearch_test_utils", + testonly = True, + srcs = glob( + ["src/test/java/**/*.java"], + exclude = ["src/test/java/**/*Test.java"], + ), + visibility = ["//visibility:public"], + deps = OPENSEARCH_DEPS + PLUGIN_DEPS + PLUGIN_TEST_DEPS + [ + ":index-opensearch__plugin", + ], +) + +QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" + +TYPES = [ + "account", + "change", + "group", + "project", +] + +SUFFIX = "sTest.java" + +# Compiles the abstract base classes (e.g. AbstractOpenQueryChangesTest) +# into a library so the concrete version-specific test targets can depend on them. +ABSTRACT_OPENSEARCH_TESTS = {i: "OpenSearchAbstractQuery*" + i.capitalize() + SUFFIX for i in TYPES} + +[java_library( + name = "abstract_open_query_%ss_test" % name, + testonly = True, + srcs = glob(["src/test/java/com/google/gerrit/opensearch/" + src]), + visibility = ["//visibility:public"], + deps = OPENSEARCH_DEPS + PLUGIN_TEST_DEPS + [ + QUERY_TESTS_DEP % name, + ":opensearch_test_utils", + ":index-opensearch__plugin", + ], +) for name, src in ABSTRACT_OPENSEARCH_TESTS.items()] + +# One dict per supported OpenSearch major version. +OPENSEARCH_TESTS_V3 = {i: "OpenSearchV3Query" + i.capitalize() + SUFFIX for i in TYPES} + +[junit_tests( + name = "open_query_%ss_test_V3" % name, + size = "enormous", + srcs = ["src/test/java/com/google/gerrit/opensearch/" + src], + tags = [ + "docker", + "exclusive", + "opensearch", + "opensearch_V3", + ], + deps = OPENSEARCH_DEPS + PLUGIN_TEST_DEPS + [ + QUERY_TESTS_DEP % name, + ":opensearch_test_utils", + ":index-opensearch__plugin", + ":abstract_open_query_%ss_test" % name, + ], +) for name, src in OPENSEARCH_TESTS_V3.items()] + +# Small unit tests (version/config/etc.) — excludes the large query integration tests. +junit_tests( + name = "index-opensearch_tests", + size = "small", + srcs = glob( + ["src/test/java/**/*Test.java"], + exclude = ["src/test/java/**/Open*Query*" + SUFFIX], + ), + tags = ["opensearch"], + deps = OPENSEARCH_DEPS + PLUGIN_TEST_DEPS + [ + ":opensearch_test_utils", + ":index-opensearch__plugin", + ], +) \ No newline at end of file
diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..78a1bef --- /dev/null +++ b/Jenkinsfile
@@ -0,0 +1,2 @@ +pluginPipeline(formatCheckId: 'gerritforge:index-opensearch-code-style', + buildCheckId: 'gerritforge:index-opensearch-build-test')
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE
@@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca3b1c1 --- /dev/null +++ b/README.md
@@ -0,0 +1,28 @@ +# Index backend for Gerrit, based on OpenSearch + +Indexing backend libModule for [Gerrit Code Review](https://gerritcodereview.com) +based on [OpenSearch](https://github.com/opensearch-project/opensearch). + +This module is based on a copy of the [index-elasticsearch module](https://gerrit.googlesource.com/modules/index-elasticsearch/+/refs/heads/master) +which was adapted to the OpenSearch APIs. OpenSearch was forked from ElasticSearch +when ElasticSearch 7.11 switched to Server Side Public License (SSPL) and the +Elastic License which aren't OSI approved Open Source licenses. Meanwhile the APIs +of ElasticSearch and OpenSearch diverged, hence the creation of this new module. + +## How to build + +This libModule is built like a Gerrit in-tree plugin, using Bazelisk. See the +[build instructions](src/main/resources/Documentation/build.md) for more details. + +## Setup + +See the [setup instructions](src/main/resources/Documentation/setup.md) for how to install the +index-opensearch module. + +For further information and supported options, refer to the [config](src/main/resources/Documentation/config.md) +documentation. + +## Integration test + +This libModule runs tests like a Gerrit in-tree plugin, using Bazelisk. See the +[test instructions](src/main/resources/Documentation/build.md#Integration-test) for more details.
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl new file mode 100644 index 0000000..eaf9212 --- /dev/null +++ b/external_plugin_deps.bzl
@@ -0,0 +1,110 @@ +load("//tools/bzl:maven_jar.bzl", "maven_jar") + +def external_plugin_deps(): + maven_jar( + name = "jackson-core", + artifact = "com.fasterxml.jackson.core:jackson-core:2.21.1", + sha1 = "47b013fc85dbb819f3ba51e95a5560d0f1c4121c", + ) + + TESTCONTAINERS_VERSION = "2.0.3" + + maven_jar( + name = "testcontainers", + artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION, + sha1 = "457576cfd348a4c564569a7e9fc7a3b53f476454", + ) + + maven_jar( + name = "opensearch-testcontainers", + artifact = "org.opensearch:opensearch-testcontainers:4.1.0", + sha1 = "6b83b1993af702160a4575d6e4c46fed0711d8aa", + ) + + maven_jar( + name = "duct-tape", + artifact = "org.rnorth.duct-tape:duct-tape:1.0.8", + sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530", + ) + + DOCKER_JAVA_VERS = "3.7.1" + + maven_jar( + name = "docker-java-api", + artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS, + sha1 = "2df91233a782749e85139991cc94d4ef40b59038", + ) + + maven_jar( + name = "docker-java-transport", + artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS, + sha1 = "7b8f1b7486e83f2871d9f98a7e12c529dfddc9c2", + ) + + maven_jar( + name = "docker-java-transport-zerodep", + artifact = "com.github.docker-java:docker-java-transport-zerodep:" + DOCKER_JAVA_VERS, + sha1 = "6b1af9996b2004703a41e0fbbf1031cabbbe4e6b", + ) + + # Match version used in docker-java-transport + # https://search.maven.org/artifact/com.github.docker-java/docker-java-transport/3.7.1/pom + maven_jar( + name = "jna", + artifact = "net.java.dev.jna:jna:5.18.1", + sha1 = "b27ba04287cc4abe769642fe8318d39fc89bf937", + ) + + maven_jar( + name = "jackson-annotations", + artifact = "com.fasterxml.jackson.core:jackson-annotations:2.21", + sha1 = "b1bc1868bf02dc0bd6c7836257a036a331005309", + ) + + maven_jar( + name = "opensearch-rest-client", + artifact = "org.opensearch.client:opensearch-rest-client:3.5.0", + sha1 = "ce20c63ec9ab0e40ca59a452d3bd2e84348410ad", + ) + + maven_jar( + name = "opensearch-java", + artifact = "org.opensearch.client:opensearch-java:3.7.0", + sha1 = "b341f9a29371814e4fb23dd99797b6d47d5758f5", + ) + + maven_jar( + name = "httpclient5", + artifact = "org.apache.httpcomponents.client5:httpclient5:5.6", + sha1 = "f502ee00ba82d44a6a29bda06a18f5b959808e09", + ) + + maven_jar( + name = "httpcore5", + artifact = "org.apache.httpcomponents.core5:httpcore5:5.4.2", + sha1 = "346d5f65ff819510666541ba94b3abf747454916", + ) + + maven_jar( + name = "reactive-streams", + artifact = "org.reactivestreams:reactive-streams:1.0.4", + sha1 = "3864a1320d97d7b045f729a326e1e077661f31b7", + ) + + maven_jar( + name = "reactor-core", + artifact = "io.projectreactor:reactor-core:3.8.4", + sha1 = "7cd47ac9899628366bc8c65bd91dcbcb4dcdef2a", + ) + + maven_jar( + name = "httpcore5-h2", + artifact = "org.apache.httpcomponents.core5:httpcore5-h2:5.4.2", + sha1 = "a9d9144455975111c62c9781d18ff0ae199a0dd2", + ) + + maven_jar( + name = "httpcore5-reactive", + artifact = "org.apache.httpcomponents.core5:httpcore5-reactive:5.4.2", + sha1 = "f254d96e83d520f4f1ff9b0d05d5a79d8db2080f", + ) \ No newline at end of file
diff --git a/src/main/java/com/google/gerrit/opensearch/AbstractOpenSearchIndex.java b/src/main/java/com/google/gerrit/opensearch/AbstractOpenSearchIndex.java new file mode 100644 index 0000000..29aff43 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/AbstractOpenSearchIndex.java
@@ -0,0 +1,489 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Streams; +import com.google.common.flogger.FluentLogger; +import com.google.common.io.BaseEncoding; +import com.google.common.io.CharStreams; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.converter.ProtoConverter; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.index.FieldType; +import com.google.gerrit.index.Index; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.FieldBundle; +import com.google.gerrit.index.query.HasCardinality; +import com.google.gerrit.index.query.ListResultSet; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.index.query.ResultSet; +import com.google.gerrit.opensearch.OpenSearchMapping.Mapping; +import com.google.gerrit.opensearch.builders.QueryBuilder; +import com.google.gerrit.opensearch.builders.SearchSourceBuilder; +import com.google.gerrit.opensearch.bulk.DeleteRequest; +import com.google.gerrit.proto.Protos; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexUtils; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.gerrit.server.logging.Metadata; +import com.google.gerrit.server.logging.TraceContext; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.protobuf.MessageLite; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.sql.Timestamp; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.StatusLine; +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +abstract class AbstractOpenSearchIndex<K, V> implements Index<K, V> { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + protected static final String BULK = "_bulk"; + protected static final String COUNT = "_count"; + protected static final String DELETE_BY_QUERY = "_delete_by_query"; + protected static final String MAPPINGS = "mappings"; + protected static final String ORDER = "order"; + protected static final String DESC_SORT_ORDER = "desc"; + protected static final String ASC_SORT_ORDER = "asc"; + protected static final String UNMAPPED_TYPE = "unmapped_type"; + protected static final String SEARCH = "_search"; + protected static final String SETTINGS = "settings"; + + static byte[] decodeBase64(String base64String) { + return BaseEncoding.base64().decode(base64String); + } + + protected static <T> List<T> decodeProtos( + JsonObject doc, String fieldName, ProtoConverter<?, T> converter) { + JsonArray field = doc.getAsJsonArray(fieldName); + if (field == null) { + return null; + } + return Streams.stream(field) + .map(JsonElement::getAsString) + .map(AbstractOpenSearchIndex::decodeBase64) + .map(bytes -> parseProtoFrom(bytes, converter)) + .collect(toImmutableList()); + } + + protected static <P extends MessageLite, T> T parseProtoFrom( + byte[] bytes, ProtoConverter<P, T> converter) { + P message = Protos.parseUnchecked(converter.getParser(), bytes); + return converter.fromProto(message); + } + + static String getContent(Response response) throws IOException { + HttpEntity responseEntity = response.getEntity(); + String content = ""; + if (responseEntity != null) { + InputStream contentStream = responseEntity.getContent(); + try (Reader reader = new InputStreamReader(contentStream, UTF_8)) { + content = CharStreams.toString(reader); + } + } + return content; + } + + private final OpenSearchConfiguration config; + private final Schema<V> schema; + private final SitePaths sitePaths; + private final String indexNameRaw; + private final Map<String, String> refreshParam; + + protected final RestClientProvider client; + protected final String indexName; + protected final Gson gson; + protected final OpenSearchQueryBuilder queryBuilder; + private final Function<V, K> valueToKeyFunction; + + AbstractOpenSearchIndex( + OpenSearchConfiguration config, + SitePaths sitePaths, + Schema<V> schema, + RestClientProvider client, + String indexName, + AutoFlush autoFlush, + Function<V, K> valueToKeyFunction) { + this.config = config; + this.sitePaths = sitePaths; + this.schema = schema; + this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create(); + this.queryBuilder = new OpenSearchQueryBuilder(); + this.indexName = config.getIndexName(indexName, schema.getVersion()); + this.indexNameRaw = indexName; + this.client = client; + this.refreshParam = + Map.of( + "refresh", + autoFlush == AutoFlush.ENABLED ? Boolean.TRUE.toString() : Boolean.FALSE.toString()); + this.valueToKeyFunction = valueToKeyFunction; + } + + @Override + public void deleteByValue(V value) { + delete(valueToKeyFunction.apply(value)); + } + + @Override + public void insert(V obj) { + replace(obj); + } + + @Override + public Schema<V> getSchema() { + return schema; + } + + @Override + public void close() { + // Do nothing. Client is closed by the provider. + } + + @Override + public void markReady(boolean ready) { + IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready); + } + + @Override + public int numDocs() { + String uri = getURI(COUNT); + Response response = performRequest("GET", uri); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format( + "Request to get number of %s index documents failed: %s", + indexName, response.getStatusLine().getReasonPhrase())); + } + String content; + try { + content = getContent(response); + return JsonParser.parseString(content).getAsJsonObject().get("count").getAsInt(); + } catch (IOException e) { + throw new StorageException( + String.format("Request to get number of %s index documents failed", indexName), e); + } + } + + @Override + public void delete(K id) { + String uri = getURI(BULK); + Response response = postRequestWithRefreshParam(uri, getDeleteActions(id)); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode)); + } + } + + @Override + public void deleteAll() { + // Delete the index, if it exists. + String endpoint = indexName + client.adapter().indicesExistParams(); + Response response = performRequest("HEAD", endpoint); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == HttpStatus.SC_OK) { + response = performRequest("DELETE", indexName); + statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format("Failed to delete index %s: %s", indexName, statusCode)); + } + } + + // Recreate the index. + String indexCreationFields = concatJsonString(getSettings(), getMappings()); + response = performRequest("PUT", indexName, indexCreationFields); + statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + String error = String.format("Failed to create index %s: %s", indexName, statusCode); + throw new StorageException(error); + } + } + + protected abstract String getDeleteActions(K id); + + protected abstract String getMappings(); + + private String getSettings() { + return gson.toJson(ImmutableMap.of(SETTINGS, Setting.createSetting(config))); + } + + protected abstract String getId(V v); + + protected String getMappingsForSingleType(Mapping mapping) { + return getMappingsFor(mapping); + } + + protected String getMappingsFor(Mapping mapping) { + JsonObject mappings = new JsonObject(); + + mappings.add(MAPPINGS, gson.toJsonTree(mapping)); + return gson.toJson(mappings); + } + + protected String getDeleteRequest(K id) { + return new DeleteRequest(id.toString(), indexName).toString(); + } + + protected abstract V fromDocument(JsonObject doc, Set<String> fields); + + protected FieldBundle toFieldBundle(JsonObject doc) { + ListMultimap<String, Object> rawFields = ArrayListMultimap.create(); + for (Map.Entry<String, JsonElement> element : + doc.get(client.adapter().rawFieldsKey()).getAsJsonObject().entrySet()) { + checkArgument( + getSchema().hasField(element.getKey()), "Unrecognized field " + element.getKey()); + FieldType<?> type = getSchema().getSchemaField(element.getKey()).getType(); + Iterable<JsonElement> innerItems = + element.getValue().isJsonArray() + ? element.getValue().getAsJsonArray() + : Collections.singleton(element.getValue()); + for (JsonElement inner : innerItems) { + if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) { + rawFields.put(element.getKey(), inner.getAsString()); + } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) { + rawFields.put(element.getKey(), inner.getAsInt()); + } else if (type == FieldType.LONG) { + rawFields.put(element.getKey(), inner.getAsLong()); + } else if (type == FieldType.TIMESTAMP) { + rawFields.put(element.getKey(), new Timestamp(inner.getAsLong())); + } else if (type == FieldType.STORED_ONLY) { + rawFields.put(element.getKey(), decodeBase64(inner.getAsString())); + } else { + throw FieldType.badFieldType(type); + } + } + } + return new FieldBundle(rawFields, /* storesIndexedFields= */ false); + } + + protected boolean hasErrors(Response response) { + try { + HttpEntity entity = response.getEntity(); + ContentType contentType = ContentType.parse(entity.getContentType()); + Preconditions.checkState( + contentType.getMimeType().equalsIgnoreCase(ContentType.APPLICATION_JSON.getMimeType()), + String.format("Expected %s, but was: %s", ContentType.APPLICATION_JSON, contentType)); + String responseStr = EntityUtils.toString(entity); + JsonObject responseJson = JsonParser.parseString(responseStr).getAsJsonObject(); + boolean hasErrors = responseJson.get("errors").getAsBoolean(); + if (hasErrors) { + logger.atSevere().log("Response with errors: %s", responseJson); + } + return hasErrors; + } catch (IOException | ParseException e) { + throw new StorageException(e); + } + } + + protected String toAction(String id, String action) { + JsonObject properties = new JsonObject(); + properties.addProperty("_id", id); + properties.addProperty("_index", indexName); + + JsonObject jsonAction = new JsonObject(); + jsonAction.add(action, properties); + return jsonAction.toString() + System.lineSeparator(); + } + + protected void addNamedElement(String name, JsonObject element, JsonArray array) { + JsonObject arrayElement = new JsonObject(); + arrayElement.add(name, element); + array.add(arrayElement); + } + + protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) { + JsonObject search = JsonParser.parseString(searchSource.toString()).getAsJsonObject(); + search.add("sort", sortArray); + return gson.toJson(search); + } + + protected JsonArray getSortArray(String idFieldName) { + JsonObject properties = new JsonObject(); + properties.addProperty(ORDER, ASC_SORT_ORDER); + + JsonArray sortArray = new JsonArray(); + addNamedElement(idFieldName, properties, sortArray); + return sortArray; + } + + protected String getURI(String request) { + try { + return URLEncoder.encode(indexName, UTF_8.toString()) + "/" + request; + } catch (UnsupportedEncodingException e) { + throw new StorageException(e); + } + } + + protected Response postRequestWithRefreshParam(String uri, Object payload) { + return performRequest("POST", uri, payload, refreshParam); + } + + private String concatJsonString(String target, String addition) { + return target.substring(0, target.length() - 1) + "," + addition.substring(1); + } + + private Response performRequest(String method, String uri) { + return performRequest(method, uri, null); + } + + private Response performRequest(String method, String uri, @Nullable Object payload) { + return performRequest(method, uri, payload, Collections.emptyMap()); + } + + private Response performRequest( + String method, String uri, @Nullable Object payload, Map<String, String> params) { + Request request = new Request(method, uri.startsWith("/") ? uri : "/" + uri); + if (payload != null) { + String payloadStr = payload instanceof String ? (String) payload : payload.toString(); + request.setEntity(new StringEntity(payloadStr, ContentType.APPLICATION_JSON)); + } + for (Map.Entry<String, String> entry : params.entrySet()) { + request.addParameter(entry.getKey(), entry.getValue()); + } + try (TraceContext.TraceTimer traceTimer = + TraceContext.newTimer( + "OpenSearch perform request", + Metadata.builder() + .indexName(indexName) + .operationName( + String.format( + "method:%s uri:%s payload:%s params:%s", method, uri, payload, params)) + .build())) { + return client.get().performRequest(request); + } catch (IOException e) { + throw new StorageException(e); + } + } + + protected class OpenQuerySource implements DataSource<V> { + private final QueryOptions opts; + private final Predicate<V> predicate; + private final String search; + + OpenQuerySource(Predicate<V> p, QueryOptions opts, JsonArray sortArray) + throws QueryParseException { + this.opts = opts; + this.predicate = p; + QueryBuilder qb = queryBuilder.toQueryBuilder(p); + SearchSourceBuilder searchSource = + new SearchSourceBuilder(client.adapter()) + .query(qb) + .size(opts.pageSize()) + .fields(Lists.newArrayList(opts.fields())) + .trackTotalHits(false); + searchSource = + opts.searchAfter() != null + ? searchSource.searchAfter((JsonArray) opts.searchAfter()) + : searchSource.from(opts.start()); + search = getSearch(searchSource, sortArray); + } + + @Override + public int getCardinality() { + if (predicate instanceof HasCardinality) { + return ((HasCardinality) predicate).getCardinality(); + } + return 10; + } + + @Override + public ResultSet<V> read() { + return readImpl(doc -> AbstractOpenSearchIndex.this.fromDocument(doc, opts.fields())); + } + + @Override + public ResultSet<FieldBundle> readRaw() { + return readImpl(AbstractOpenSearchIndex.this::toFieldBundle); + } + + private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) { + try { + String uri = getURI(SEARCH); + JsonArray searchAfter = null; + Response response = performRequest("POST", uri, search, Collections.emptyMap()); + StatusLine statusLine = response.getStatusLine(); + if (statusLine.getStatusCode() == HttpStatus.SC_OK) { + String content = getContent(response); + JsonObject obj = + JsonParser.parseString(content).getAsJsonObject().getAsJsonObject("hits"); + if (obj.get("hits") != null) { + JsonArray json = obj.getAsJsonArray("hits"); + ImmutableList.Builder<T> results = ImmutableList.builderWithExpectedSize(json.size()); + JsonObject hit = null; + for (int i = 0; i < json.size(); i++) { + hit = json.get(i).getAsJsonObject(); + T mapperResult = mapper.apply(hit); + if (mapperResult != null) { + results.add(mapperResult); + } + } + if (hit != null && hit.get("sort") != null) { + searchAfter = hit.getAsJsonArray("sort"); + } + JsonArray finalSearchAfter = searchAfter; + return new ListResultSet<>(results.build()) { + @Override + public Object searchAfter() { + return finalSearchAfter; + } + }; + } + } else { + logger.atSevere().log("%s", statusLine.getReasonPhrase()); + } + return new ListResultSet<>(ImmutableList.of()); + } catch (IOException e) { + throw new StorageException(e); + } + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/IndexVersionDiscovery.java b/src/main/java/com/google/gerrit/opensearch/IndexVersionDiscovery.java new file mode 100644 index 0000000..2c544de --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/IndexVersionDiscovery.java
@@ -0,0 +1,63 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static java.util.stream.Collectors.toList; + +import com.google.common.flogger.FluentLogger; +import com.google.gson.JsonParser; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.util.List; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.message.StatusLine; +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +@Singleton +class IndexVersionDiscovery { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final RestClientProvider client; + + @Inject + IndexVersionDiscovery(RestClientProvider client) { + this.client = client; + } + + List<String> discover(String prefix, String indexName) throws IOException { + String name = prefix + indexName + "_"; + Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name)); + Response response = client.get().performRequest(request); + + StatusLine statusLine = response.getStatusLine(); + if (statusLine.getStatusCode() != HttpStatus.SC_OK) { + String message = + String.format( + "Failed to discover index versions for %s: %d: %s", + name, statusLine.getStatusCode(), statusLine.getReasonPhrase()); + logger.atSevere().log("%s", message); + throw new IOException(message); + } + + return JsonParser.parseString(AbstractOpenSearchIndex.getContent(response)) + .getAsJsonObject() + .entrySet() + .stream() + .map(e -> e.getKey().replace(name, "")) + .collect(toList()); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/IndexVersionManager.java b/src/main/java/com/google/gerrit/opensearch/IndexVersionManager.java new file mode 100644 index 0000000..a57b960 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/IndexVersionManager.java
@@ -0,0 +1,87 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.flogger.FluentLogger; +import com.google.common.primitives.Ints; +import com.google.gerrit.index.Index; +import com.google.gerrit.index.IndexDefinition; +import com.google.gerrit.index.Schema; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.GerritIndexStatus; +import com.google.gerrit.server.index.OnlineUpgradeListener; +import com.google.gerrit.server.index.VersionManager; +import com.google.gerrit.server.plugincontext.PluginSetContext; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.TreeMap; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class IndexVersionManager extends VersionManager { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final String prefix; + private final IndexVersionDiscovery versionDiscovery; + + @Inject + IndexVersionManager( + @GerritServerConfig Config cfg, + OpenSearchConfiguration openCfg, + SitePaths sitePaths, + PluginSetContext<OnlineUpgradeListener> listeners, + Collection<IndexDefinition<?, ?, ?>> defs, + IndexVersionDiscovery versionDiscovery) { + super( + sitePaths, + listeners, + defs, + VersionManager.shouldPerformOnlineUpgrade(cfg), + cfg.getBoolean("index", "reuseExistingDocuments", false)); + this.versionDiscovery = versionDiscovery; + prefix = openCfg.prefix; + } + + @Override + protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions( + IndexDefinition<K, V, I> def, GerritIndexStatus cfg) { + TreeMap<Integer, Version<V>> versions = new TreeMap<>(); + try { + List<String> discovered = versionDiscovery.discover(prefix, def.getName()); + logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered); + for (String version : discovered) { + Integer v = Ints.tryParse(version); + if (v == null || version.length() != 4) { + logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version); + continue; + } + versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v))); + } + } catch (IOException e) { + logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName()); + } + + for (Schema<V> schema : def.getSchemas().values()) { + int v = schema.getVersion(); + boolean exists = versions.containsKey(v); + versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v))); + } + return versions; + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchAccountIndex.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchAccountIndex.java new file mode 100644 index 0000000..93582ea --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchAccountIndex.java
@@ -0,0 +1,144 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.collect.ImmutableSet; +import com.google.gerrit.entities.Account; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.opensearch.OpenSearchMapping.Mapping; +import com.google.gerrit.opensearch.bulk.BulkRequest; +import com.google.gerrit.opensearch.bulk.IndexRequest; +import com.google.gerrit.opensearch.bulk.UpdateRequest; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.AccountState; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexUtils; +import com.google.gerrit.server.index.account.AccountField; +import com.google.gerrit.server.index.account.AccountIndex; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.assistedinject.Assisted; +import java.util.Set; +import org.apache.hc.core5.http.HttpStatus; +import org.opensearch.client.Response; + +public class OpenSearchAccountIndex extends AbstractOpenSearchIndex<Account.Id, AccountState> + implements AccountIndex { + static class AccountMapping { + final Mapping accounts; + + AccountMapping(Schema<AccountState> schema, QueryAdapter adapter) { + this.accounts = OpenSearchMapping.createMapping(schema, adapter); + } + } + + private static final String ACCOUNTS = "accounts"; + + private final AccountMapping mapping; + private final Provider<AccountCache> accountCache; + private final Schema<AccountState> schema; + + @Inject + OpenSearchAccountIndex( + OpenSearchConfiguration cfg, + SitePaths sitePaths, + Provider<AccountCache> accountCache, + RestClientProvider client, + AutoFlush autoFlush, + @Assisted Schema<AccountState> schema) { + super(cfg, sitePaths, schema, client, ACCOUNTS, autoFlush, AccountIndex.ENTITY_TO_KEY); + this.accountCache = accountCache; + this.mapping = new AccountMapping(schema, client.adapter()); + this.schema = schema; + } + + @Override + public void replace(AccountState as) { + BulkRequest bulk = + new IndexRequest(getId(as), indexName) + .add(new UpdateRequest<>(schema, as, ImmutableSet.of())); + + String uri = getURI(BULK); + Response response = postRequestWithRefreshParam(uri, bulk); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK || hasErrors(response)) { + throw new StorageException( + String.format( + "Failed to replace account %s in index %s: %s", + as.account().id(), indexName, statusCode)); + } + } + + @Override + public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts) + throws QueryParseException { + boolean useLegacyNumericFields = schema.hasField(AccountField.ID_FIELD_SPEC); + JsonArray sortArray = + getSortArray( + useLegacyNumericFields + ? AccountField.ID_FIELD_SPEC.getName() + : AccountField.ID_STR_FIELD_SPEC.getName()); + return new OpenQuerySource( + p, opts.filterFields(o -> IndexUtils.accountFields(o, useLegacyNumericFields)), sortArray); + } + + @Override + protected String getDeleteActions(Account.Id a) { + return getDeleteRequest(a); + } + + @Override + protected String getMappings() { + return getMappingsForSingleType(mapping.accounts); + } + + @Override + protected String getId(AccountState as) { + return as.account().id().toString(); + } + + @Override + protected AccountState fromDocument(JsonObject json, Set<String> fields) { + JsonElement source = json.get("_source"); + if (source == null) { + source = json.getAsJsonObject().get("fields"); + } + + Account.Id id = + Account.id( + source + .getAsJsonObject() + .get( + schema.hasField(AccountField.ID_FIELD_SPEC) + ? AccountField.ID_FIELD_SPEC.getName() + : AccountField.ID_STR_FIELD_SPEC.getName()) + .getAsInt()); + // Use the AccountCache rather than depending on any stored fields in the document (of which + // there shouldn't be any). The most expensive part to compute anyway is the effective group + // IDs, and we don't have a good way to reindex when those change. + // If the account doesn't exist return an empty AccountState to represent the missing account + // to account the fact that the account exists in the index. + return accountCache.get().getEvenIfMissing(id); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchChangeIndex.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchChangeIndex.java new file mode 100644 index 0000000..dc0a993 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchChangeIndex.java
@@ -0,0 +1,213 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static java.util.Objects.requireNonNull; + +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.entities.converter.ChangeProtoConverter; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.SchemaFieldDefs.SchemaField; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.opensearch.OpenSearchMapping.Mapping; +import com.google.gerrit.opensearch.builders.QueryBuilder; +import com.google.gerrit.opensearch.builders.SearchSourceBuilder; +import com.google.gerrit.opensearch.bulk.BulkRequest; +import com.google.gerrit.opensearch.bulk.IndexRequest; +import com.google.gerrit.opensearch.bulk.UpdateRequest; +import com.google.gerrit.server.change.MergeabilityComputationBehavior; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexUtils; +import com.google.gerrit.server.index.change.ChangeField; +import com.google.gerrit.server.index.change.ChangeIndex; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.ChangePredicates; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import java.util.Set; +import org.apache.hc.core5.http.HttpStatus; +import org.eclipse.jgit.lib.Config; +import org.opensearch.client.Response; + +/** Secondary index implementation using OpenSearch. */ +class OpenSearchChangeIndex extends AbstractOpenSearchIndex<Change.Id, ChangeData> + implements ChangeIndex { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + static class ChangeMapping { + final Mapping changes; + + ChangeMapping(Schema<ChangeData> schema, QueryAdapter adapter) { + this.changes = OpenSearchMapping.createMapping(schema, adapter); + } + } + + private static final String CHANGES = "changes"; + + private final ChangeMapping mapping; + private final ChangeData.Factory changeDataFactory; + private final Schema<ChangeData> schema; + private final ImmutableSet<String> skipFields; + + @Inject + OpenSearchChangeIndex( + OpenSearchConfiguration cfg, + ChangeData.Factory changeDataFactory, + SitePaths sitePaths, + RestClientProvider clientBuilder, + @GerritServerConfig Config gerritConfig, + AutoFlush autoFlush, + @Assisted Schema<ChangeData> schema) { + super(cfg, sitePaths, schema, clientBuilder, CHANGES, autoFlush, ChangeIndex.ENTITY_TO_KEY); + this.changeDataFactory = changeDataFactory; + this.mapping = new ChangeMapping(schema, client.adapter()); + this.schema = schema; + this.skipFields = + MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex() + ? ImmutableSet.of() + : ImmutableSet.of(ChangeField.MERGEABLE_SPEC.getName()); + } + + @Override + public void replace(ChangeData cd) { + BulkRequest bulk = + new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields)); + + if (logger.atFine().isEnabled()) { + String metaRevision = null; + try { + metaRevision = cd.metaRevisionOrThrow().name(); + } catch (Exception ignored) { + } + logger.atFine().log( + "Indexing: change: %s, status: %s, meta revision: %s", + cd.change().currentPatchSetId(), cd.change().getStatus(), metaRevision); + } + + String uri = getURI(BULK); + Response response = postRequestWithRefreshParam(uri, bulk); + int statusCode = response.getStatusLine().getStatusCode(); + if (hasErrors(response) || statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format( + "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode)); + } + } + + @Override + public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts) + throws QueryParseException { + QueryOptions filteredOpts = opts.filterFields(o -> IndexUtils.changeFields(o)); + return new OpenQuerySource(p, filteredOpts, getSortArray()); + } + + private JsonArray getSortArray() { + JsonObject properties = new JsonObject(); + properties.addProperty(ORDER, DESC_SORT_ORDER); + + JsonArray sortArray = new JsonArray(); + addNamedElement(ChangeField.UPDATED_SPEC.getName(), properties, sortArray); + addNamedElement(ChangeField.MERGED_ON_SPEC.getName(), getMergedOnSortOptions(), sortArray); + addNamedElement(ChangeField.NUMERIC_ID_STR_SPEC.getName(), properties, sortArray); + return sortArray; + } + + private JsonObject getMergedOnSortOptions() { + JsonObject sortOptions = new JsonObject(); + sortOptions.addProperty(ORDER, DESC_SORT_ORDER); + // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open + // changes, because the corresponding documents do not have mergedOn field. + sortOptions.addProperty(UNMAPPED_TYPE, OpenSearchMapping.TIMESTAMP_FIELD_TYPE); + return sortOptions; + } + + @Override + protected String getDeleteActions(Change.Id c) { + return getDeleteRequest(c); + } + + @Override + protected String getMappings() { + return getMappingsFor(mapping.changes); + } + + @Override + protected String getId(ChangeData cd) { + return cd.getId().toString(); + } + + @Override + protected ChangeData fromDocument(JsonObject json, Set<String> fields) { + JsonElement sourceElement = json.get("_source"); + if (sourceElement == null) { + sourceElement = json.getAsJsonObject().get("fields"); + } + JsonObject source = sourceElement.getAsJsonObject(); + JsonElement c = source.get(ChangeField.CHANGE_SPEC.getName()); + + if (c == null) { + int id = source.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()).getAsInt(); + // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present. + String projectName = + requireNonNull(source.get(ChangeField.PROJECT_SPEC.getName()).getAsString()); + return changeDataFactory.create(Project.nameKey(projectName), Change.id(id)); + } + + ChangeData cd = + changeDataFactory.create( + parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE)); + + for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) { + if (fields.contains(field.getName()) && source.get(field.getName()) != null) { + field.setIfPossible(cd, new OpenSearchStoredValue(source.get(field.getName()))); + } + } + + return cd; + } + + @Override + public void deleteAllForProject(NameKey project) { + QueryBuilder qb; + try { + qb = queryBuilder.toQueryBuilder(ChangePredicates.project(project)); + } catch (QueryParseException e) { + throw new IllegalStateException("Failed to build project query.", e); + } + String payload = new SearchSourceBuilder(client.adapter()).query(qb).toString(); + String uri = getURI(DELETE_BY_QUERY); + Response response = postRequestWithRefreshParam(uri, payload); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format( + "Failed to delete changes in project %s from index %s: %s", + project, indexName, statusCode)); + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java new file mode 100644 index 0000000..1053bd3 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java
@@ -0,0 +1,149 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.base.MoreObjects.firstNonNull; + +import com.google.common.base.Strings; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.index.IndexConfig; +import com.google.gerrit.index.PaginationType; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.inject.Inject; +import com.google.inject.ProvisionException; +import com.google.inject.Singleton; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.hc.core5.http.HttpHost; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class OpenSearchConfiguration { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + static final String SECTION_OPENSEARCH = "opensearch"; + static final String KEY_PASSWORD = "password"; + static final String KEY_USERNAME = "username"; + static final String KEY_PREFIX = "prefix"; + static final String KEY_SERVER = "server"; + static final String KEY_NUMBER_OF_SHARDS = "numberOfShards"; + static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas"; + static final String KEY_MAX_RESULT_WINDOW = "maxResultWindow"; + static final String KEY_CODEC = "codec"; + static final String KEY_CONNECT_TIMEOUT = "connectTimeout"; + static final String KEY_SOCKET_TIMEOUT = "socketTimeout"; + + static final String DEFAULT_CODEC = "default"; + static final String DEFAULT_PORT = "9200"; + static final String DEFAULT_USERNAME = "admin"; + static final int DEFAULT_NUMBER_OF_SHARDS = 1; + static final int DEFAULT_NUMBER_OF_REPLICAS = 1; + static final int DEFAULT_MAX_RESULT_WINDOW = Integer.MAX_VALUE; + static final int DEFAULT_CONNECT_TIMEOUT = 1000; + static final int DEFAULT_SOCKET_TIMEOUT = 30000; + + private final Config cfg; + private final List<HttpHost> hosts; + + final String username; + final String password; + final int numberOfShards; + final int numberOfReplicas; + final int maxResultWindow; + final String codec; + final int connectTimeout; + final int socketTimeout; + final String prefix; + + @Inject + OpenSearchConfiguration(@GerritServerConfig Config cfg, IndexConfig indexConfig) { + if (PaginationType.NONE == indexConfig.paginationType()) { + throw new ProvisionException( + "The 'index.paginationType = NONE' configuration is not supported by OpenSearch"); + } + + this.cfg = cfg; + this.password = cfg.getString(SECTION_OPENSEARCH, null, KEY_PASSWORD); + this.username = + password == null + ? null + : firstNonNull(cfg.getString(SECTION_OPENSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME); + this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_OPENSEARCH, null, KEY_PREFIX)); + this.numberOfShards = + cfg.getInt(SECTION_OPENSEARCH, null, KEY_NUMBER_OF_SHARDS, DEFAULT_NUMBER_OF_SHARDS); + this.numberOfReplicas = + cfg.getInt(SECTION_OPENSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS); + this.maxResultWindow = + cfg.getInt(SECTION_OPENSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW); + this.codec = firstNonNull(cfg.getString(SECTION_OPENSEARCH, null, KEY_CODEC), DEFAULT_CODEC); + this.connectTimeout = + (int) + cfg.getTimeUnit( + SECTION_OPENSEARCH, + null, + KEY_CONNECT_TIMEOUT, + DEFAULT_CONNECT_TIMEOUT, + TimeUnit.MILLISECONDS); + this.socketTimeout = + (int) + cfg.getTimeUnit( + SECTION_OPENSEARCH, + null, + KEY_SOCKET_TIMEOUT, + DEFAULT_SOCKET_TIMEOUT, + TimeUnit.MILLISECONDS); + this.hosts = new ArrayList<>(); + for (String server : cfg.getStringList(SECTION_OPENSEARCH, null, KEY_SERVER)) { + try { + URI uri = new URI(server); + int port = uri.getPort(); + String host = uri.getHost(); + if (host == null) { + throw new IllegalArgumentException("Missing host in server URI: " + server); + } + HttpHost httpHost = + new HttpHost(uri.getScheme(), host, port == -1 ? Integer.parseInt(DEFAULT_PORT) : port); + this.hosts.add(httpHost); + } catch (URISyntaxException | IllegalArgumentException e) { + logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage()); + } + } + + if (hosts.isEmpty()) { + throw new ProvisionException("No valid OpenSearch servers configured"); + } + + logger.atInfo().log("OpenSearch servers: %s", hosts); + } + + Config getConfig() { + return cfg; + } + + HttpHost[] getHosts() { + return hosts.toArray(new HttpHost[hosts.size()]); + } + + String getIndexName(String name, int schemaVersion) { + return String.format("%s%s_%04d", prefix, name, schemaVersion); + } + + int getNumberOfShards() { + return numberOfShards; + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchException.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchException.java new file mode 100644 index 0000000..6145b95 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchException.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +class OpenSearchException extends RuntimeException { + private static final long serialVersionUID = 1L; + + OpenSearchException(String message) { + super(message); + } + + OpenSearchException(String message, Throwable cause) { + super(message, cause); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchGroupIndex.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchGroupIndex.java new file mode 100644 index 0000000..3515936 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchGroupIndex.java
@@ -0,0 +1,129 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.collect.ImmutableSet; +import com.google.gerrit.entities.AccountGroup; +import com.google.gerrit.entities.InternalGroup; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.opensearch.OpenSearchMapping.Mapping; +import com.google.gerrit.opensearch.bulk.BulkRequest; +import com.google.gerrit.opensearch.bulk.IndexRequest; +import com.google.gerrit.opensearch.bulk.UpdateRequest; +import com.google.gerrit.server.account.GroupCache; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexUtils; +import com.google.gerrit.server.index.group.GroupField; +import com.google.gerrit.server.index.group.GroupIndex; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.assistedinject.Assisted; +import java.util.Set; +import org.apache.hc.core5.http.HttpStatus; +import org.opensearch.client.Response; + +public class OpenSearchGroupIndex extends AbstractOpenSearchIndex<AccountGroup.UUID, InternalGroup> + implements GroupIndex { + static class GroupMapping { + final Mapping groups; + + GroupMapping(Schema<InternalGroup> schema, QueryAdapter adapter) { + this.groups = OpenSearchMapping.createMapping(schema, adapter); + } + } + + private static final String GROUPS = "groups"; + + private final GroupMapping mapping; + private final Provider<GroupCache> groupCache; + private final Schema<InternalGroup> schema; + + @Inject + OpenSearchGroupIndex( + OpenSearchConfiguration cfg, + SitePaths sitePaths, + Provider<GroupCache> groupCache, + RestClientProvider client, + AutoFlush autoFlush, + @Assisted Schema<InternalGroup> schema) { + super(cfg, sitePaths, schema, client, GROUPS, autoFlush, GroupIndex.ENTITY_TO_KEY); + this.groupCache = groupCache; + this.mapping = new GroupMapping(schema, client.adapter()); + this.schema = schema; + } + + @Override + public void replace(InternalGroup group) { + BulkRequest bulk = + new IndexRequest(getId(group), indexName) + .add(new UpdateRequest<>(schema, group, ImmutableSet.of())); + + String uri = getURI(BULK); + Response response = postRequestWithRefreshParam(uri, bulk); + int statusCode = response.getStatusLine().getStatusCode(); + if (hasErrors(response) || statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format( + "Failed to replace group %s in index %s: %s", + group.getGroupUUID().get(), indexName, statusCode)); + } + } + + @Override + public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts) + throws QueryParseException { + JsonArray sortArray = getSortArray(GroupField.UUID_FIELD_SPEC.getName()); + return new OpenQuerySource(p, opts.filterFields(IndexUtils::groupFields), sortArray); + } + + @Override + protected String getDeleteActions(AccountGroup.UUID g) { + return getDeleteRequest(g); + } + + @Override + protected String getMappings() { + return getMappingsForSingleType(mapping.groups); + } + + @Override + protected String getId(InternalGroup group) { + return group.getGroupUUID().get(); + } + + @Override + protected InternalGroup fromDocument(JsonObject json, Set<String> fields) { + JsonElement source = json.get("_source"); + if (source == null) { + source = json.getAsJsonObject().get("fields"); + } + + AccountGroup.UUID uuid = + AccountGroup.uuid( + source.getAsJsonObject().get(GroupField.UUID_FIELD_SPEC.getName()).getAsString()); + // Use the GroupCache rather than depending on any stored fields in the + // document (of which there shouldn't be any). + return groupCache.get().get(uuid).orElse(null); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchIndexModule.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchIndexModule.java new file mode 100644 index 0000000..517014a --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchIndexModule.java
@@ -0,0 +1,93 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.index.project.ProjectIndex; +import com.google.gerrit.server.ModuleImpl; +import com.google.gerrit.server.index.AbstractIndexModule; +import com.google.gerrit.server.index.VersionManager; +import com.google.gerrit.server.index.account.AccountIndex; +import com.google.gerrit.server.index.change.ChangeIndex; +import com.google.gerrit.server.index.group.GroupIndex; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.inject.Inject; + +@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE) +public class OpenSearchIndexModule extends AbstractIndexModule { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final AutoFlush autoFlush; + + @VisibleForTesting + public static OpenSearchIndexModule singleVersionWithExplicitVersions( + ImmutableMap<String, Integer> versions, int threads, boolean slave) { + return new OpenSearchIndexModule(versions, threads, slave, AutoFlush.ENABLED); + } + + public static OpenSearchIndexModule singleVersionWithExplicitVersions( + ImmutableMap<String, Integer> versions, int threads, boolean slave, AutoFlush autoFlush) { + return new OpenSearchIndexModule(versions, threads, slave, autoFlush); + } + + @Inject + public OpenSearchIndexModule() { + this(null, 0, false, AutoFlush.ENABLED); + } + + protected OpenSearchIndexModule( + ImmutableMap<String, Integer> singleVersions, + int threads, + boolean slave, + AutoFlush autoFlush) { + super(singleVersions, threads, slave); + this.autoFlush = autoFlush; + } + + @Override + public void configure() { + logger.atInfo().log("Gerrit index backend set to OpenSearch"); + super.configure(); + install(RestClientProvider.module()); + bind(AutoFlush.class).toInstance(autoFlush); + } + + @Override + protected Class<? extends AccountIndex> getAccountIndex() { + return OpenSearchAccountIndex.class; + } + + @Override + protected Class<? extends ChangeIndex> getChangeIndex() { + return OpenSearchChangeIndex.class; + } + + @Override + protected Class<? extends GroupIndex> getGroupIndex() { + return OpenSearchGroupIndex.class; + } + + @Override + protected Class<? extends ProjectIndex> getProjectIndex() { + return OpenSearchProjectIndex.class; + } + + @Override + protected Class<? extends VersionManager> getVersionManager() { + return IndexVersionManager.class; + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchMapping.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchMapping.java new file mode 100644 index 0000000..d01900e --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchMapping.java
@@ -0,0 +1,137 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.index.FieldType; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.SchemaFieldDefs.SchemaField; +import com.google.gson.annotations.SerializedName; +import java.util.Map; + +class OpenSearchMapping { + + protected static final String TIMESTAMP_FIELD_TYPE = "date"; + protected static final String TIMESTAMP_FIELD_FORMAT = "date_optional_time"; + + static Mapping createMapping(Schema<?> schema, QueryAdapter adapter) { + OpenSearchMapping.Builder mapping = new OpenSearchMapping.Builder(adapter); + for (SchemaField<?, ?> field : schema.getSchemaFields().values()) { + String name = field.getName(); + FieldType<?> fieldType = field.getType(); + if (fieldType == FieldType.EXACT) { + mapping.addExactField(name); + } else if (fieldType == FieldType.TIMESTAMP) { + mapping.addTimestamp(name); + } else if (fieldType == FieldType.INTEGER + || fieldType == FieldType.INTEGER_RANGE + || fieldType == FieldType.LONG) { + mapping.addNumber(name); + } else if (fieldType == FieldType.FULL_TEXT) { + mapping.addStringWithAnalyzer(name, "custom_with_char_filter"); + } else if (fieldType == FieldType.PREFIX) { + mapping.addStringWithAnalyzer(name, "keyword_tokenizer"); + } else if (fieldType == FieldType.STORED_ONLY) { + mapping.addString(name); + } else { + throw new IllegalStateException("Unsupported field type: " + fieldType.getName()); + } + } + mapping.addSourceIncludes( + schema.getSchemaFields().values().stream() + .filter(f -> f.isStored()) + .map(f -> f.getName()) + .toArray(String[]::new)); + return mapping.build(); + } + + static class Builder { + private final QueryAdapter adapter; + private final ImmutableMap.Builder<String, FieldProperties> fields = + new ImmutableMap.Builder<>(); + private final ImmutableMap.Builder<String, String[]> sourceIncludes = + new ImmutableMap.Builder<>(); + + Builder(QueryAdapter adapter) { + this.adapter = adapter; + } + + Mapping build() { + Mapping mapping = new Mapping(); + mapping.properties = fields.build(); + mapping.source = sourceIncludes.build(); + return mapping; + } + + Builder addExactField(String name) { + fields.put(name, new FieldProperties(adapter.exactFieldType())); + return this; + } + + Builder addTimestamp(String name) { + FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE); + properties.type = TIMESTAMP_FIELD_TYPE; + properties.format = TIMESTAMP_FIELD_FORMAT; + fields.put(name, properties); + return this; + } + + Builder addNumber(String name) { + fields.put(name, new FieldProperties("long")); + return this; + } + + Builder addString(String name) { + fields.put(name, new FieldProperties(adapter.stringFieldType())); + return this; + } + + Builder addStringWithAnalyzer(String name, String analyzer) { + FieldProperties key = new FieldProperties(adapter.stringFieldType()); + key.analyzer = analyzer; + fields.put(name, key); + return this; + } + + Builder addSourceIncludes(String[] includes) { + sourceIncludes.put("includes", includes); + return this; + } + + Builder add(String name, String type) { + fields.put(name, new FieldProperties(type)); + return this; + } + } + + static class Mapping { + @SerializedName("_source") + Map<String, String[]> source; + + Map<String, FieldProperties> properties; + } + + static class FieldProperties { + String type; + String index; + String format; + String analyzer; + Map<String, FieldProperties> fields; + + FieldProperties(String type) { + this.type = type; + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchProjectIndex.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchProjectIndex.java new file mode 100644 index 0000000..5509da3 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchProjectIndex.java
@@ -0,0 +1,133 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.collect.ImmutableSet; +import com.google.gerrit.entities.Project; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.project.ProjectData; +import com.google.gerrit.index.project.ProjectField; +import com.google.gerrit.index.project.ProjectIndex; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.opensearch.OpenSearchMapping.Mapping; +import com.google.gerrit.opensearch.bulk.BulkRequest; +import com.google.gerrit.opensearch.bulk.IndexRequest; +import com.google.gerrit.opensearch.bulk.UpdateRequest; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexUtils; +import com.google.gerrit.server.index.options.AutoFlush; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.assistedinject.Assisted; +import java.util.Optional; +import java.util.Set; +import org.apache.hc.core5.http.HttpStatus; +import org.opensearch.client.Response; + +public class OpenSearchProjectIndex extends AbstractOpenSearchIndex<Project.NameKey, ProjectData> + implements ProjectIndex { + static class ProjectMapping { + final Mapping projects; + + ProjectMapping(Schema<ProjectData> schema, QueryAdapter adapter) { + this.projects = OpenSearchMapping.createMapping(schema, adapter); + } + } + + private static final String PROJECTS = "projects"; + + private final ProjectMapping mapping; + private final Provider<ProjectCache> projectCache; + private final Schema<ProjectData> schema; + + @Inject + OpenSearchProjectIndex( + OpenSearchConfiguration cfg, + SitePaths sitePaths, + Provider<ProjectCache> projectCache, + RestClientProvider client, + AutoFlush autoFlush, + @Assisted Schema<ProjectData> schema) { + super(cfg, sitePaths, schema, client, PROJECTS, autoFlush, ProjectIndex.ENTITY_TO_KEY); + this.projectCache = projectCache; + this.mapping = new ProjectMapping(schema, client.adapter()); + this.schema = schema; + } + + @Override + public void replace(ProjectData projectState) { + BulkRequest bulk = + new IndexRequest(projectState.getProject().getName(), indexName) + .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of())); + + String uri = getURI(BULK); + Response response = postRequestWithRefreshParam(uri, bulk); + int statusCode = response.getStatusLine().getStatusCode(); + if (hasErrors(response) || statusCode != HttpStatus.SC_OK) { + throw new StorageException( + String.format( + "Failed to replace project %s in index %s: %s", + projectState.getProject().getName(), indexName, statusCode)); + } + } + + @Override + public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts) + throws QueryParseException { + JsonArray sortArray = getSortArray(ProjectField.NAME_SPEC.getName()); + return new OpenQuerySource(p, opts.filterFields(IndexUtils::projectFields), sortArray); + } + + @Override + protected String getDeleteActions(Project.NameKey nameKey) { + return getDeleteRequest(nameKey); + } + + @Override + protected String getMappings() { + return getMappingsForSingleType(mapping.projects); + } + + @Override + protected String getId(ProjectData projectState) { + return projectState.getProject().getName(); + } + + @Override + protected ProjectData fromDocument(JsonObject json, Set<String> fields) { + JsonElement source = json.get("_source"); + if (source == null) { + source = json.getAsJsonObject().get("fields"); + } + + Project.NameKey nameKey = + Project.nameKey( + source.getAsJsonObject().get(ProjectField.NAME_SPEC.getName()).getAsString()); + Optional<ProjectState> state = projectCache.get().get(nameKey); + if (!state.isPresent()) { + return null; + } + return state.get().toProjectData(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchQueryBuilder.java new file mode 100644 index 0000000..5515c13 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchQueryBuilder.java
@@ -0,0 +1,160 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.gerrit.index.FieldType; +import com.google.gerrit.index.SchemaFieldDefs.SchemaField; +import com.google.gerrit.index.query.AndPredicate; +import com.google.gerrit.index.query.IndexPredicate; +import com.google.gerrit.index.query.IntegerRangePredicate; +import com.google.gerrit.index.query.NotPredicate; +import com.google.gerrit.index.query.OrPredicate; +import com.google.gerrit.index.query.PostFilterPredicate; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.index.query.RegexPredicate; +import com.google.gerrit.index.query.TimestampRangePredicate; +import com.google.gerrit.opensearch.builders.BoolQueryBuilder; +import com.google.gerrit.opensearch.builders.QueryBuilder; +import com.google.gerrit.opensearch.builders.QueryBuilders; + +public class OpenSearchQueryBuilder { + + <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException { + if (p instanceof AndPredicate) { + return and(p); + } else if (p instanceof OrPredicate) { + return or(p); + } else if (p instanceof NotPredicate) { + return not(p); + } else if (p instanceof Predicate.Any) { + return QueryBuilders.matchAllQuery(); + } else if (p instanceof IndexPredicate) { + return fieldQuery((IndexPredicate<T>) p); + } else if (p instanceof PostFilterPredicate) { + return QueryBuilders.matchAllQuery(); + } else { + throw new QueryParseException("cannot create query for index: " + p); + } + } + + private <T> BoolQueryBuilder and(Predicate<T> p) throws QueryParseException { + BoolQueryBuilder b = QueryBuilders.boolQuery(); + for (Predicate<T> c : p.getChildren()) { + b.filter(toQueryBuilder(c)); + } + return b; + } + + private <T> BoolQueryBuilder or(Predicate<T> p) throws QueryParseException { + BoolQueryBuilder q = QueryBuilders.boolQuery(); + for (Predicate<T> c : p.getChildren()) { + q.should(toQueryBuilder(c)); + } + return q; + } + + private <T> QueryBuilder not(Predicate<T> p) throws QueryParseException { + Predicate<T> n = p.getChild(0); + if (n instanceof TimestampRangePredicate) { + return notTimestamp((TimestampRangePredicate<T>) n); + } + + // Lucene does not support negation, start with all and subtract. + BoolQueryBuilder q = QueryBuilders.boolQuery(); + q.filter(QueryBuilders.matchAllQuery()); + q.mustNot(toQueryBuilder(n)); + return q; + } + + private <T> QueryBuilder fieldQuery(IndexPredicate<T> p) throws QueryParseException { + FieldType<?> type = p.getType(); + SchemaField<?, ?> field = p.getField(); + String name = field.getName(); + String value = p.getValue(); + + if (type == FieldType.INTEGER) { + // Create integer terms with string representations + return QueryBuilders.termQuery(name, value); + } else if (type == FieldType.INTEGER_RANGE) { + return intRangeQuery(p); + } else if (type == FieldType.TIMESTAMP) { + return timestampQuery(p); + } else if (type == FieldType.EXACT) { + return exactQuery(p); + } else if (type == FieldType.PREFIX) { + return QueryBuilders.matchPhrasePrefixQuery(name, value); + } else if (type == FieldType.FULL_TEXT) { + return QueryBuilders.matchPhraseQuery(name, value); + } else { + throw FieldType.badFieldType(p.getType()); + } + } + + private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p) throws QueryParseException { + if (p instanceof IntegerRangePredicate) { + IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p; + int minimum = r.getMinimumValue(); + int maximum = r.getMaximumValue(); + if (minimum == maximum) { + // Just fall back to a standard integer query. + return QueryBuilders.termQuery(p.getField().getName(), minimum); + } + return QueryBuilders.rangeQuery(p.getField().getName()).gte(minimum).lte(maximum); + } + throw new QueryParseException("not an integer range: " + p); + } + + private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException { + if (r.getMinTimestamp().toEpochMilli() == 0) { + return QueryBuilders.rangeQuery(r.getField().getName()).gt(r.getMaxTimestamp()); + } + throw new QueryParseException("cannot negate: " + r); + } + + private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException { + if (p instanceof TimestampRangePredicate) { + TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p; + if (r.getMaxTimestamp().toEpochMilli() == Long.MAX_VALUE) { + // The time range only has the start value, search from the start to the max supported value + // Long.MAX_VALUE + return QueryBuilders.rangeQuery(r.getField().getName()).gte(r.getMinTimestamp()); + } + return QueryBuilders.rangeQuery(r.getField().getName()) + .gte(r.getMinTimestamp()) + .lte(r.getMaxTimestamp()); + } + throw new QueryParseException("not a timestamp: " + p); + } + + private <T> QueryBuilder exactQuery(IndexPredicate<T> p) { + String name = p.getField().getName(); + String value = p.getValue(); + + if (!p.getField().isRepeatable() && value.isEmpty()) { + return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name)); + } else if (p instanceof RegexPredicate) { + if (value.startsWith("^")) { + value = value.substring(1); + } + if (value.endsWith("$") && !value.endsWith("\\$") && !value.endsWith("\\\\$")) { + value = value.substring(0, value.length() - 1); + } + return QueryBuilders.regexpQuery(name, value); + } else { + return QueryBuilders.termQuery(name, value); + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchStoredValue.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchStoredValue.java new file mode 100644 index 0000000..d5194e4 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchStoredValue.java
@@ -0,0 +1,99 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.gerrit.index.StoredValue; +import com.google.gson.JsonElement; +import com.google.protobuf.MessageLite; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.stream.StreamSupport; + +/** Bridge to recover fields from the opensearch index. */ +public class OpenSearchStoredValue implements StoredValue { + private final JsonElement field; + + OpenSearchStoredValue(JsonElement field) { + this.field = field; + } + + @Override + public String asString() { + return field.getAsString(); + } + + @Override + public Iterable<String> asStrings() { + return StreamSupport.stream(field.getAsJsonArray().spliterator(), false) + .map(f -> f.getAsString()) + .collect(toImmutableList()); + } + + @Override + public Integer asInteger() { + return field.getAsInt(); + } + + @Override + public Iterable<Integer> asIntegers() { + return StreamSupport.stream(field.getAsJsonArray().spliterator(), false) + .map(f -> f.getAsInt()) + .collect(toImmutableList()); + } + + @Override + public Long asLong() { + return field.getAsLong(); + } + + @Override + public Iterable<Long> asLongs() { + return StreamSupport.stream(field.getAsJsonArray().spliterator(), false) + .map(f -> f.getAsLong()) + .collect(toImmutableList()); + } + + @Override + public Timestamp asTimestamp() { + return Timestamp.from(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(field.getAsString()))); + } + + @Override + public byte[] asByteArray() { + return AbstractOpenSearchIndex.decodeBase64(field.getAsString()); + } + + @Override + public Iterable<byte[]> asByteArrays() { + return StreamSupport.stream(field.getAsJsonArray().spliterator(), false) + .map(f -> AbstractOpenSearchIndex.decodeBase64(f.getAsString())) + .collect(toImmutableList()); + } + + @Override + public MessageLite asProto() { + // OpenSearch does not store protos + return null; + } + + @Override + public Iterable<MessageLite> asProtos() { + // OpenSearch does not store protos + return null; + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchVersion.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchVersion.java new file mode 100644 index 0000000..e8e6389 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchVersion.java
@@ -0,0 +1,65 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.base.Joiner; +import java.util.regex.Pattern; + +public enum OpenSearchVersion { + V3("3\\.\\d+\\.\\d+.*"), + V4("4\\.\\d+\\.\\d+.*"); + + private final String version; + private final Pattern pattern; + + OpenSearchVersion(String version) { + this.version = version; + this.pattern = Pattern.compile(version); + } + + public static class UnsupportedVersion extends OpenSearchException { + private static final long serialVersionUID = 1L; + + UnsupportedVersion(String version) { + super( + String.format( + "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions())); + } + } + + /** + * Convert a version String to an OpenSearchVersion if supported. + * + * @param version for which to return an OpenSearchVersion + * @return the corresponding OpenSearchVersion if supported + */ + public static OpenSearchVersion forVersion(String version) { + for (OpenSearchVersion value : OpenSearchVersion.values()) { + if (value.pattern.matcher(version).matches()) { + return value; + } + } + throw new UnsupportedVersion(version); + } + + public static String supportedVersions() { + return Joiner.on(", ").join(OpenSearchVersion.values()); + } + + @Override + public String toString() { + return version; + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/PrimaryIndexModule.java b/src/main/java/com/google/gerrit/opensearch/PrimaryIndexModule.java new file mode 100644 index 0000000..0f39bac --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/PrimaryIndexModule.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.gerrit.server.ModuleImpl; +import com.google.gerrit.server.index.AbstractIndexModule; +import com.google.gerrit.server.index.options.AutoFlush; + +@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE) +public class PrimaryIndexModule extends OpenSearchIndexModule { + + public PrimaryIndexModule() { + super(null, 0, false, AutoFlush.ENABLED); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/QueryAdapter.java b/src/main/java/com/google/gerrit/opensearch/QueryAdapter.java new file mode 100644 index 0000000..0a74b64 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/QueryAdapter.java
@@ -0,0 +1,64 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// TODO: Consider inlining this class into AbstractOpenSearchIndex and OpenSearchMapping +package com.google.gerrit.opensearch; + +public class QueryAdapter { + private static final String INDICES = "?allow_no_indices=false"; + + private final String searchFilteringName; + private final String exactFieldType; + private final String stringFieldType; + private final String indexProperty; + private final String rawFieldsKey; + private final String versionDiscoveryUrl; + + QueryAdapter() { + this.versionDiscoveryUrl = "/%s*"; + this.searchFilteringName = "_source"; + this.exactFieldType = "keyword"; + this.stringFieldType = "text"; + this.indexProperty = "true"; + this.rawFieldsKey = "_source"; + } + + public String searchFilteringName() { + return searchFilteringName; + } + + String indicesExistParams() { + return INDICES; + } + + String exactFieldType() { + return exactFieldType; + } + + String stringFieldType() { + return stringFieldType; + } + + String indexProperty() { + return indexProperty; + } + + String rawFieldsKey() { + return rawFieldsKey; + } + + String getVersionDiscoveryUrl(String name) { + return String.format(versionDiscoveryUrl, name); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/ReplicaIndexModule.java b/src/main/java/com/google/gerrit/opensearch/ReplicaIndexModule.java new file mode 100644 index 0000000..3e39363 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/ReplicaIndexModule.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.gerrit.server.ModuleImpl; +import com.google.gerrit.server.index.AbstractIndexModule; +import com.google.gerrit.server.index.options.AutoFlush; + +@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE) +public class ReplicaIndexModule extends OpenSearchIndexModule { + + public ReplicaIndexModule() { + super(null, 0, true, AutoFlush.ENABLED); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/RestClientProvider.java b/src/main/java/com/google/gerrit/opensearch/RestClientProvider.java new file mode 100644 index 0000000..70957d9 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/RestClientProvider.java
@@ -0,0 +1,168 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.lifecycle.LifecycleModule; +import com.google.gson.JsonParser; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +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.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.StatusLine; +import org.apache.hc.core5.util.Timeout; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.client.RestClient; +import org.opensearch.client.RestClientBuilder; + +@Singleton +class RestClientProvider implements Provider<RestClient>, LifecycleListener { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final OpenSearchConfiguration cfg; + + private volatile RestClient client; + private QueryAdapter adapter; + + @Inject + RestClientProvider(OpenSearchConfiguration cfg) { + this.cfg = cfg; + } + + public static LifecycleModule module() { + return new LifecycleModule() { + @Override + protected void configure() { + listener().to(RestClientProvider.class); + } + }; + } + + @Override + public RestClient get() { + if (client == null) { + synchronized (this) { + if (client == null) { + client = build(); + OpenSearchVersion version = getVersion(); + logger.atInfo().log("OpenSearch integration version %s", version); + adapter = new QueryAdapter(); + } + } + } + return client; + } + + @Override + public void start() {} + + @Override + public void stop() { + if (client != null) { + try { + client.close(); + } catch (IOException e) { + // Ignore. We can't do anything about it. + } + } + } + + QueryAdapter adapter() { + get(); // Make sure we're connected + return adapter; + } + + public static class FailedToGetVersion extends OpenSearchException { + private static final long serialVersionUID = 1L; + private static final String MESSAGE = "Failed to get OpenSearch version"; + + FailedToGetVersion(StatusLine status) { + super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase())); + } + + FailedToGetVersion(Throwable cause) { + super(MESSAGE, cause); + } + } + + private OpenSearchVersion getVersion() throws OpenSearchException { + try { + Response response = client.performRequest(new Request("GET", "/")); + StatusLine statusLine = response.getStatusLine(); + if (statusLine.getStatusCode() != HttpStatus.SC_OK) { + throw new FailedToGetVersion(statusLine); + } + String version = + JsonParser.parseString(AbstractOpenSearchIndex.getContent(response)) + .getAsJsonObject() + .get("version") + .getAsJsonObject() + .get("number") + .getAsString(); + logger.atInfo().log("Connected to OpenSearch version %s", version); + return OpenSearchVersion.forVersion(version); + } catch (IOException e) { + throw new FailedToGetVersion(e); + } + } + + private RestClient build() { + RestClientBuilder builder = RestClient.builder(cfg.getHosts()); + builder.setDefaultHeaders( + new Header[] {new BasicHeader("Accept", ContentType.APPLICATION_JSON.toString())}); + setConfiguredTimeouts(builder); + setConfiguredCredentialsIfAny(builder); + return builder.build(); + } + + private void setConfiguredTimeouts(RestClientBuilder builder) { + builder.setRequestConfigCallback( + (RequestConfig.Builder requestConfigBuilder) -> + requestConfigBuilder + .setConnectTimeout(Timeout.ofMilliseconds(cfg.connectTimeout)) + .setResponseTimeout(Timeout.ofMilliseconds(cfg.socketTimeout))); + } + + private void setConfiguredCredentialsIfAny(RestClientBuilder builder) { + String username = cfg.username; + String password = cfg.password; + if (username != null && password != null) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(null, -1), + new UsernamePasswordCredentials(username, password.toCharArray())); + builder.setHttpClientConfigCallback( + (HttpAsyncClientBuilder httpClientBuilder) -> { + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + configureHttpClientBuilder(httpClientBuilder); + return httpClientBuilder; + }); + } + } + + protected void configureHttpClientBuilder( + @SuppressWarnings("unused") HttpAsyncClientBuilder httpClientBuilder) {} +}
diff --git a/src/main/java/com/google/gerrit/opensearch/Setting.java b/src/main/java/com/google/gerrit/opensearch/Setting.java new file mode 100644 index 0000000..28721df --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/Setting.java
@@ -0,0 +1,104 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.SerializedName; +import java.util.Map; + +class Setting { + /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */ + private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING = + ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020"); + + static SettingProperties createSetting(OpenSearchConfiguration config) { + return new Setting.Builder().addCharFilter().addAnalyzer().build(config); + } + + static class Builder { + private final ImmutableMap.Builder<String, FieldProperties> fields = + new ImmutableMap.Builder<>(); + + SettingProperties build(OpenSearchConfiguration config) { + SettingProperties properties = new SettingProperties(); + properties.analysis = fields.build(); + properties.numberOfShards = config.getNumberOfShards(); + properties.numberOfReplicas = config.numberOfReplicas; + properties.maxResultWindow = config.maxResultWindow; + properties.codec = config.codec; + return properties; + } + + Builder addCharFilter() { + FieldProperties charMapping = new FieldProperties("mapping"); + charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING); + + FieldProperties charFilter = new FieldProperties(); + charFilter.customMapping = charMapping; + fields.put("char_filter", charFilter); + return this; + } + + Builder addAnalyzer() { + FieldProperties customAnalyzer = new FieldProperties("custom"); + customAnalyzer.tokenizer = "standard"; + customAnalyzer.charFilter = new String[] {"custom_mapping"}; + customAnalyzer.filter = new String[] {"lowercase"}; + + FieldProperties analyzer = new FieldProperties(); + analyzer.customWithCharFilter = customAnalyzer; + analyzer.keywordTokenizer = ImmutableMap.of("tokenizer", "keyword"); + fields.put("analyzer", analyzer); + return this; + } + + private static String[] getCustomCharMappings(ImmutableMap<String, String> map) { + int mappingIndex = 0; + int numOfMappings = map.size(); + String[] mapping = new String[numOfMappings]; + for (Map.Entry<String, String> e : map.entrySet()) { + mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue(); + } + return mapping; + } + } + + static class SettingProperties { + @SerializedName("index.codec") + String codec; + + Map<String, FieldProperties> analysis; + Integer numberOfShards; + Integer numberOfReplicas; + Integer maxResultWindow; + } + + static class FieldProperties { + String tokenizer; + String type; + String[] charFilter; + String[] filter; + String[] mappings; + FieldProperties customMapping; + FieldProperties customWithCharFilter; + Map<String, String> keywordTokenizer; + + FieldProperties() {} + + FieldProperties(String type) { + this.type = type; + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/BoolQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/BoolQueryBuilder.java new file mode 100644 index 0000000..c2e10c8 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/BoolQueryBuilder.java
@@ -0,0 +1,98 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * A Query that matches documents matching boolean combinations of other queries. + * + * <p>A trimmed down version of org.elasticsearch.index.query.BoolQueryBuilder. + */ +public class BoolQueryBuilder extends QueryBuilder { + + private final List<QueryBuilder> mustClauses = new ArrayList<>(); + + private final List<QueryBuilder> mustNotClauses = new ArrayList<>(); + + private final List<QueryBuilder> filterClauses = new ArrayList<>(); + + private final List<QueryBuilder> shouldClauses = new ArrayList<>(); + + /** + * Adds a query that <b>must</b> appear in the matching documents and will contribute to scoring. + */ + public BoolQueryBuilder must(QueryBuilder queryBuilder) { + mustClauses.add(queryBuilder); + return this; + } + + /** + * Adds a query that <b>must</b> appear in the matching documents and will not contribute to + * scoring. + */ + public BoolQueryBuilder filter(QueryBuilder queryBuilder) { + filterClauses.add(queryBuilder); + return this; + } + + /** + * Adds a query that <b>must not</b> appear in the matching documents and will not contribute to + * scoring. + */ + public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) { + mustNotClauses.add(queryBuilder); + return this; + } + + /** + * Adds a query that <i>should</i> appear in the matching documents. For a boolean query with no + * <tt>MUST</tt> clauses one or more <code>SHOULD</code> clauses must match a document for the + * BooleanQuery to match. + */ + public BoolQueryBuilder should(QueryBuilder queryBuilder) { + shouldClauses.add(queryBuilder); + return this; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("bool"); + doXArrayContent("must", mustClauses, builder); + doXArrayContent("filter", filterClauses, builder); + doXArrayContent("must_not", mustNotClauses, builder); + doXArrayContent("should", shouldClauses, builder); + builder.endObject(); + } + + private void doXArrayContent(String field, List<QueryBuilder> clauses, XContentBuilder builder) + throws IOException { + if (clauses.isEmpty()) { + return; + } + if (clauses.size() == 1) { + builder.field(field); + clauses.get(0).toXContent(builder); + } else { + builder.startArray(field); + for (QueryBuilder clause : clauses) { + clause.toXContent(builder); + } + builder.endArray(); + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/ExistsQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/ExistsQueryBuilder.java new file mode 100644 index 0000000..f18447b --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/ExistsQueryBuilder.java
@@ -0,0 +1,38 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** + * Constructs a query that only match on documents that the field has a value in them. + * + * <p>A trimmed down version of org.elasticsearch.index.query.ExistsQueryBuilder. + */ +class ExistsQueryBuilder extends QueryBuilder { + + private final String name; + + ExistsQueryBuilder(String name) { + this.name = name; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("exists"); + builder.field("field", name); + builder.endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/MatchAllQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/MatchAllQueryBuilder.java new file mode 100644 index 0000000..d9a0c4b --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/MatchAllQueryBuilder.java
@@ -0,0 +1,31 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** + * A query that matches on all documents. + * + * <p>A trimmed down version of org.elasticsearch.index.query.MatchAllQueryBuilder. + */ +class MatchAllQueryBuilder extends QueryBuilder { + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("match_all"); + builder.endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/MatchQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/MatchQueryBuilder.java new file mode 100644 index 0000000..7369576 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/MatchQueryBuilder.java
@@ -0,0 +1,62 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; +import java.util.Locale; + +/** + * Match query is a query that analyzes the text and constructs a query as the result of the + * analysis. It can construct different queries based on the type provided. + * + * <p>A trimmed down version of org.elasticsearch.index.query.MatchQueryBuilder. + */ +class MatchQueryBuilder extends QueryBuilder { + + enum Type { + /** The text is analyzed and used as a phrase query. */ + MATCH_PHRASE, + /** The text is analyzed and used in a phrase query, with the last term acting as a prefix. */ + MATCH_PHRASE_PREFIX; + + @Override + public String toString() { + return name().toLowerCase(Locale.US); + } + } + + private final String name; + + private final Object text; + + private Type type; + + /** Constructs a new text query. */ + MatchQueryBuilder(String name, Object text) { + this.name = name; + this.text = text; + } + + /** Sets the type of the text query. */ + MatchQueryBuilder type(Type type) { + this.type = type; + return this; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject(type.toString()).field(name, text).endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilder.java new file mode 100644 index 0000000..cbf2ab9 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilder.java
@@ -0,0 +1,31 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** A trimmed down version of org.elasticsearch.index.query.QueryBuilder. */ +public abstract class QueryBuilder { + + protected QueryBuilder() {} + + protected void toXContent(XContentBuilder builder) throws IOException { + builder.startObject(); + doXContent(builder); + builder.endObject(); + } + + protected abstract void doXContent(XContentBuilder builder) throws IOException; +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilders.java b/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilders.java new file mode 100644 index 0000000..926b95d --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/QueryBuilders.java
@@ -0,0 +1,103 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +/** + * A static factory for simple "import static" usage. + * + * <p>A trimmed down version of org.elasticsearch.index.query.QueryBuilders. + */ +public abstract class QueryBuilders { + + /** A query that match on all documents. */ + public static MatchAllQueryBuilder matchAllQuery() { + return new MatchAllQueryBuilder(); + } + + /** + * Creates a text query with type "PHRASE" for the provided field name and text. + * + * @param name The field name. + * @param text The query text (to be analyzed). + */ + public static MatchQueryBuilder matchPhraseQuery(String name, Object text) { + return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE); + } + + /** + * Creates a match query with type "PHRASE_PREFIX" for the provided field name and text. + * + * @param name The field name. + * @param text The query text (to be analyzed). + */ + public static MatchQueryBuilder matchPhrasePrefixQuery(String name, Object text) { + return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE_PREFIX); + } + + /** + * A Query that matches documents containing a term. + * + * @param name The name of the field + * @param value The value of the term + */ + public static TermQueryBuilder termQuery(String name, String value) { + return new TermQueryBuilder(name, value); + } + + /** + * A Query that matches documents containing a term. + * + * @param name The name of the field + * @param value The value of the term + */ + public static TermQueryBuilder termQuery(String name, int value) { + return new TermQueryBuilder(name, value); + } + + /** + * A Query that matches documents within an range of terms. + * + * @param name The field name + */ + public static RangeQueryBuilder rangeQuery(String name) { + return new RangeQueryBuilder(name); + } + + /** + * A Query that matches documents containing terms with a specified regular expression. + * + * @param name The name of the field + * @param regexp The regular expression + */ + public static RegexpQueryBuilder regexpQuery(String name, String regexp) { + return new RegexpQueryBuilder(name, regexp); + } + + /** A Query that matches documents matching boolean combinations of other queries. */ + public static BoolQueryBuilder boolQuery() { + return new BoolQueryBuilder(); + } + + /** + * A filter to filter only documents where a field exists in them. + * + * @param name The name of the field + */ + public static ExistsQueryBuilder existsQuery(String name) { + return new ExistsQueryBuilder(name); + } + + private QueryBuilders() {} +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/QuerySourceBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/QuerySourceBuilder.java new file mode 100644 index 0000000..654eae7 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/QuerySourceBuilder.java
@@ -0,0 +1,32 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** A trimmed down and modified version of org.elasticsearch.action.support.QuerySourceBuilder. */ +class QuerySourceBuilder { + + private final QueryBuilder queryBuilder; + + QuerySourceBuilder(QueryBuilder queryBuilder) { + this.queryBuilder = queryBuilder; + } + + void innerToXContent(XContentBuilder builder) throws IOException { + builder.field("query"); + queryBuilder.toXContent(builder); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/RangeQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/RangeQueryBuilder.java new file mode 100644 index 0000000..8b2c2fc --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/RangeQueryBuilder.java
@@ -0,0 +1,89 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** + * A Query that matches documents within an range of terms. + * + * <p>A trimmed down version of org.elasticsearch.index.query.RangeQueryBuilder. + */ +public class RangeQueryBuilder extends QueryBuilder { + + private final String name; + private Object from; + private Object to; + private boolean includeLower = true; + private boolean includeUpper = true; + + /** + * A Query that matches documents within an range of terms. + * + * @param name The field name + */ + RangeQueryBuilder(String name) { + this.name = name; + } + + /** The from part of the range query. Null indicates unbounded. */ + public RangeQueryBuilder gt(Object from) { + this.from = from; + this.includeLower = false; + return this; + } + + /** The from part of the range query. Null indicates unbounded. */ + public RangeQueryBuilder gte(Object from) { + this.from = from; + this.includeLower = true; + return this; + } + + /** The from part of the range query. Null indicates unbounded. */ + public RangeQueryBuilder gte(int from) { + this.from = from; + this.includeLower = true; + return this; + } + + /** The to part of the range query. Null indicates unbounded. */ + public RangeQueryBuilder lte(Object to) { + this.to = to; + this.includeUpper = true; + return this; + } + + /** The to part of the range query. Null indicates unbounded. */ + public RangeQueryBuilder lte(int to) { + this.to = to; + this.includeUpper = true; + return this; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("range"); + builder.startObject(name); + + builder.field("from", from); + builder.field("to", to); + builder.field("include_lower", includeLower); + builder.field("include_upper", includeUpper); + + builder.endObject(); + builder.endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/RegexpQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/RegexpQueryBuilder.java new file mode 100644 index 0000000..ae753ad --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/RegexpQueryBuilder.java
@@ -0,0 +1,51 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** + * A Query that does fuzzy matching for a specific value. + * + * <p>A trimmed down version of org.elasticsearch.index.query.RegexpQueryBuilder. + */ +class RegexpQueryBuilder extends QueryBuilder { + + private final String name; + private final String regexp; + + /** + * Constructs a new term query. + * + * @param name The name of the field + * @param regexp The regular expression + */ + RegexpQueryBuilder(String name, String regexp) { + this.name = name; + this.regexp = regexp; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("regexp"); + builder.startObject(name); + + builder.field("value", regexp); + builder.field("flags", "ALL"); + + builder.endObject(); + builder.endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/SearchAfterBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/SearchAfterBuilder.java new file mode 100644 index 0000000..5bb2fc5 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/SearchAfterBuilder.java
@@ -0,0 +1,45 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; +import java.io.IOException; + +/** + * A trimmed down and modified version of org.elasticsearch.search.searchafter.SearchAfterBuilder. + */ +public final class SearchAfterBuilder { + private JsonArray sortValues; + + public SearchAfterBuilder(JsonArray sortValues) { + this.sortValues = sortValues; + } + + public void innerToXContent(XContentBuilder builder) throws IOException { + builder.startArray("search_after"); + for (int i = 0; i < sortValues.size(); i++) { + JsonPrimitive value = sortValues.get(i).getAsJsonPrimitive(); + if (value.isNumber()) { + builder.value(value.getAsLong()); + } else if (value.isBoolean()) { + builder.value(value.getAsBoolean()); + } else { + builder.value(value.getAsString()); + } + } + builder.endArray(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/SearchSourceBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/SearchSourceBuilder.java new file mode 100644 index 0000000..f8ea131 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/SearchSourceBuilder.java
@@ -0,0 +1,135 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import com.google.gerrit.opensearch.QueryAdapter; +import com.google.gson.JsonArray; +import java.io.IOException; +import java.util.List; + +/** + * A search source builder allowing to easily build search source. + * + * <p>A trimmed down and modified version of org.elasticsearch.search.builder.SearchSourceBuilder. + */ +public class SearchSourceBuilder { + private final QueryAdapter adapter; + + private QuerySourceBuilder querySourceBuilder; + + private SearchAfterBuilder searchAfterBuilder; + + private int from = -1; + + private int size = -1; + + private boolean trackTotalHits = true; + + private List<String> fieldNames; + + /** Constructs a new search source builder. */ + public SearchSourceBuilder(QueryAdapter adapter) { + this.adapter = adapter; + } + + /** Constructs a new search source builder with a search query. */ + public SearchSourceBuilder query(QueryBuilder query) { + if (this.querySourceBuilder == null) { + this.querySourceBuilder = new QuerySourceBuilder(query); + } + return this; + } + + /** From index to start the search from. Defaults to <tt>0</tt>. */ + public SearchSourceBuilder from(int from) { + this.from = from; + return this; + } + + public SearchSourceBuilder searchAfter(JsonArray sortValues) { + this.searchAfterBuilder = new SearchAfterBuilder(sortValues); + return this; + } + + /** The number of search hits to return. Defaults to <tt>10</tt>. */ + public SearchSourceBuilder size(int size) { + this.size = size; + return this; + } + + public SearchSourceBuilder trackTotalHits(boolean track) { + this.trackTotalHits = track; + return this; + } + + /** + * Sets the fields to load and return as part of the search request. If none are specified, the + * source of the document will be returned. + */ + public SearchSourceBuilder fields(List<String> fields) { + this.fieldNames = fields; + return this; + } + + @Override + public final String toString() { + try { + XContentBuilder builder = new XContentBuilder(); + toXContent(builder); + return builder.string(); + } catch (IOException ioe) { + return ""; + } + } + + private void toXContent(XContentBuilder builder) throws IOException { + builder.startObject(); + innerToXContent(builder); + builder.endObject(); + } + + private void innerToXContent(XContentBuilder builder) throws IOException { + if (from != -1) { + builder.field("from", from); + } + if (size != -1) { + builder.field("size", size); + } + + if (!trackTotalHits) { + builder.field("track_total_hits", false); + } + + if (querySourceBuilder != null) { + querySourceBuilder.innerToXContent(builder); + } + + if (fieldNames != null) { + if (fieldNames.size() == 1) { + builder.field(adapter.searchFilteringName(), fieldNames.get(0)); + } else { + builder.startArray(adapter.searchFilteringName()); + for (String fieldName : fieldNames) { + builder.value(fieldName); + } + builder.endArray(); + } + } + + if (searchAfterBuilder != null) { + searchAfterBuilder.innerToXContent(builder); + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/TermQueryBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/TermQueryBuilder.java new file mode 100644 index 0000000..7e87452 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/TermQueryBuilder.java
@@ -0,0 +1,67 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import java.io.IOException; + +/** + * A Query that matches documents containing a term. + * + * <p>A trimmed down version of org.elasticsearch.index.query.TermQueryBuilder. + */ +class TermQueryBuilder extends QueryBuilder { + + private final String name; + + private final Object value; + + /** + * Constructs a new term query. + * + * @param name The name of the field + * @param value The value of the term + */ + TermQueryBuilder(String name, String value) { + this(name, (Object) value); + } + + /** + * Constructs a new term query. + * + * @param name The name of the field + * @param value The value of the term + */ + TermQueryBuilder(String name, int value) { + this(name, (Object) value); + } + + /** + * Constructs a new term query. + * + * @param name The name of the field + * @param value The value of the term + */ + private TermQueryBuilder(String name, Object value) { + this.name = name; + this.value = value; + } + + @Override + protected void doXContent(XContentBuilder builder) throws IOException { + builder.startObject("term"); + builder.field(name, value); + builder.endObject(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/builders/XContentBuilder.java b/src/main/java/com/google/gerrit/opensearch/builders/XContentBuilder.java new file mode 100644 index 0000000..178929e --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/builders/XContentBuilder.java
@@ -0,0 +1,165 @@ +// Copyright (C) 2026 The Android Open Source Project, 2009-2015 Elasticsearch +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.builders; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.format.DateTimeFormatter.ISO_INSTANT; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.core.json.JsonWriteFeature; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; + +/** A trimmed down and modified version of org.elasticsearch.common.xcontent.XContentBuilder. */ +public final class XContentBuilder implements Closeable { + + private final JsonGenerator generator; + + private final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + + /** + * Constructs a new builder. Make sure to call {@link #close()} when the builder is done with. + * Inspired from org.elasticsearch.common.xcontent.json.JsonXContent static block. + */ + public XContentBuilder() throws IOException { + this.generator = + JsonFactory.builder() + .configure(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES, true) + .configure(JsonWriteFeature.QUOTE_FIELD_NAMES, true) + .configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true) + .configure(JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW, false) + .build() + .createGenerator(bos, JsonEncoding.UTF8); + } + + public XContentBuilder startObject(String name) throws IOException { + field(name); + startObject(); + return this; + } + + public XContentBuilder startObject() throws IOException { + generator.writeStartObject(); + return this; + } + + public XContentBuilder endObject() throws IOException { + generator.writeEndObject(); + return this; + } + + public void startArray(String name) throws IOException { + field(name); + startArray(); + } + + private void startArray() throws IOException { + generator.writeStartArray(); + } + + public void endArray() throws IOException { + generator.writeEndArray(); + } + + public XContentBuilder field(String name) throws IOException { + generator.writeFieldName(name); + return this; + } + + public XContentBuilder field(String name, String value) throws IOException { + field(name); + generator.writeString(value); + return this; + } + + public XContentBuilder field(String name, int value) throws IOException { + field(name); + generator.writeNumber(value); + return this; + } + + public XContentBuilder field(String name, Iterable<?> value) throws IOException { + startArray(name); + for (Object o : value) { + value(o); + } + endArray(); + return this; + } + + public XContentBuilder field(String name, Object value) throws IOException { + field(name); + writeValue(value); + return this; + } + + public XContentBuilder value(Object value) throws IOException { + writeValue(value); + return this; + } + + public XContentBuilder field(String name, boolean value) throws IOException { + field(name); + generator.writeBoolean(value); + return this; + } + + public XContentBuilder value(String value) throws IOException { + generator.writeString(value); + return this; + } + + @Override + public void close() { + try { + generator.close(); + } catch (IOException e) { + // ignore + } + } + + /** Returns a string representation of the builder (only applicable for text based xcontent). */ + public String string() { + close(); + byte[] bytesArray = bos.toByteArray(); + return new String(bytesArray, UTF_8); + } + + private void writeValue(Object value) throws IOException { + if (value == null) { + generator.writeNull(); + return; + } + Class<?> type = value.getClass(); + if (type == String.class) { + generator.writeString((String) value); + } else if (type == Integer.class) { + generator.writeNumber(((Integer) value)); + } else if (type == Long.class) { + generator.writeNumber(((Long) value)); + } else if (type == byte[].class) { + generator.writeBinary((byte[]) value); + } else if (value instanceof Date) { + generator.writeString(ISO_INSTANT.format(((Date) value).toInstant())); + } else { + generator.writeString(value.toString()); + } + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/bulk/ActionRequest.java b/src/main/java/com/google/gerrit/opensearch/bulk/ActionRequest.java new file mode 100644 index 0000000..ad6e1b1 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/bulk/ActionRequest.java
@@ -0,0 +1,41 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.bulk; + +import com.google.gson.JsonObject; + +abstract class ActionRequest extends BulkRequest { + + private final String action; + private final String id; + private final String index; + + protected ActionRequest(String action, String id, String index) { + this.action = action; + this.id = id; + this.index = index; + } + + @Override + protected String getRequest() { + JsonObject properties = new JsonObject(); + properties.addProperty("_id", id); + properties.addProperty("_index", index); + + JsonObject jsonAction = new JsonObject(); + jsonAction.add(action, properties); + return jsonAction.toString() + System.lineSeparator(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/bulk/BulkRequest.java b/src/main/java/com/google/gerrit/opensearch/bulk/BulkRequest.java new file mode 100644 index 0000000..2a61cf6 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/bulk/BulkRequest.java
@@ -0,0 +1,43 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.bulk; + +import java.util.ArrayList; +import java.util.List; + +public abstract class BulkRequest { + + private final List<BulkRequest> requests = new ArrayList<>(); + + protected BulkRequest() { + add(this); + } + + public BulkRequest add(BulkRequest request) { + requests.add(request); + return this; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (BulkRequest request : requests) { + builder.append(request.getRequest()); + } + return builder.toString(); + } + + protected abstract String getRequest(); +}
diff --git a/src/main/java/com/google/gerrit/opensearch/bulk/DeleteRequest.java b/src/main/java/com/google/gerrit/opensearch/bulk/DeleteRequest.java new file mode 100644 index 0000000..fea42bb --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/bulk/DeleteRequest.java
@@ -0,0 +1,22 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.bulk; + +public class DeleteRequest extends ActionRequest { + + public DeleteRequest(String id, String index) { + super("delete", id, index); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/bulk/IndexRequest.java b/src/main/java/com/google/gerrit/opensearch/bulk/IndexRequest.java new file mode 100644 index 0000000..e0402db --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/bulk/IndexRequest.java
@@ -0,0 +1,22 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.bulk; + +public class IndexRequest extends ActionRequest { + + public IndexRequest(String id, String index) { + super("index", id, index); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/bulk/UpdateRequest.java b/src/main/java/com/google/gerrit/opensearch/bulk/UpdateRequest.java new file mode 100644 index 0000000..035bb35 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/bulk/UpdateRequest.java
@@ -0,0 +1,72 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.bulk; + +import static java.util.stream.Collectors.toList; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.google.common.collect.Streams; +import com.google.gerrit.index.Schema; +import com.google.gerrit.index.Schema.Values; +import com.google.gerrit.index.SchemaFieldDefs; +import com.google.gerrit.opensearch.builders.XContentBuilder; +import com.google.gerrit.proto.Protos; +import com.google.protobuf.MessageLite; +import java.io.IOException; + +public class UpdateRequest<V> extends BulkRequest { + + private final Schema<V> schema; + private final V v; + private final ImmutableSet<String> skipFields; + + public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) { + this.schema = schema; + this.v = v; + this.skipFields = skipFields; + } + + @Override + protected String getRequest() { + try (XContentBuilder closeable = new XContentBuilder()) { + XContentBuilder builder = closeable.startObject(); + for (Values<V> schemaValues : schema.buildFields(v, skipFields)) { + String name = schemaValues.getField().getName(); + Iterable<?> values = schemaValues.getValues(); + if (SchemaFieldDefs.isProtoField(schemaValues.getField())) { + values = + Iterables.transform( + schemaValues.getValues(), v -> Protos.toByteArray((MessageLite) v)); + } + if (schemaValues.getField().isRepeatable()) { + builder.field(name, Streams.stream(values).collect(toList())); + } else { + Object element = Iterables.getOnlyElement(values, ""); + if (shouldAddElement(element)) { + builder.field(name, element); + } + } + } + return builder.endObject().string() + System.lineSeparator(); + } catch (IOException e) { + return e.toString(); + } + } + + private boolean shouldAddElement(Object element) { + return !(element instanceof String) || !((String) element).isEmpty(); + } +}
diff --git a/src/main/java/com/google/gerrit/opensearch/init/InitOpenSearchIndex.java b/src/main/java/com/google/gerrit/opensearch/init/InitOpenSearchIndex.java new file mode 100644 index 0000000..bb53502 --- /dev/null +++ b/src/main/java/com/google/gerrit/opensearch/init/InitOpenSearchIndex.java
@@ -0,0 +1,86 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch.init; + +import com.google.common.collect.Iterables; +import com.google.gerrit.index.IndexType; +import com.google.gerrit.index.SchemaDefinitions; +import com.google.gerrit.pgm.init.api.ConsoleUI; +import com.google.gerrit.pgm.init.api.InitFlags; +import com.google.gerrit.pgm.init.api.InitStep; +import com.google.gerrit.pgm.init.api.Section; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.index.IndexModule; +import com.google.gerrit.server.index.IndexUtils; +import com.google.inject.Inject; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; + +public class InitOpenSearchIndex implements InitStep { + private final ConsoleUI ui; + private final Section index; + private final SitePaths site; + private final InitFlags initFlags; + private final Section gerrit; + private final Section.Factory sections; + + @Inject + InitOpenSearchIndex(ConsoleUI ui, Section.Factory sections, SitePaths site, InitFlags initFlags) { + this.ui = ui; + this.index = sections.get("index", null); + this.gerrit = sections.get("gerrit", null); + this.site = site; + this.initFlags = initFlags; + this.sections = sections; + } + + @Override + public void run() throws IOException { + ui.header("Index"); + IndexType type = + new IndexType( + index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes())); + + Section opensearch = sections.get("opensearch", null); + opensearch.string("Index Prefix", "prefix", "gerrit_"); + opensearch.string("Server", "server", "http://localhost:9200"); + index.string("Result window size", "maxLimit", "10000"); + + if ((site.isNew || isEmptySite()) && type.isLucene()) { + for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) { + IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true); + } + } else { + String message = + String.format( + "\nThe index must be %sbuilt before starting Gerrit:\n" + + " java -jar gerrit.war reindex -d site_path\n", + site.isNew ? "" : "re"); + ui.message(message); + initFlags.autoStart = false; + } + } + + private boolean isEmptySite() { + try (DirectoryStream<Path> files = + Files.newDirectoryStream(site.resolve(gerrit.get("basePath")))) { + return Iterables.isEmpty(files); + } catch (IOException e) { + return true; + } + } +}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md new file mode 100644 index 0000000..3890128 --- /dev/null +++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,5 @@ +# Index backend for Gerrit, based on OpenSearch + +Indexing backend libModule for [Gerrit Code Review](https://gerritcodereview.com) +based on [OpenSearch](https://docs.opensearch.org/latest/). This module is based +on the [index-elasticsearch module](https://gerrit-review.googlesource.com/admin/repos/modules/index-elasticsearch). \ No newline at end of file
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md new file mode 100644 index 0000000..bd46dd0 --- /dev/null +++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,65 @@ +# Build + +This plugin is built with Bazel in-tree build. This plugin depends on the OpenSearch +REST Client for integration with an OpenSearch cluster. The REST client is licensed as +Apache v2 and is compatible with OpenSearch 3.x. See the [OpenSearch Java client +docs](https://opensearch.org/docs/latest/clients/java/) for more information. + +## Build in Gerrit tree + +Create a symbolic link of the repository source to the Gerrit source +tree plugins/index-opensearch directory, and the external_plugin_deps.bzl +dependencies linked to plugins/external_plugin_deps.bzl. + +```sh +git clone https://gerrit.googlesource.com/gerrit +git clone https://gerrit.googlesource.com/modules/index-opensearch +cd gerrit/plugins +ln -s ../../index-opensearch index-opensearch +ln -sf ../../index-opensearch/external_plugin_deps.bzl . +``` + +From the Gerrit source tree issue the command `bazelisk build plugins/index-opensearch`. + +```sh +bazelisk build plugins/index-opensearch +``` + +The libModule jar file is created under `bazel-bin/plugins/index-opensearch/index-opensearch.jar` + +## Integration test + +There are two different ways to run tests for this module. You can either run only the tests +provided by the module or you can run all Gerrit core acceptance tests with the indexing backend set +to this module. + +To run only the tests provided by this plugin: +```sh +bazelisk test plugins/index-opensearch/... +``` + +Gerrit acceptance tests allow the execution with an alternate implementation of +the indexing backend using the `GERRIT_INDEX_MODULE` environment variable. +```sh +bazelisk test --test_env=GERRIT_INDEX_MODULE=com.google.gerrit.opensearch.OpenSearchIndexModule //... +``` + +Note: Integration tests require Docker to be available, as they spin up an OpenSearch container +using Testcontainers. + +## IDE setup + +This project can be imported into the Eclipse IDE. +Add the plugin name to the `CUSTOM_PLUGINS` and to the +`CUSTOM_PLUGINS_TEST_DEPS` set in Gerrit core in +`tools/bzl/plugins.bzl`, and execute: +``` + ./tools/eclipse/project.py +``` + +More information about Bazel can be found in the [Gerrit +documentation](../../../Documentation/dev-bazel.html). + +[Back to @PLUGIN@ documentation index][index] + +[index]: index.html \ No newline at end of file
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md new file mode 100644 index 0000000..fb08ac5 --- /dev/null +++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,113 @@ +# Configuration + +## Section index + +### index.maxLimit + +Maximum limit to allow for search queries. Requesting results above this limit will truncate the +list (but will still set `_more_changes` on result lists). Set to 0 for no limit. This value +should not exceed the `index.max_result_window` value configured on the OpenSearch server. If a +value is not configured during site initialization, defaults to 10000, which is the default value +of `index.max_result_window` in OpenSearch. + +### index.paginationType + +The pagination type to use when index queries are repeated to obtain the next set of results. +Supported values are: `OFFSET` and `SEARCH_AFTER`. For more information, refer to +[`index.paginationType`](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#index.paginationType). + +Defaults to `OFFSET`. +Note: paginationType `NONE` is not supported and Gerrit will not start if it is configured (results +in `ProvisionException`). + +## Section opensearch + +Note that when Gerrit is configured to use OpenSearch, the OpenSearch +server(s) must be reachable during the site initialization. + +### opensearch.prefix + +This setting can be used to prefix index names to allow multiple Gerrit instances in a single +OpenSearch cluster. Prefix `gerrit1_` would result in a change index named +`gerrit1_changes_0001`. + +Not set by default. + +### opensearch.server + +OpenSearch server URI in the form `http[s]://hostname:port`. The `port` is optional and defaults +to `9200` if not specified. + +At least one server must be specified. May be specified multiple times to configure multiple +OpenSearch servers. + +Note that the site initialization program only allows to configure a single +server. To configure multiple servers the `gerrit.config` file must be edited +manually. + +### opensearch.numberOfShards + +Sets the number of shards to use per index. Refer to the +[OpenSearch documentation](https://opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index-settings/) for details. + +Defaults to 1. + +### opensearch.numberOfReplicas + +Sets the number of replicas to use per index. Refer to the +[OpenSearch documentation](https://opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index-settings/) for details. + +Defaults to 1. + +### opensearch.maxResultWindow + +Sets the maximum value of `from + size` for searches to use per index. Refer to the +[OpenSearch documentation](https://opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index-settings/) for details. + +Defaults to 10000. + +### opensearch.connectTimeout + +Sets the timeout for connecting to OpenSearch. + +Defaults to `1 second`. + +### opensearch.socketTimeout + +Sets the timeout for the underlying connection. For more information, refer to +[`httpd.idleTimeout`](https://gerrit-documentation.storage.googleapis.com/Documentation/3.5.2/config-gerrit.html#httpd.idleTimeout). + +Defaults to `30 seconds`. + +## OpenSearch Security + +When security is enabled in OpenSearch, the username and password must be provided. Note that +the same username and password are used for all servers. + +OpenSearch enables the security plugin by default. For further information about OpenSearch +security, please refer to +[the documentation](https://opensearch.org/docs/latest/security/configuration/index/). + +### opensearch.username + +Username used to connect to OpenSearch. + +If a password is set, defaults to `admin`, otherwise not set by default. + +### opensearch.password + +Password used to connect to OpenSearch. + +Not set by default. + +### opensearch.codec + +Sets the codec to be used for the index data. For further information about supported codecs, +please refer to the static index setting +[index.codec](https://opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index-settings/). + +Defaults to `default`. + +[Back to @PLUGIN@ documentation index][index] + +[index]: index.html \ No newline at end of file
diff --git a/src/main/resources/Documentation/setup.md b/src/main/resources/Documentation/setup.md new file mode 100644 index 0000000..cc1232b --- /dev/null +++ b/src/main/resources/Documentation/setup.md
@@ -0,0 +1,26 @@ +# Setup + +* Install index-opensearch module + +Install the index-opensearch.jar into the `$GERRIT_SITE/lib` directory. + +Add the index-opensearch module to `$GERRIT_SITE/etc/gerrit.config` as follows: + +```ini +[gerrit] + installIndexModule = com.google.gerrit.opensearch.OpenSearchIndexModule +``` + +When installing the module on Gerrit replicas, use following example: + +```ini +[gerrit] + installIndexModule = com.google.gerrit.opensearch.ReplicaOpenSearchIndexModule +``` + +For further information and supported options, refer to [config](config.html) +documentation. + +[Back to @PLUGIN@ documentation index][index] + +[index]: index.html
diff --git a/src/test/java/com/google/gerrit/opensearch/Container.java b/src/test/java/com/google/gerrit/opensearch/Container.java new file mode 100644 index 0000000..71c3dd5 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/Container.java
@@ -0,0 +1,60 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.common.flogger.FluentLogger; +import java.net.URISyntaxException; +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.testcontainers.OpenSearchContainer; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.DockerImageName; + +public class Container extends OpenSearchContainer<Container> { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public static Container createAndStart(OpenSearchVersion version) { + Container container = new Container(version); + try { + container.start(); + } catch (ContainerLaunchException e) { + logger.atSevere().log( + "Failed to launch OpenSearch container. Logs from container:\n%s", container.getLogs()); + throw e; + } + return container; + } + + private static DockerImageName getImageName(OpenSearchVersion version) { + DockerImageName image = DockerImageName.parse("opensearchproject/opensearch"); + switch (version) { + case V3: + return image.withTag("3.5.0"); + } + throw new IllegalStateException("No tests for version: " + version.name()); + } + + private Container(OpenSearchVersion version) { + super(getImageName(version)); + withEnv("action.destructive_requires_name", "false"); + } + + public HttpHost getHttpHost() { + try { + return HttpHost.create(getHttpHostAddress()); + } catch (URISyntaxException e) { + throw new IllegalStateException("Invalid OpenSearch host address", e); + } + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java b/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java new file mode 100644 index 0000000..2405480 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java
@@ -0,0 +1,62 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import javax.net.ssl.SSLContext; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.ssl.SSLContextBuilder; + +@Singleton +class ContainerRestClientProvider extends RestClientProvider { + private final Container container; + + @Inject + ContainerRestClientProvider(OpenSearchConfiguration cfg, Container container) { + super(cfg); + this.container = container; + } + + @Override + protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) { + if (container.isSecurityEnabled()) { + try { + SSLContext sslContext = + SSLContextBuilder.create().loadTrustMaterial(null, (chains, authType) -> true).build(); + TlsStrategy tlsStrategy = + ClientTlsStrategyBuilder.create() + .setSslContext(sslContext) + .setTlsDetailsFactory( + sslEngine -> + new TlsDetails(sslEngine.getSession(), sslEngine.getApplicationProtocol())) + .build(); + PoolingAsyncClientConnectionManager connectionManager = + PoolingAsyncClientConnectionManagerBuilder.create().setTlsStrategy(tlsStrategy).build(); + httpClientBuilder.setConnectionManager(connectionManager); + } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { + throw new IllegalStateException("Failed to create SSL context for test container", e); + } + } + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryAccountsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryAccountsTest.java new file mode 100644 index 0000000..79c5abe --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryAccountsTest.java
@@ -0,0 +1,87 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.server.index.account.AccountIndexDefinition; +import com.google.gerrit.server.query.account.AbstractQueryAccountsTest; +import com.google.gerrit.testing.ConfigSuite; +import com.google.inject.Inject; +import com.google.inject.Injector; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.eclipse.jgit.lib.Config; +import org.junit.AfterClass; +import org.junit.Test; + +public abstract class OpenSearchAbstractQueryAccountsTest extends AbstractQueryAccountsTest { + @ConfigSuite.Default + public static Config defaultConfig() { + return OpenSearchTestUtils.createConfig(); + } + + @ConfigSuite.Config + public static Config searchAfterPaginationType() { + Config config = defaultConfig(); + config.setString("index", null, "paginationType", "SEARCH_AFTER"); + return config; + } + + private static Container container; + private static CloseableHttpClient client; + + protected static void startIndexService(OpenSearchVersion version) { + container = Container.createAndStart(version); + client = OpenSearchTestUtils.createHttpClient(container); + } + + @AfterClass + public static void stopOpenSearchServer() { + if (container != null) { + container.stop(); + } + } + + @Inject private AccountIndexDefinition accountIndexDefinition; + + @Override + protected void initAfterLifecycleStart() throws Exception { + super.initAfterLifecycleStart(); + OpenSearchTestUtils.createAllIndexes(injector); + } + + @Override + protected Injector createInjector() { + return OpenSearchTestUtils.createInjector(config, testName, container); + } + + @Test + public void testErrorResponseFromAccountIndex() throws Exception { + gApi.accounts().self().index(); + + OpenSearchTestUtils.closeIndex(client, container, testName); + StorageException thrown = + assertThrows(StorageException.class, () -> gApi.accounts().self().index()); + assertThat(thrown).hasMessageThat().contains("Failed to replace account"); + } + + @Test + public void testNumDocs() throws Exception { + assertThat(accountIndexDefinition.getIndexCollection().getSearchIndex().numDocs()) + .isGreaterThan(-1); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryChangesTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryChangesTest.java new file mode 100644 index 0000000..ebb0229 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryChangesTest.java
@@ -0,0 +1,105 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.server.index.change.ChangeIndexDefinition; +import com.google.gerrit.server.query.change.AbstractQueryChangesTest; +import com.google.gerrit.testing.ConfigSuite; +import com.google.gerrit.testing.GerritTestName; +import com.google.inject.Inject; +import com.google.inject.Injector; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Repository; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Rule; +import org.junit.Test; + +public abstract class OpenSearchAbstractQueryChangesTest extends AbstractQueryChangesTest { + @ConfigSuite.Default + public static Config defaultConfig() { + return OpenSearchTestUtils.createConfig(); + } + + @ConfigSuite.Config + public static Config searchAfterPaginationType() { + Config config = defaultConfig(); + config.setString("index", null, "paginationType", "SEARCH_AFTER"); + return config; + } + + private static Container container; + private static CloseableHttpClient client; + + protected static void startIndexService(OpenSearchVersion version) { + container = Container.createAndStart(version); + client = OpenSearchTestUtils.createHttpClient(container); + } + + @AfterClass + public static void stopOpenSearchServer() { + if (container != null) { + container.stop(); + } + } + + @Rule public final GerritTestName testName = new GerritTestName(); + @Inject private ChangeIndexDefinition changeIndexDefinition; + + @After + public void closeIndex() throws Exception { + OpenSearchTestUtils.closeIndex(client, container, testName); + } + + @Override + protected void initAfterLifecycleStart() throws Exception { + super.initAfterLifecycleStart(); + OpenSearchTestUtils.createAllIndexes(injector); + } + + @Override + protected Injector createInjector() { + return OpenSearchTestUtils.createInjector(config, testName, container); + } + + @Test + public void testErrorResponseFromChangeIndex() throws Exception { + Project.NameKey project = Project.nameKey("repo"); + TestRepository<Repository> repo = createAndOpenProject(project); + Change c = insert(project, newChangeWithStatus(repo, Change.Status.NEW)); + gApi.changes().id(c.getProject().get(), c.getChangeId()).index(); + + OpenSearchTestUtils.closeIndex(client, container, testName); + StorageException thrown = + assertThrows( + StorageException.class, + () -> gApi.changes().id(c.getProject().get(), c.getChangeId()).index()); + assertThat(thrown).hasMessageThat().contains("Failed to reindex change"); + } + + @Test + public void testNumDocs() throws Exception { + assertThat(changeIndexDefinition.getIndexCollection().getSearchIndex().numDocs()) + .isGreaterThan(-1); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryGroupsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryGroupsTest.java new file mode 100644 index 0000000..6a85e64 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryGroupsTest.java
@@ -0,0 +1,88 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.extensions.api.groups.GroupApi; +import com.google.gerrit.server.index.group.GroupIndexDefinition; +import com.google.gerrit.server.query.group.AbstractQueryGroupsTest; +import com.google.gerrit.testing.ConfigSuite; +import com.google.inject.Inject; +import com.google.inject.Injector; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.eclipse.jgit.lib.Config; +import org.junit.AfterClass; +import org.junit.Test; + +public abstract class OpenSearchAbstractQueryGroupsTest extends AbstractQueryGroupsTest { + @ConfigSuite.Default + public static Config defaultConfig() { + return OpenSearchTestUtils.createConfig(); + } + + @ConfigSuite.Config + public static Config searchAfterPaginationType() { + Config config = defaultConfig(); + config.setString("index", null, "paginationType", "SEARCH_AFTER"); + return config; + } + + private static Container container; + private static CloseableHttpClient client; + + protected static void startIndexService(OpenSearchVersion version) { + container = Container.createAndStart(version); + client = OpenSearchTestUtils.createHttpClient(container); + } + + @AfterClass + public static void stopOpenSearchServer() { + if (container != null) { + container.stop(); + } + } + + @Inject private GroupIndexDefinition groupIndexDefinition; + + @Override + protected void initAfterLifecycleStart() throws Exception { + super.initAfterLifecycleStart(); + OpenSearchTestUtils.createAllIndexes(injector); + } + + @Override + protected Injector createInjector() { + return OpenSearchTestUtils.createInjector(config, testName, container); + } + + @Test + public void testErrorResponseFromGroupIndex() throws Exception { + GroupApi group = gApi.groups().create("test"); + group.index(); + + OpenSearchTestUtils.closeIndex(client, container, testName); + StorageException thrown = assertThrows(StorageException.class, () -> group.index()); + assertThat(thrown).hasMessageThat().contains("Failed to replace group"); + } + + @Test + public void testNumDocs() throws Exception { + assertThat(groupIndexDefinition.getIndexCollection().getSearchIndex().numDocs()) + .isGreaterThan(-1); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryProjectsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryProjectsTest.java new file mode 100644 index 0000000..bec0084 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchAbstractQueryProjectsTest.java
@@ -0,0 +1,88 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.extensions.api.projects.ProjectApi; +import com.google.gerrit.server.index.project.ProjectIndexDefinition; +import com.google.gerrit.server.query.project.AbstractQueryProjectsTest; +import com.google.gerrit.testing.ConfigSuite; +import com.google.inject.Inject; +import com.google.inject.Injector; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.eclipse.jgit.lib.Config; +import org.junit.AfterClass; +import org.junit.Test; + +public abstract class OpenSearchAbstractQueryProjectsTest extends AbstractQueryProjectsTest { + @ConfigSuite.Default + public static Config defaultConfig() { + return OpenSearchTestUtils.createConfig(); + } + + @ConfigSuite.Config + public static Config searchAfterPaginationType() { + Config config = defaultConfig(); + config.setString("index", null, "paginationType", "SEARCH_AFTER"); + return config; + } + + private static Container container; + private static CloseableHttpClient client; + + protected static void startIndexService(OpenSearchVersion version) { + container = Container.createAndStart(version); + client = OpenSearchTestUtils.createHttpClient(container); + } + + @AfterClass + public static void stopOpenSearchServer() { + if (container != null) { + container.stop(); + } + } + + @Inject private ProjectIndexDefinition projectIndexDefinition; + + @Override + protected void initAfterLifecycleStart() throws Exception { + super.initAfterLifecycleStart(); + OpenSearchTestUtils.createAllIndexes(injector); + } + + @Override + protected Injector createInjector() { + return OpenSearchTestUtils.createInjector(config, testName, container); + } + + @Test + public void testErrorResponseFromProjectIndex() throws Exception { + ProjectApi project = gApi.projects().create("test"); + project.index(false); + + OpenSearchTestUtils.closeIndex(client, container, testName); + StorageException thrown = assertThrows(StorageException.class, () -> project.index(false)); + assertThat(thrown).hasMessageThat().contains("Failed to replace project"); + } + + @Test + public void testNumDocs() throws Exception { + assertThat(projectIndexDefinition.getIndexCollection().getSearchIndex().numDocs()) + .isGreaterThan(-1); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java new file mode 100644 index 0000000..02f228a --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java
@@ -0,0 +1,141 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.DEFAULT_USERNAME; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_PASSWORD; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_PREFIX; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_SERVER; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_USERNAME; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.SECTION_OPENSEARCH; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; +import static java.util.stream.Collectors.toList; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.index.IndexConfig; +import com.google.inject.ProvisionException; +import java.util.Arrays; +import org.apache.hc.core5.http.HttpHost; +import org.eclipse.jgit.lib.Config; +import org.junit.Test; + +public class OpenSearchConfigurationTest { + @Test + public void singleServerNoOtherConfig() throws Exception { + Config cfg = newConfig(); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertHosts(esCfg, "http://open:1234"); + assertThat(esCfg.username).isNull(); + assertThat(esCfg.password).isNull(); + assertThat(esCfg.prefix).isEmpty(); + } + + @Test + public void serverWithoutPortSpecified() throws Exception { + Config cfg = new Config(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "http://open"); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertHosts(esCfg, "http://open:9200"); + } + + @Test + public void prefix() throws Exception { + Config cfg = newConfig(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_PREFIX, "myprefix"); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertThat(esCfg.prefix).isEqualTo("myprefix"); + } + + @Test + public void withAuthentication() throws Exception { + Config cfg = newConfig(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_USERNAME, "myself"); + cfg.setString(SECTION_OPENSEARCH, null, KEY_PASSWORD, "s3kr3t"); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertThat(esCfg.username).isEqualTo("myself"); + assertThat(esCfg.password).isEqualTo("s3kr3t"); + } + + @Test + public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception { + Config cfg = newConfig(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_PASSWORD, "s3kr3t"); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME); + assertThat(esCfg.password).isEqualTo("s3kr3t"); + } + + @Test + public void multipleServers() throws Exception { + Config cfg = new Config(); + cfg.setStringList( + SECTION_OPENSEARCH, + null, + KEY_SERVER, + ImmutableList.of("http://open1:1234", "http://open2:1234")); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertHosts(esCfg, "http://open1:1234", "http://open2:1234"); + } + + @Test + public void noServers() throws Exception { + assertProvisionException(new Config(), "No valid OpenSearch servers configured"); + } + + @Test + public void singleServerInvalid() throws Exception { + Config cfg = new Config(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "foo"); + assertProvisionException(cfg, "No valid OpenSearch servers configured"); + } + + @Test + public void multipleServersIncludingInvalid() throws Exception { + Config cfg = new Config(); + cfg.setStringList( + SECTION_OPENSEARCH, null, KEY_SERVER, ImmutableList.of("http://open1:1234", "foo")); + OpenSearchConfiguration esCfg = newOpenConfig(cfg); + assertHosts(esCfg, "http://open1:1234"); + } + + @Test + public void unsupportedPaginationTypeNone() { + Config cfg = new Config(); + cfg.setString("index", null, "paginationType", "NONE"); + assertProvisionException( + cfg, "The 'index.paginationType = NONE' configuration is not supported by OpenSearch"); + } + + private static Config newConfig() { + Config config = new Config(); + config.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "http://open:1234"); + return config; + } + + private static OpenSearchConfiguration newOpenConfig(Config cfg) { + return new OpenSearchConfiguration(cfg, IndexConfig.fromConfig(cfg).build()); + } + + private void assertHosts(OpenSearchConfiguration cfg, Object... hostURIs) throws Exception { + assertThat(Arrays.asList(cfg.getHosts()).stream().map(HttpHost::toURI).collect(toList())) + .containsExactly(hostURIs); + } + + private void assertProvisionException(Config cfg, String msg) { + ProvisionException thrown = assertThrows(ProvisionException.class, () -> newOpenConfig(cfg)); + assertThat(thrown).hasMessageThat().contains(msg); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java new file mode 100644 index 0000000..fffd003 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java
@@ -0,0 +1,130 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertWithMessage; + +import com.google.gerrit.index.IndexDefinition; +import com.google.gerrit.server.LibModuleType; +import com.google.gerrit.testing.GerritTestName; +import com.google.gerrit.testing.InMemoryModule; +import com.google.gerrit.testing.IndexConfig; +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import java.util.Collection; +import java.util.UUID; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.eclipse.jgit.lib.Config; + +public final class OpenSearchTestUtils { + private static final String OPENSEARCH_USERNAME = "admin"; + + public static void configure(Config config, Container container, String prefix) { + config.setString("index", null, "type", "opensearch"); + config.setString("opensearch", null, "server", container.getHttpHost().toURI()); + config.setString("opensearch", null, "prefix", prefix); + config.setInt("index", null, "maxLimit", 10000); + if (container.isSecurityEnabled()) { + config.setString("opensearch", null, "username", OPENSEARCH_USERNAME); + config.setString("opensearch", null, "password", container.getPassword()); + } + } + + public static void createAllIndexes(Injector injector) { + Collection<IndexDefinition<?, ?, ?>> indexDefs = + injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {})); + for (IndexDefinition<?, ?, ?> indexDef : indexDefs) { + indexDef.getIndexCollection().getSearchIndex().deleteAll(); + } + } + + public static Config getConfig(OpenSearchVersion version) { + Container container = Container.createAndStart(version); + String indicesPrefix = UUID.randomUUID().toString(); + Config cfg = new Config(); + configure(cfg, container, indicesPrefix); + return cfg; + } + + public static Config createConfig() { + Config cfg = IndexConfig.create(); + return cfg; + } + + public static void configureOpenSearchModule(Config openSearchConfig) { + openSearchConfig.setString( + "index", + null, + "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey(), + "com.google.gerrit.opensearch.OpenSearchIndexModule"); + } + + public static class ContainerTestModule extends AbstractModule { + private final Container container; + + ContainerTestModule(Container container) { + this.container = container; + } + + @Override + protected void configure() { + bind(RestClientProvider.class).to(ContainerRestClientProvider.class); + bind(Container.class).toInstance(container); + } + } + + public static Injector createInjector( + Config config, GerritTestName testName, Container container) { + Config opensearchConfig = new Config(config); + OpenSearchTestUtils.configureOpenSearchModule(opensearchConfig); + InMemoryModule.setDefaults(opensearchConfig); + String indicesPrefix = testName.getSanitizedMethodName(); + OpenSearchTestUtils.configure(opensearchConfig, container, indicesPrefix); + return Guice.createInjector( + new ContainerTestModule(container), new InMemoryModule(opensearchConfig)); + } + + public static CloseableHttpClient createHttpClient(Container container) { + return HttpClients.custom().build(); + } + + public static void closeIndex( + CloseableHttpClient client, Container container, GerritTestName testName) throws Exception { + ClassicHttpResponse response = + client.execute( + new HttpPost( + String.format( + "%s/%s*/_close", + container.getHttpHost().toURI(), testName.getSanitizedMethodName()))); + int statusCode = response.getCode(); + assertWithMessage( + "response status code should be %s, but was %s. Full response was %s", + HttpStatus.SC_OK, statusCode, EntityUtils.toString(response.getEntity())) + .that(statusCode) + .isEqualTo(HttpStatus.SC_OK); + } + + private OpenSearchTestUtils() { + // hide default constructor + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryAccountsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryAccountsTest.java new file mode 100644 index 0000000..e408399 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryAccountsTest.java
@@ -0,0 +1,24 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import org.junit.BeforeClass; + +public class OpenSearchV3QueryAccountsTest extends OpenSearchAbstractQueryAccountsTest { + @BeforeClass + public static void startIndexService() { + startIndexService(OpenSearchVersion.V3); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryChangesTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryChangesTest.java new file mode 100644 index 0000000..60006a3 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryChangesTest.java
@@ -0,0 +1,24 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import org.junit.BeforeClass; + +public class OpenSearchV3QueryChangesTest extends OpenSearchAbstractQueryChangesTest { + @BeforeClass + public static void startIndexService() { + startIndexService(OpenSearchVersion.V3); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryGroupsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryGroupsTest.java new file mode 100644 index 0000000..cbcefd2 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryGroupsTest.java
@@ -0,0 +1,24 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import org.junit.BeforeClass; + +public class OpenSearchV3QueryGroupsTest extends OpenSearchAbstractQueryGroupsTest { + @BeforeClass + public static void startIndexService() { + startIndexService(OpenSearchVersion.V3); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryProjectsTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryProjectsTest.java new file mode 100644 index 0000000..37d1480 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchV3QueryProjectsTest.java
@@ -0,0 +1,24 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import org.junit.BeforeClass; + +public class OpenSearchV3QueryProjectsTest extends OpenSearchAbstractQueryProjectsTest { + @BeforeClass + public static void startIndexService() { + startIndexService(OpenSearchVersion.V3); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchVersionTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchVersionTest.java new file mode 100644 index 0000000..f2aa306 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchVersionTest.java
@@ -0,0 +1,65 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import org.junit.Test; + +public class OpenSearchVersionTest { + @Test + public void supportedVersion() throws Exception { + // Pre-release formats + assertThat(OpenSearchVersion.forVersion("3.0.0-alpha1")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.0.0-beta1")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.0.0-rc1")).isEqualTo(OpenSearchVersion.V3); + + // Patch releases + assertThat(OpenSearchVersion.forVersion("3.0.0")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.0.10")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.1.0")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.1.1")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.2.0")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.3.0")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.4.0")).isEqualTo(OpenSearchVersion.V3); + assertThat(OpenSearchVersion.forVersion("3.5.0")).isEqualTo(OpenSearchVersion.V3); + } + + @Test + public void unsupportedVersion() throws Exception { + // 2.x is explicitly unsupported — we require 3.x+ + OpenSearchVersion.UnsupportedVersion thrown = + assertThrows( + OpenSearchVersion.UnsupportedVersion.class, + () -> OpenSearchVersion.forVersion("2.19.1")); + assertThat(thrown) + .hasMessageThat() + .contains( + "Unsupported version: [2.19.1]. Supported versions: " + + OpenSearchVersion.supportedVersions()); + + // Completely unrelated version + thrown = + assertThrows( + OpenSearchVersion.UnsupportedVersion.class, + () -> OpenSearchVersion.forVersion("1.0.0")); + assertThat(thrown) + .hasMessageThat() + .contains( + "Unsupported version: [1.0.0]. Supported versions: " + + OpenSearchVersion.supportedVersions()); + } +}