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());
+  }
+}