Merge branch 'stable-2.15'

* stable-2.15:
  GroupField: Change UUID fields' type to KEYWORD
  Add keyword type to index type system
  Elasticsearch: Encapsulate supported versions in an enum
  setup_gjf.sh: amend SHA1 for GJF 1.6
  Elasticsearch: Tidy up Javadoc in builders package
  setup_gjf.sh: Add support for google-java-format 1.6
  ElasticRestClientProvider: Detect Elasticsearch version
  Convert ElasticRestClientBuilder to a provider
  WorkQueue: rename prefix to queueName
  Remove outdated Elasticsearch/Lucene comments from WORKSPACE
  AbstractElasticIndex: Move generation of index name to ElasticConfiguration

ElasticProjectIndex is adapted to the changes done in:

- Ie4696b4d5 (AbstractElasticIndex: Move generation of index name to ElasticConfiguration)
- I4747114e2 (Convert ElasticRestClientBuilder to a provider)

Change-Id: I5ee243cb696c5e3e14b8c881950eb27000efce5c
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
deleted file mode 100644
index 37207ff..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2018 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.acceptance.pgm;
-
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.elasticsearch.ElasticContainer;
-import com.google.gerrit.elasticsearch.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Injector;
-import java.util.UUID;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.Ignore;
-
-@NoHttpd
-@Ignore
-public class ElasticReindexIT extends AbstractReindexTests {
-  private static ElasticContainer<?> container;
-
-  @ConfigSuite.Default
-  public static Config elasticsearch() {
-    ElasticNodeInfo elasticNodeInfo;
-    try {
-      container = ElasticContainer.createAndStart();
-      elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    } catch (Throwable t) {
-      return null;
-    }
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix);
-    return cfg;
-  }
-
-  @Override
-  public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @After
-  public void stopElasticServer() {
-    if (container != null) {
-      container.stop();
-      container = null;
-    }
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
deleted file mode 100644
index 9d25308..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ /dev/null
@@ -1,209 +0,0 @@
-// Copyright (C) 2014 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.elasticsearch;
-
-import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.io.CharStreams;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-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.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpStatus;
-import org.apache.http.entity.ContentType;
-import org.apache.http.nio.entity.NStringEntity;
-import org.elasticsearch.client.Response;
-
-abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
-  protected static final String BULK = "_bulk";
-  protected static final String IGNORE_UNMAPPED = "ignore_unmapped";
-  protected static final String ORDER = "order";
-  protected static final String SEARCH = "_search";
-
-  protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
-    JsonArray field = doc.getAsJsonArray(fieldName);
-    if (field == null) {
-      return null;
-    }
-    return FluentIterable.from(field)
-        .transform(i -> codec.decode(decodeBase64(i.toString())))
-        .toList();
-  }
-
-  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)) {
-        content = CharStreams.toString(reader);
-      }
-    }
-    return content;
-  }
-
-  private final Schema<V> schema;
-  private final SitePaths sitePaths;
-  private final String indexNameRaw;
-  private final ElasticRestClientProvider client;
-
-  protected final String indexName;
-  protected final Gson gson;
-  protected final ElasticQueryBuilder queryBuilder;
-
-  AbstractElasticIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Schema<V> schema,
-      ElasticRestClientProvider client,
-      String indexName) {
-    this.sitePaths = sitePaths;
-    this.schema = schema;
-    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
-    this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName = cfg.getIndexName(indexName, schema.getVersion());
-    this.indexNameRaw = indexName;
-    this.client = client;
-  }
-
-  @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) throws IOException {
-    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void delete(K c) throws IOException {
-    String uri = getURI(indexNameRaw, BULK);
-    Response response = postRequest(addActions(c), uri, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
-          String.format("Failed to delete %s from index %s: %s", c, indexName, statusCode));
-    }
-  }
-
-  @Override
-  public void deleteAll() throws IOException {
-    // Delete the index, if it exists.
-    Response response = client.get().performRequest("HEAD", indexName);
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode == HttpStatus.SC_OK) {
-      response = client.get().performRequest("DELETE", indexName);
-      statusCode = response.getStatusLine().getStatusCode();
-      if (statusCode != HttpStatus.SC_OK) {
-        throw new IOException(
-            String.format("Failed to delete index %s: %s", indexName, statusCode));
-      }
-    }
-
-    // Recreate the index.
-    response = performRequest("PUT", getMappings(), indexName, Collections.emptyMap());
-    statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
-      throw new IOException(error);
-    }
-  }
-
-  protected abstract String addActions(K c);
-
-  protected abstract String getMappings();
-
-  protected abstract String getId(V v);
-
-  protected String delete(String type, K c) {
-    String id = c.toString();
-    return new DeleteRequest(id, indexNameRaw, type).toString();
-  }
-
-  protected void addNamedElement(String name, JsonObject element, JsonArray array) {
-    JsonObject arrayElement = new JsonObject();
-    arrayElement.add(name, element);
-    array.add(arrayElement);
-  }
-
-  protected Map<String, String> getRefreshParam() {
-    Map<String, String> params = new HashMap<>();
-    params.put("refresh", "true");
-    return params;
-  }
-
-  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
-    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
-    search.add("sort", sortArray);
-    return gson.toJson(search);
-  }
-
-  protected JsonArray getSortArray(String idFieldName) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "asc");
-    properties.addProperty(IGNORE_UNMAPPED, true);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(idFieldName, properties, sortArray);
-    return sortArray;
-  }
-
-  protected String getURI(String type, String request) throws UnsupportedEncodingException {
-    String encodedType = URLEncoder.encode(type, UTF_8.toString());
-    String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
-    return encodedIndexName + "/" + encodedType + "/" + request;
-  }
-
-  protected Response postRequest(Object payload, String uri, Map<String, String> params)
-      throws IOException {
-    return performRequest("POST", payload, uri, params);
-  }
-
-  private Response performRequest(
-      String method, Object payload, String uri, Map<String, String> params) throws IOException {
-    String payloadStr = payload instanceof String ? (String) payload : payload.toString();
-    HttpEntity entity = new NStringEntity(payloadStr, ContentType.APPLICATION_JSON);
-    return client.get().performRequest(method, uri, params, entity);
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
deleted file mode 100644
index 28fc181..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,208 +0,0 @@
-// Copyright (C) 2016 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.elasticsearch;
-
-import static com.google.gerrit.server.index.account.AccountField.ID;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-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.reviewdb.client.Account;
-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.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Response;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  public static class AccountMapping {
-    MappingProperties accounts;
-
-    public AccountMapping(Schema<AccountState> schema) {
-      this.accounts = ElasticMapping.createMapping(schema);
-    }
-  }
-
-  public static final String ACCOUNTS = "accounts";
-
-  private static final Logger log = LoggerFactory.getLogger(ElasticAccountIndex.class);
-
-  private final AccountMapping mapping;
-  private final Provider<AccountCache> accountCache;
-  private final Schema<AccountState> schema;
-
-  @AssistedInject
-  ElasticAccountIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<AccountState> schema) {
-    super(cfg, sitePaths, schema, client, ACCOUNTS);
-    this.accountCache = accountCache;
-    this.mapping = new AccountMapping(schema);
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(AccountState as) throws IOException {
-    BulkRequest bulk =
-        new IndexRequest(getId(as), indexName, ACCOUNTS).add(new UpdateRequest<>(schema, as));
-
-    String uri = getURI(ACCOUNTS, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
-          String.format(
-              "Failed to replace account %s in index %s: %s",
-              as.getAccount().getId(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    return new QuerySource(p, opts);
-  }
-
-  @Override
-  protected String addActions(Account.Id c) {
-    return delete(ACCOUNTS, c);
-  }
-
-  @Override
-  protected String getMappings() {
-    ImmutableMap<String, AccountMapping> mappings = ImmutableMap.of("mappings", mapping);
-    return gson.toJson(mappings);
-  }
-
-  @Override
-  protected String getId(AccountState as) {
-    return as.getAccount().getId().toString();
-  }
-
-  private class QuerySource implements DataSource<AccountState> {
-    private final String search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<AccountState> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.accountFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      JsonArray sortArray = getSortArray(AccountField.ID.getName());
-      search = getSearch(searchSource, sortArray);
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<AccountState> read() throws OrmException {
-      try {
-        List<AccountState> results = Collections.emptyList();
-        String uri = getURI(ACCOUNTS, SEARCH);
-        Response response = postRequest(search, uri, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toAccountState(json.get(i)));
-            }
-          }
-        } else {
-          log.error(statusLine.getReasonPhrase());
-        }
-        final List<AccountState> r = Collections.unmodifiableList(results);
-        return new ResultSet<AccountState>() {
-          @Override
-          public Iterator<AccountState> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<AccountState> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    private AccountState toAccountState(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.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.
-      return accountCache.get().get(id);
-    }
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
deleted file mode 100644
index f5a9bc0..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,447 +0,0 @@
-// Copyright (C) 2014 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.elasticsearch;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-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.change.ChangeIndexRewriter;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import org.apache.commons.codec.binary.Base64;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Response;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Secondary index implementation using Elasticsearch. */
-class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  private static final Logger log = LoggerFactory.getLogger(ElasticChangeIndex.class);
-
-  static class ChangeMapping {
-    MappingProperties openChanges;
-    MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema);
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  static final String CHANGES = "changes";
-  static final String OPEN_CHANGES = "open_" + CHANGES;
-  static final String CLOSED_CHANGES = "closed_" + CHANGES;
-
-  private final ChangeMapping mapping;
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final Schema<ChangeData> schema;
-
-  @Inject
-  ElasticChangeIndex(
-      ElasticConfiguration cfg,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      SitePaths sitePaths,
-      ElasticRestClientProvider client,
-      @Assisted Schema<ChangeData> schema) {
-    super(cfg, sitePaths, schema, client, CHANGES);
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-    mapping = new ChangeMapping(schema);
-  }
-
-  @Override
-  public void replace(ChangeData cd) throws IOException {
-    String deleteIndex;
-    String insertIndex;
-
-    try {
-      if (cd.change().getStatus().isOpen()) {
-        insertIndex = OPEN_CHANGES;
-        deleteIndex = CLOSED_CHANGES;
-      } else {
-        insertIndex = CLOSED_CHANGES;
-        deleteIndex = OPEN_CHANGES;
-      }
-    } catch (OrmException e) {
-      throw new IOException(e);
-    }
-
-    BulkRequest bulk =
-        new IndexRequest(getId(cd), indexName, insertIndex)
-            .add(new UpdateRequest<>(schema, cd))
-            .add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex));
-
-    String uri = getURI(CHANGES, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
-          String.format(
-              "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
-    List<String> indexes = Lists.newArrayListWithCapacity(2);
-    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
-      indexes.add(OPEN_CHANGES);
-    }
-    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-      indexes.add(CLOSED_CHANGES);
-    }
-    return new QuerySource(indexes, p, opts);
-  }
-
-  @Override
-  protected String addActions(Id c) {
-    return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
-  }
-
-  @Override
-  protected String getMappings() {
-    return gson.toJson(ImmutableMap.of("mappings", mapping));
-  }
-
-  @Override
-  protected String getId(ChangeData cd) {
-    return cd.getId().toString();
-  }
-
-  private class QuerySource implements ChangeDataSource {
-    private final String search;
-    private final Set<String> fields;
-    private final List<String> types;
-
-    QuerySource(List<String> types, Predicate<ChangeData> p, QueryOptions opts)
-        throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.changeFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      search = getSearch(searchSource, getSortArray());
-      this.types = types;
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      try {
-        List<ChangeData> results = Collections.emptyList();
-        String uri = getURI(types);
-        Response response = postRequest(search, uri, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toChangeData(json.get(i)));
-            }
-          }
-        } else {
-          log.error(statusLine.getReasonPhrase());
-        }
-        final List<ChangeData> r = Collections.unmodifiableList(results);
-        return new ResultSet<ChangeData>() {
-          @Override
-          public Iterator<ChangeData> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<ChangeData> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public boolean hasChange() {
-      return false;
-    }
-
-    private ChangeData toChangeData(JsonElement json) {
-      JsonElement sourceElement = json.getAsJsonObject().get("_source");
-      if (sourceElement == null) {
-        sourceElement = json.getAsJsonObject().get("fields");
-      }
-      JsonObject source = sourceElement.getAsJsonObject();
-      JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-      if (c == null) {
-        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
-        // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-        String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-        return changeDataFactory.create(
-            db.get(), new Project.NameKey(projectName), new Change.Id(id));
-      }
-
-      ChangeData cd =
-          changeDataFactory.create(
-              db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
-
-      // Patch sets.
-      cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
-
-      // Approvals.
-      if (source.get(ChangeField.APPROVAL.getName()) != null) {
-        cd.setCurrentApprovals(
-            decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
-      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-        cd.setCurrentApprovals(Collections.emptyList());
-      }
-
-      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-      if (addedElement != null && deletedElement != null) {
-        // Changed lines.
-        int added = addedElement.getAsInt();
-        int deleted = deletedElement.getAsInt();
-        cd.setChangedLines(added, deleted);
-      }
-
-      // Star.
-      JsonElement starredElement = source.get(ChangeField.STAR.getName());
-      if (starredElement != null) {
-        ListMultimap<Account.Id, String> stars =
-            MultimapBuilder.hashKeys().arrayListValues().build();
-        JsonArray starBy = starredElement.getAsJsonArray();
-        if (starBy.size() > 0) {
-          for (int i = 0; i < starBy.size(); i++) {
-            String[] indexableFields = starBy.get(i).getAsString().split(":");
-            Account.Id id = Account.Id.parse(indexableFields[0]);
-            stars.put(id, indexableFields[1]);
-          }
-        }
-        cd.setStars(stars);
-      }
-
-      // Mergeable.
-      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-      if (mergeableElement != null) {
-        String mergeable = mergeableElement.getAsString();
-        if ("1".equals(mergeable)) {
-          cd.setMergeable(true);
-        } else if ("0".equals(mergeable)) {
-          cd.setMergeable(false);
-        }
-      }
-
-      // Reviewed-by.
-      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-        JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-        if (reviewedBy.size() > 0) {
-          Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-          for (int i = 0; i < reviewedBy.size(); i++) {
-            int aId = reviewedBy.get(i).getAsInt();
-            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-              break;
-            }
-            accounts.add(new Account.Id(aId));
-          }
-          cd.setReviewedBy(accounts);
-        }
-      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-        cd.setReviewedBy(Collections.emptySet());
-      }
-
-      if (source.get(ChangeField.REVIEWER.getName()) != null) {
-        cd.setReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-        cd.setReviewers(ReviewerSet.empty());
-      }
-
-      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-
-      if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-        cd.setPendingReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-        cd.setPendingReviewers(ReviewerSet.empty());
-      }
-
-      if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setPendingReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source
-                            .get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())
-                            .getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-        cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-          cd);
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-          cd);
-      decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-      if (fields.contains(ChangeField.REF_STATE.getName())) {
-        cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
-      }
-      if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-        cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-      }
-
-      return cd;
-    }
-
-    private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-      JsonElement element = source.get(name);
-      return element != null
-          ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
-          : Collections.emptyList();
-    }
-
-    private void decodeSubmitRecords(
-        JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-      JsonArray records = doc.getAsJsonArray(fieldName);
-      if (records == null) {
-        return;
-      }
-      ChangeField.parseSubmitRecords(
-          FluentIterable.from(records)
-              .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
-              .toList(),
-          opts,
-          out);
-    }
-
-    private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-      JsonElement count = doc.get(fieldName);
-      if (count == null) {
-        return;
-      }
-      out.setUnresolvedCommentCount(count.getAsInt());
-    }
-
-    private JsonArray getSortArray() {
-      JsonObject properties = new JsonObject();
-      properties.addProperty(ORDER, "desc");
-      properties.addProperty(IGNORE_UNMAPPED, true);
-
-      JsonArray sortArray = new JsonArray();
-      addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
-      addNamedElement(ChangeField.LEGACY_ID.getName(), properties, sortArray);
-      return sortArray;
-    }
-  }
-
-  private String getURI(List<String> types) throws UnsupportedEncodingException {
-    String joinedTypes = String.join(",", types);
-    return getURI(joinedTypes, SEARCH);
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
deleted file mode 100644
index 03a958b..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,207 +0,0 @@
-// Copyright (C) 2017 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.elasticsearch;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-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.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
-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.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Response;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  static class GroupMapping {
-    MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema) {
-      this.groups = ElasticMapping.createMapping(schema);
-    }
-  }
-
-  public static final String GROUPS = "groups";
-
-  private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
-
-  private final GroupMapping mapping;
-  private final Provider<GroupCache> groupCache;
-  private final Schema<InternalGroup> schema;
-
-  @AssistedInject
-  ElasticGroupIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<InternalGroup> schema) {
-    super(cfg, sitePaths, schema, client, GROUPS);
-    this.groupCache = groupCache;
-    this.mapping = new GroupMapping(schema);
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(InternalGroup group) throws IOException {
-    BulkRequest bulk =
-        new IndexRequest(getId(group), indexName, GROUPS).add(new UpdateRequest<>(schema, group));
-
-    String uri = getURI(GROUPS, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
-          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 {
-    return new QuerySource(p, opts);
-  }
-
-  @Override
-  protected String addActions(AccountGroup.UUID c) {
-    return delete(GROUPS, c);
-  }
-
-  @Override
-  protected String getMappings() {
-    ImmutableMap<String, GroupMapping> mappings = ImmutableMap.of("mappings", mapping);
-    return gson.toJson(mappings);
-  }
-
-  @Override
-  protected String getId(InternalGroup group) {
-    return group.getGroupUUID().get();
-  }
-
-  private class QuerySource implements DataSource<InternalGroup> {
-    private final String search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<InternalGroup> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.groupFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      JsonArray sortArray = getSortArray(GroupField.UUID.getName());
-      search = getSearch(searchSource, sortArray);
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<InternalGroup> read() throws OrmException {
-      try {
-        List<InternalGroup> results = Collections.emptyList();
-        String uri = getURI(GROUPS, SEARCH);
-        Response response = postRequest(search, uri, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toAccountGroup(json.get(i)).get());
-            }
-          }
-        } else {
-          log.error(statusLine.getReasonPhrase());
-        }
-        final List<InternalGroup> r = Collections.unmodifiableList(results);
-        return new ResultSet<InternalGroup>() {
-          @Override
-          public Iterator<InternalGroup> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<InternalGroup> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    private Optional<InternalGroup> toAccountGroup(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      AccountGroup.UUID uuid =
-          new AccountGroup.UUID(
-              source.getAsJsonObject().get(GroupField.UUID.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);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
new file mode 100644
index 0000000..320ebfa
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -0,0 +1,359 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.elasticsearch.builders.QueryBuilder;
+import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
+import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.index.FieldDef;
+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.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+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.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+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.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.nio.entity.NStringEntity;
+import org.elasticsearch.client.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final Logger log = LoggerFactory.getLogger(AbstractElasticIndex.class);
+
+  protected static final String BULK = "_bulk";
+  protected static final String IGNORE_UNMAPPED = "ignore_unmapped";
+  protected static final String ORDER = "order";
+  protected static final String SEARCH = "_search";
+
+  protected static <T> List<T> decodeProtos(
+      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
+    JsonArray field = doc.getAsJsonArray(fieldName);
+    if (field == null) {
+      return null;
+    }
+    return FluentIterable.from(field)
+        .transform(i -> codec.decode(decodeBase64(i.toString())))
+        .toList();
+  }
+
+  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)) {
+        content = CharStreams.toString(reader);
+      }
+    }
+    return content;
+  }
+
+  private final Schema<V> schema;
+  private final SitePaths sitePaths;
+  private final String indexNameRaw;
+  private final ElasticRestClientProvider client;
+
+  protected final String indexName;
+  protected final Gson gson;
+  protected final ElasticQueryBuilder queryBuilder;
+
+  AbstractElasticIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Schema<V> schema,
+      ElasticRestClientProvider client,
+      String indexName) {
+    this.sitePaths = sitePaths;
+    this.schema = schema;
+    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.indexName = cfg.getIndexName(indexName, schema.getVersion());
+    this.indexNameRaw = indexName;
+    this.client = client;
+  }
+
+  @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) throws IOException {
+    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void delete(K c) throws IOException {
+    String uri = getURI(indexNameRaw, BULK);
+    Response response = postRequest(addActions(c), uri, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format("Failed to delete %s from index %s: %s", c, indexName, statusCode));
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // Delete the index, if it exists.
+    Response response = client.get().performRequest("HEAD", indexName);
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode == HttpStatus.SC_OK) {
+      response = client.get().performRequest("DELETE", indexName);
+      statusCode = response.getStatusLine().getStatusCode();
+      if (statusCode != HttpStatus.SC_OK) {
+        throw new IOException(
+            String.format("Failed to delete index %s: %s", indexName, statusCode));
+      }
+    }
+
+    // Recreate the index.
+    response = performRequest("PUT", getMappings(), indexName, Collections.emptyMap());
+    statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
+      throw new IOException(error);
+    }
+  }
+
+  protected abstract String addActions(K c);
+
+  protected abstract String getMappings();
+
+  protected abstract String getId(V v);
+
+  protected String delete(String type, K c) {
+    String id = c.toString();
+    return new DeleteRequest(id, indexNameRaw, type).toString();
+  }
+
+  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
+
+  protected FieldBundle toFieldBundle(JsonObject doc) {
+    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
+    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
+    for (Map.Entry<String, JsonElement> element : doc.get("fields").getAsJsonObject().entrySet()) {
+      checkArgument(
+          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
+      FieldType<?> type = allFields.get(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(), Base64.decodeBase64(inner.getAsString()));
+        } else {
+          throw FieldType.badFieldType(type);
+        }
+      }
+    }
+    return new FieldBundle(rawFields);
+  }
+
+  protected String toAction(String type, String id, String action) {
+    JsonObject properties = new JsonObject();
+    properties.addProperty("_id", id);
+    properties.addProperty("_index", indexName);
+    properties.addProperty("_type", type);
+
+    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 Map<String, String> getRefreshParam() {
+    Map<String, String> params = new HashMap<>();
+    params.put("refresh", "true");
+    return params;
+  }
+
+  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
+    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
+    search.add("sort", sortArray);
+    return gson.toJson(search);
+  }
+
+  protected JsonArray getSortArray(String idFieldName) {
+    JsonObject properties = new JsonObject();
+    properties.addProperty(ORDER, "asc");
+    properties.addProperty(IGNORE_UNMAPPED, true);
+
+    JsonArray sortArray = new JsonArray();
+    addNamedElement(idFieldName, properties, sortArray);
+    return sortArray;
+  }
+
+  protected String getURI(String type, String request) throws UnsupportedEncodingException {
+    String encodedType = URLEncoder.encode(type, UTF_8.toString());
+    String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
+    return encodedIndexName + "/" + encodedType + "/" + request;
+  }
+
+  protected Response postRequest(Object payload, String uri, Map<String, String> params)
+      throws IOException {
+    return performRequest("POST", payload, uri, params);
+  }
+
+  private Response performRequest(
+      String method, Object payload, String uri, Map<String, String> params) throws IOException {
+    String payloadStr = payload instanceof String ? (String) payload : payload.toString();
+    HttpEntity entity = new NStringEntity(payloadStr, ContentType.APPLICATION_JSON);
+    return client.get().performRequest(method, uri, params, entity);
+  }
+
+  protected class ElasticQuerySource implements DataSource<V> {
+    private final QueryOptions opts;
+    private final String search;
+    private final String index;
+
+    ElasticQuerySource(Predicate<V> p, QueryOptions opts, String index, JsonArray sortArray)
+        throws QueryParseException {
+      this.opts = opts;
+      this.index = index;
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder()
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(opts.fields()));
+      search = getSearch(searchSource, sortArray);
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<V> read() throws OrmException {
+      return readImpl((doc) -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      return readImpl(AbstractElasticIndex.this::toFieldBundle);
+    }
+
+    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) throws OrmException {
+      try {
+        List<T> results = Collections.emptyList();
+        String uri = getURI(index, SEARCH);
+        Response response =
+            performRequest(HttpPost.METHOD_NAME, search, uri, Collections.emptyMap());
+        StatusLine statusLine = response.getStatusLine();
+        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
+          String content = getContent(response);
+          JsonObject obj =
+              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
+              if (mapperResult != null) {
+                results.add(mapperResult);
+              }
+            }
+          }
+        } else {
+          log.error(statusLine.getReasonPhrase());
+        }
+        final List<T> r = Collections.unmodifiableList(results);
+        return new ResultSet<T>() {
+          @Override
+          public Iterator<T> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<T> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
new file mode 100644
index 0000000..b771cf5
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,30 @@
+java_library(
+    name = "elasticsearch",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/commons:codec",
+        "//lib/commons:lang",
+        "//lib/elasticsearch-rest-client",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
+        "//lib/httpcomponents:httpcore",
+        "//lib/httpcomponents:httpcore-nio",
+        "//lib/jackson:jackson-core",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
new file mode 100644
index 0000000..1abdee9
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 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.elasticsearch;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+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.reviewdb.client.Account;
+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.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.io.IOException;
+import java.util.Set;
+import org.apache.http.HttpStatus;
+import org.elasticsearch.client.Response;
+
+public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  static class AccountMapping {
+    MappingProperties accounts;
+
+    AccountMapping(Schema<AccountState> schema) {
+      this.accounts = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String ACCOUNTS = "accounts";
+
+  private final AccountMapping mapping;
+  private final Provider<AccountCache> accountCache;
+  private final Schema<AccountState> schema;
+
+  @Inject
+  ElasticAccountIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Provider<AccountCache> accountCache,
+      ElasticRestClientProvider client,
+      @Assisted Schema<AccountState> schema) {
+    super(cfg, sitePaths, schema, client, ACCOUNTS);
+    this.accountCache = accountCache;
+    this.mapping = new AccountMapping(schema);
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(as), indexName, ACCOUNTS).add(new UpdateRequest<>(schema, as));
+
+    String uri = getURI(ACCOUNTS, BULK);
+    Response response = postRequest(bulk, uri, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format(
+              "Failed to replace account %s in index %s: %s",
+              as.getAccount().getId(), indexName, statusCode));
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
+      throws QueryParseException {
+    JsonArray sortArray = getSortArray(AccountField.ID.getName());
+    return new ElasticQuerySource(
+        p, opts.filterFields(IndexUtils::accountFields), ACCOUNTS, sortArray);
+  }
+
+  @Override
+  protected String addActions(Account.Id c) {
+    return delete(ACCOUNTS, c);
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, AccountMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(AccountState as) {
+    return as.getAccount().getId().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 = new Account.Id(source.getAsJsonObject().get(ID.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/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
new file mode 100644
index 0000000..c7b0fb5
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,416 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+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.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.StarredChangesUtil;
+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.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.http.HttpStatus;
+import org.elasticsearch.client.Response;
+
+/** Secondary index implementation using Elasticsearch. */
+class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  static class ChangeMapping {
+    MappingProperties openChanges;
+    MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema) {
+      MappingProperties mapping = ElasticMapping.createMapping(schema);
+      this.openChanges = mapping;
+      this.closedChanges = mapping;
+    }
+  }
+
+  static final String CHANGES = "changes";
+  static final String OPEN_CHANGES = "open_" + CHANGES;
+  static final String CLOSED_CHANGES = "closed_" + CHANGES;
+
+  private final ChangeMapping mapping;
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Schema<ChangeData> schema;
+
+  @Inject
+  ElasticChangeIndex(
+      ElasticConfiguration cfg,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      SitePaths sitePaths,
+      ElasticRestClientProvider clientBuilder,
+      @Assisted Schema<ChangeData> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, CHANGES);
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.schema = schema;
+    mapping = new ChangeMapping(schema);
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    String deleteIndex;
+    String insertIndex;
+
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        insertIndex = OPEN_CHANGES;
+        deleteIndex = CLOSED_CHANGES;
+      } else {
+        insertIndex = CLOSED_CHANGES;
+        deleteIndex = OPEN_CHANGES;
+      }
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+
+    BulkRequest bulk =
+        new IndexRequest(getId(cd), indexName, insertIndex)
+            .add(new UpdateRequest<>(schema, cd))
+            .add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex));
+
+    String uri = getURI(CHANGES, BULK);
+    Response response = postRequest(bulk, uri, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          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 {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<String> indexes = Lists.newArrayListWithCapacity(2);
+    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+      indexes.add(OPEN_CHANGES);
+    }
+    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+      indexes.add(CLOSED_CHANGES);
+    }
+
+    QueryOptions filteredOpts = opts.filterFields(IndexUtils::changeFields);
+    return new ElasticQuerySource(p, filteredOpts, getURI(indexes), getSortArray());
+  }
+
+  private JsonArray getSortArray() {
+    JsonObject properties = new JsonObject();
+    properties.addProperty(ORDER, "desc");
+    properties.addProperty(IGNORE_UNMAPPED, true);
+
+    JsonArray sortArray = new JsonArray();
+    addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
+    addNamedElement(ChangeField.LEGACY_ID.getName(), properties, sortArray);
+    return sortArray;
+  }
+
+  private String getURI(List<String> types) {
+    return String.join(",", types);
+  }
+
+  @Override
+  protected String addActions(Id c) {
+    return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
+  }
+
+  @Override
+  protected String getMappings() {
+    return gson.toJson(ImmutableMap.of("mappings", mapping));
+  }
+
+  @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.getName());
+
+    if (c == null) {
+      int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+      String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
+      return changeDataFactory.create(
+          db.get(), new Project.NameKey(projectName), new Change.Id(id));
+    }
+
+    ChangeData cd =
+        changeDataFactory.create(
+            db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
+
+    // Patch sets.
+    cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+
+    // Approvals.
+    if (source.get(ChangeField.APPROVAL.getName()) != null) {
+      cd.setCurrentApprovals(decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+      cd.setCurrentApprovals(Collections.emptyList());
+    }
+
+    // Added & Deleted.
+    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+    if (addedElement != null && deletedElement != null) {
+      // Changed lines.
+      int added = addedElement.getAsInt();
+      int deleted = deletedElement.getAsInt();
+      cd.setChangedLines(added, deleted);
+    }
+
+    // Star.
+    JsonElement starredElement = source.get(ChangeField.STAR.getName());
+    if (starredElement != null) {
+      ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
+      JsonArray starBy = starredElement.getAsJsonArray();
+      if (starBy.size() > 0) {
+        for (int i = 0; i < starBy.size(); i++) {
+          String[] indexableFields = starBy.get(i).getAsString().split(":");
+          Optional<Account.Id> id = Account.Id.tryParse(indexableFields[0]);
+          if (id.isPresent()) {
+            stars.put(id.get(), indexableFields[1]);
+          }
+        }
+      }
+      cd.setStars(stars);
+    }
+
+    // Mergeable.
+    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+    if (mergeableElement != null) {
+      String mergeable = mergeableElement.getAsString();
+      if ("1".equals(mergeable)) {
+        cd.setMergeable(true);
+      } else if ("0".equals(mergeable)) {
+        cd.setMergeable(false);
+      }
+    }
+
+    // Reviewed-by.
+    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+      if (reviewedBy.size() > 0) {
+        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
+        for (int i = 0; i < reviewedBy.size(); i++) {
+          int aId = reviewedBy.get(i).getAsInt();
+          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+            break;
+          }
+          accounts.add(new Account.Id(aId));
+        }
+        cd.setReviewedBy(accounts);
+      }
+    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+      cd.setReviewedBy(Collections.emptySet());
+    }
+
+    // Hashtag.
+    if (source.get(ChangeField.HASHTAG.getName()) != null) {
+      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
+      if (hashtagArray.size() > 0) {
+        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
+        for (int i = 0; i < hashtagArray.size(); i++) {
+          hashtags.add(hashtagArray.get(i).getAsString());
+        }
+        cd.setHashtags(hashtags);
+      }
+    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
+      cd.setHashtags(Collections.emptySet());
+    }
+
+    // Star.
+    if (source.get(ChangeField.STAR.getName()) != null) {
+      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
+      if (starArray.size() > 0) {
+        ListMultimap<Account.Id, String> stars =
+            MultimapBuilder.hashKeys().arrayListValues().build();
+        for (int i = 0; i < starArray.size(); i++) {
+          StarredChangesUtil.StarField starField =
+              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
+          stars.put(starField.accountId(), starField.label());
+        }
+        cd.setStars(stars);
+      }
+    } else if (fields.contains(ChangeField.STAR.getName())) {
+      cd.setStars(ImmutableListMultimap.of());
+    }
+
+    // Reviewer.
+    if (source.get(ChangeField.REVIEWER.getName()) != null) {
+      cd.setReviewers(
+          ChangeField.parseReviewerFieldValues(
+              cd.getId(),
+              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+      cd.setReviewers(ReviewerSet.empty());
+    }
+
+    // Reviewer-by-email.
+    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              cd.getId(),
+              FluentIterable.from(
+                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+    }
+
+    // Pending-reviewer.
+    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
+      cd.setPendingReviewers(
+          ChangeField.parseReviewerFieldValues(
+              cd.getId(),
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
+      cd.setPendingReviewers(ReviewerSet.empty());
+    }
+
+    // Pending-reviewer-by-email.
+    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setPendingReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              cd.getId(),
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
+      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
+    }
+
+    // Stored-submit-record-strict.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
+        cd);
+
+    // Stored-submit-record-leniant.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
+        cd);
+
+    // Ref-state.
+    if (fields.contains(ChangeField.REF_STATE.getName())) {
+      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+    }
+
+    // Ref-state-pattern.
+    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
+      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
+    }
+
+    // Unresolved-comment-count.
+    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
+
+    return cd;
+  }
+
+  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
+    JsonElement element = source.get(name);
+    return element != null
+        ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
+        : Collections.emptyList();
+  }
+
+  private void decodeSubmitRecords(
+      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
+    JsonArray records = doc.getAsJsonArray(fieldName);
+    if (records == null) {
+      return;
+    }
+    ChangeField.parseSubmitRecords(
+        FluentIterable.from(records)
+            .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
+            .toList(),
+        opts,
+        out);
+  }
+
+  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
+    JsonElement count = doc.get(fieldName);
+    if (count == null) {
+      return;
+    }
+    out.setUnresolvedCommentCount(count.getAsInt());
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
rename to java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticException.java b/java/com/google/gerrit/elasticsearch/ElasticException.java
new file mode 100644
index 0000000..d4baf75
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 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.elasticsearch;
+
+class ElasticException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  ElasticException(String message) {
+    super(message);
+  }
+
+  ElasticException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
new file mode 100644
index 0000000..d1eab7c
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2017 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.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+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.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.group.InternalGroup;
+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.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.io.IOException;
+import java.util.Set;
+import org.apache.http.HttpStatus;
+import org.elasticsearch.client.Response;
+
+public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
+    implements GroupIndex {
+  static class GroupMapping {
+    MappingProperties groups;
+
+    GroupMapping(Schema<InternalGroup> schema) {
+      this.groups = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String GROUPS = "groups";
+
+  private final GroupMapping mapping;
+  private final Provider<GroupCache> groupCache;
+  private final Schema<InternalGroup> schema;
+
+  @Inject
+  ElasticGroupIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Provider<GroupCache> groupCache,
+      ElasticRestClientProvider client,
+      @Assisted Schema<InternalGroup> schema) {
+    super(cfg, sitePaths, schema, client, GROUPS);
+    this.groupCache = groupCache;
+    this.mapping = new GroupMapping(schema);
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(InternalGroup group) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(group), indexName, GROUPS).add(new UpdateRequest<>(schema, group));
+
+    String uri = getURI(GROUPS, BULK);
+    Response response = postRequest(bulk, uri, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          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.getName());
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), GROUPS, sortArray);
+  }
+
+  @Override
+  protected String addActions(AccountGroup.UUID c) {
+    return delete(GROUPS, c);
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, GroupMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @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 =
+        new AccountGroup.UUID(
+            source.getAsJsonObject().get(GroupField.UUID.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/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
similarity index 77%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index e78416d..1e41985 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -22,23 +23,22 @@
 import java.util.Map;
 
 public class ElasticIndexModule extends AbstractIndexModule {
-
   public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new ElasticIndexModule(versions, threads, false);
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new ElasticIndexModule(versions, threads, false, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, true);
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade(boolean slave) {
+    return new ElasticIndexModule(null, 0, true, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, false);
+  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade(boolean slave) {
+    return new ElasticIndexModule(null, 0, false, slave);
   }
 
   private ElasticIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
-    super(singleVersions, threads, onlineUpgrade);
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
+    super(singleVersions, threads, onlineUpgrade, slave);
   }
 
   @Override
@@ -63,6 +63,11 @@
   }
 
   @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return ElasticProjectIndex.class;
+  }
+
+  @Override
   protected Class<? extends VersionManager> getVersionManager() {
     return ElasticIndexVersionManager.class;
   }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
similarity index 91%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
index 1ddff77..612402e 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.base.Strings;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.registration.DynamicSet;
 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;
@@ -28,6 +30,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -40,14 +43,14 @@
 
   @Inject
   ElasticIndexVersionManager(
-      ElasticConfiguration cfg,
+      @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       DynamicSet<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
       ElasticIndexVersionDiscovery versionDiscovery) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg.getConfig()));
+    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
     this.versionDiscovery = versionDiscovery;
-    prefix = cfg.prefix;
+    prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
   }
 
   @Override
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
rename to java/com/google/gerrit/elasticsearch/ElasticMapping.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
new file mode 100644
index 0000000..a536b41
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2017 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.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+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.reviewdb.client.Project;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.project.ProjectCache;
+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.io.IOException;
+import java.util.Set;
+import org.apache.http.HttpStatus;
+import org.elasticsearch.client.Response;
+
+public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  static class ProjectMapping {
+    MappingProperties projects;
+
+    ProjectMapping(Schema<ProjectData> schema) {
+      this.projects = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String PROJECTS = "projects";
+
+  private final ProjectMapping mapping;
+  private final Provider<ProjectCache> projectCache;
+  private final Schema<ProjectData> schema;
+
+  @Inject
+  ElasticProjectIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Provider<ProjectCache> projectCache,
+      ElasticRestClientProvider clientBuilder,
+      @Assisted Schema<ProjectData> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, PROJECTS);
+    this.projectCache = projectCache;
+    this.schema = schema;
+    this.mapping = new ProjectMapping(schema);
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(projectState.getProject().getName(), indexName, PROJECTS)
+            .add(new UpdateRequest<>(schema, projectState));
+
+    String uri = getURI(PROJECTS, BULK);
+    Response response = postRequest(bulk, uri, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          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.getName());
+    return new ElasticQuerySource(
+        p, opts.filterFields(IndexUtils::projectFields), PROJECTS, sortArray);
+  }
+
+  @Override
+  protected String addActions(Project.NameKey nameKey) {
+    return delete(PROJECTS, nameKey);
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, ProjectMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @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 =
+        new Project.NameKey(
+            source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+    return projectCache.get().get(nameKey).toProjectData();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
new file mode 100644
index 0000000..91f938c
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2018 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.elasticsearch;
+
+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.http.HttpHost;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestClientBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(ElasticRestClientProvider.class);
+
+  private final HttpHost[] hosts;
+  private final String username;
+  private final String password;
+
+  private RestClient client;
+
+  @Inject
+  ElasticRestClientProvider(ElasticConfiguration cfg) {
+    hosts = cfg.urls.toArray(new HttpHost[cfg.urls.size()]);
+    username = cfg.username;
+    password = cfg.password;
+  }
+
+  public static LifecycleModule module() {
+    return new LifecycleModule() {
+      @Override
+      protected void configure() {
+        listener().to(ElasticRestClientProvider.class);
+      }
+    };
+  }
+
+  @Override
+  public RestClient get() {
+    if (client == null) {
+      synchronized (this) {
+        if (client == null) {
+          client = build();
+          ElasticVersion version = getVersion();
+          log.info("Elasticsearch integration version {}", version);
+        }
+      }
+    }
+    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.
+      }
+    }
+  }
+
+  public static class FailedToGetVersion extends ElasticException {
+    private static final long serialVersionUID = 1L;
+    private static final String MESSAGE = "Failed to get Elasticsearch version";
+
+    FailedToGetVersion(StatusLine status) {
+      super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase()));
+    }
+
+    FailedToGetVersion(Throwable cause) {
+      super(MESSAGE, cause);
+    }
+  }
+
+  private ElasticVersion getVersion() throws ElasticException {
+    try {
+      Response response = client.performRequest("GET", "");
+      StatusLine statusLine = response.getStatusLine();
+      if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
+        throw new FailedToGetVersion(statusLine);
+      }
+      String version =
+          new JsonParser()
+              .parse(AbstractElasticIndex.getContent(response))
+              .getAsJsonObject()
+              .get("version")
+              .getAsJsonObject()
+              .get("number")
+              .getAsString();
+      log.info("Connected to Elasticsearch version {}", version);
+      return ElasticVersion.forVersion(version);
+    } catch (IOException e) {
+      throw new FailedToGetVersion(e);
+    }
+  }
+
+  private RestClient build() {
+    RestClientBuilder builder = RestClient.builder(hosts);
+    setConfiguredCredentialsIfAny(builder);
+    return builder.build();
+  }
+
+  private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
+    if (username != null && password != null) {
+      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+      credentialsProvider.setCredentials(
+          AuthScope.ANY, new UsernamePasswordCredentials(username, password));
+      builder.setHttpClientConfigCallback(
+          (HttpAsyncClientBuilder httpClientBuilder) ->
+              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
new file mode 100644
index 0000000..ff26382
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 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.elasticsearch;
+
+import com.google.common.base.Joiner;
+import java.util.regex.Pattern;
+
+public enum ElasticVersion {
+  V2_4("2.4.*"),
+  V5_6("5.6.*"),
+  V6_2("6.2.*");
+
+  private final String version;
+  private final Pattern pattern;
+
+  private ElasticVersion(String version) {
+    this.version = version;
+    this.pattern = Pattern.compile(version);
+  }
+
+  public static class InvalidVersion extends ElasticException {
+    private static final long serialVersionUID = 1L;
+
+    InvalidVersion(String version) {
+      super(
+          String.format(
+              "Invalid version: [%s]. Supported versions: %s", version, supportedVersions()));
+    }
+  }
+
+  public static ElasticVersion forVersion(String version) throws InvalidVersion {
+    for (ElasticVersion value : ElasticVersion.values()) {
+      if (value.pattern.matcher(version).matches()) {
+        return value;
+      }
+    }
+    throw new InvalidVersion(version);
+  }
+
+  public static String supportedVersions() {
+    return Joiner.on(", ").join(ElasticVersion.values());
+  }
+
+  @Override
+  public String toString() {
+    return version;
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
rename to java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java b/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
rename to java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java b/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
rename to java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java b/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
rename to java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
rename to java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java b/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
rename to java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
rename to java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
rename to java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
new file mode 100644
index 0000000..49c34dd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2014 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.acceptance.pgm;
+
+import com.google.gerrit.acceptance.NoHttpd;
+import org.junit.Ignore;
+
+@NoHttpd
+@Ignore
+public class ElasticReindexIT extends AbstractReindexTests {}
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
new file mode 100644
index 0000000..1249909
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,58 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+java_library(
+    name = "elasticsearch_test_utils",
+    testonly = 1,
+    srcs = [
+        "ElasticContainer.java",
+        "ElasticTestUtils.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib/guice",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/testcontainers",
+        "//lib/truth",
+    ],
+)
+
+ELASTICSEARCH_TESTS = {i: "ElasticQuery" + i.capitalize() + "sTest.java" for i in [
+    "account",
+    "change",
+    "group",
+    "project",
+]}
+
+[junit_tests(
+    name = "elasticsearch_%ss_test" % name,
+    size = "large",
+    srcs = [src],
+    tags = [
+        "docker",
+        "elastic",
+    ],
+    deps = [
+        ":elasticsearch_test_utils",
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
+        "//lib/guice",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/testcontainers",
+    ],
+) for name, src in ELASTICSEARCH_TESTS.items()]
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
similarity index 95%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 6df742c..a7a23c3 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -15,10 +15,9 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticVersion;
 import java.util.Set;
 import org.apache.http.HttpHost;
-import org.junit.internal.AssumptionViolatedException;
+import org.junit.AssumptionViolatedException;
 import org.testcontainers.containers.GenericContainer;
 
 /* Helper class for running ES integration tests in docker container */
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
similarity index 89%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
index 4bf1a46..4f0f8b0 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
@@ -16,7 +16,9 @@
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
@@ -24,6 +26,11 @@
 import org.junit.BeforeClass;
 
 public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
   private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer<?> container;
 
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
index eed7d9e..2aef875 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -16,8 +16,10 @@
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.junit.TestRepository;
@@ -27,6 +29,11 @@
 import org.junit.Test;
 
 public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
   private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer<?> container;
 
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
index 779dd91..f13c491 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
@@ -16,7 +16,9 @@
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
@@ -24,6 +26,11 @@
 import org.junit.BeforeClass;
 
 public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
   private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer<?> container;
 
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
similarity index 82%
copy from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
copy to javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
index 779dd91..dd04010 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
@@ -15,15 +15,22 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 
-public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
+public class ElasticQueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
   private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer<?> container;
 
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
similarity index 96%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index 54a3895..ca52e2a 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -38,7 +38,7 @@
     config.setString("elasticsearch", "test", "hostname", "localhost");
     config.setInt("elasticsearch", "test", "port", port);
     config.setString("elasticsearch", null, "prefix", prefix);
-    config.setInt("index", null, "maxLimit", 10000);
+    config.setString("index", null, "maxLimit", "10000");
   }
 
   public static void createAllIndexes(Injector injector) throws IOException {