Merge branch 'stable-2.15' into stable-2.16

* stable-2.15:
  ElasticContainer: Use 7.0.0-alpha2

Change-Id: I3ac909ab9e19da41e2c2b03b966f4abc507c3c54
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 722e6d8..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,207 +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.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 {
-  private static final Logger log = LoggerFactory.getLogger(ElasticAccountIndex.class);
-
-  static class AccountMapping {
-    final MappingProperties accounts;
-
-    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
-      this.accounts = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String ACCOUNTS = "accounts";
-
-  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, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(AccountState as) throws IOException {
-    BulkRequest bulk =
-        new IndexRequest(getId(as), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, as));
-
-    String uri = getURI(type, BULK);
-    Response response = postRequest(uri, bulk, 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 getDeleteActions(Account.Id a) {
-    return delete(type, a);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(ACCOUNTS, mapping.accounts);
-  }
-
-  @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(client.adapter())
-              .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(type, SEARCH);
-        Response response = postRequest(uri, search);
-        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 264822e..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,465 +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 {
-    final MappingProperties changes;
-    final MappingProperties openChanges;
-    final MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
-      this.changes = mapping;
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  private static final String CHANGES = "changes";
-  private static final String OPEN_CHANGES = "open_" + CHANGES;
-  private static final String CLOSED_CHANGES = "closed_" + CHANGES;
-  private static final String ALL_CHANGES = OPEN_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, ALL_CHANGES);
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-    this.mapping = new ChangeMapping(schema, client.adapter());
-  }
-
-  @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);
-    }
-
-    ElasticQueryAdapter adapter = client.adapter();
-    BulkRequest bulk =
-        new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
-            .add(new UpdateRequest<>(schema, cd));
-    if (!adapter.usePostV5Type()) {
-      bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
-    }
-
-    String uri = getURI(type, BULK);
-    Response response = postRequest(uri, bulk, 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 (client.adapter().usePostV5Type()) {
-      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
-          || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-        indexes.add(ElasticQueryAdapter.POST_V5_TYPE);
-      }
-    } else {
-      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 getDeleteActions(Id c) {
-    if (client.adapter().usePostV5Type()) {
-      return delete(ElasticQueryAdapter.POST_V5_TYPE, c);
-    }
-    return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
-  }
-
-  @Override
-  protected String getMappings() {
-    if (client.adapter().usePostV5Type()) {
-      return getMappingsFor(ElasticQueryAdapter.POST_V5_TYPE, mapping.changes);
-    }
-    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(client.adapter())
-              .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(uri, search);
-        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");
-      client.adapter().setIgnoreUnmapped(properties);
-
-      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 79701e1..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,206 +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.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 {
-  private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
-
-  static class GroupMapping {
-    final MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
-      this.groups = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String GROUPS = "groups";
-
-  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, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(InternalGroup group) throws IOException {
-    BulkRequest bulk =
-        new IndexRequest(getId(group), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, group));
-
-    String uri = getURI(type, BULK);
-    Response response = postRequest(uri, bulk, 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 getDeleteActions(AccountGroup.UUID g) {
-    return delete(type, g);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(GROUPS, mapping.groups);
-  }
-
-  @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(client.adapter())
-              .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(type, SEARCH);
-        Response response = postRequest(uri, search);
-        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/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
similarity index 63%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
rename to java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index c21299f..3b589b2 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -14,45 +14,70 @@
 
 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.ImmutableMap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.Nullable;
 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.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.Request;
 import org.elasticsearch.client.Response;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected static final String BULK = "_bulk";
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
@@ -197,6 +222,50 @@
     return new DeleteRequest(id.toString(), indexName, type, client.adapter()).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(client.adapter().rawFieldsKey()).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);
@@ -269,4 +338,85 @@
     }
     return client.get().performRequest(request);
   }
+
+  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(client.adapter())
+              .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, uri, search, 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 {
+          logger.atSevere().log(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..8d23051
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,31 @@
+java_library(
+    name = "elasticsearch",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//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/flogger:api",
+        "//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",
+    ],
+)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
new file mode 100644
index 0000000..1b69b6d
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -0,0 +1,130 @@
+// 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.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 {
+    final MappingProperties accounts;
+
+    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
+      this.accounts = ElasticMapping.createMapping(schema, adapter);
+    }
+  }
+
+  private 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, client.adapter());
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(as), indexName, type, client.adapter())
+            .add(new UpdateRequest<>(schema, as));
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, 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 getDeleteActions(Account.Id a) {
+    return delete(type, a);
+  }
+
+  @Override
+  protected String getMappings() {
+    return getMappingsForSingleType(ACCOUNTS, mapping.accounts);
+  }
+
+  @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..1224c61
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,434 @@
+// 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.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 java.util.Objects.requireNonNull;
+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 {
+    final MappingProperties changes;
+    final MappingProperties openChanges;
+    final MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
+      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
+      this.changes = mapping;
+      this.openChanges = mapping;
+      this.closedChanges = mapping;
+    }
+  }
+
+  private static final String CHANGES = "changes";
+  private static final String OPEN_CHANGES = "open_" + CHANGES;
+  private 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, client.adapter());
+  }
+
+  @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);
+    }
+
+    ElasticQueryAdapter adapter = client.adapter();
+    BulkRequest bulk =
+        new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
+            .add(new UpdateRequest<>(schema, cd));
+    if (!adapter.usePostV5Type()) {
+      bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
+    }
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, 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 (client.adapter().usePostV5Type()) {
+      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
+          || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+        indexes.add(ElasticQueryAdapter.POST_V5_TYPE);
+      }
+    } else {
+      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");
+    client.adapter().setIgnoreUnmapped(properties);
+
+    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 getDeleteActions(Id c) {
+    if (client.adapter().usePostV5Type()) {
+      return delete(ElasticQueryAdapter.POST_V5_TYPE, c);
+    }
+    return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
+  }
+
+  @Override
+  protected String getMappings() {
+    if (client.adapter().usePostV5Type()) {
+      return getMappingsFor(ElasticQueryAdapter.POST_V5_TYPE, mapping.changes);
+    }
+    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 = requireNonNull(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 93%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
rename to java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index cd74567..6863238 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
@@ -28,12 +29,10 @@
 import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 class ElasticConfiguration {
-  private static final Logger log = LoggerFactory.getLogger(ElasticConfiguration.class);
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String SECTION_ELASTICSEARCH = "elasticsearch";
   static final String KEY_PASSWORD = "password";
@@ -92,7 +91,7 @@
                 uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
         this.hosts.add(httpHost);
       } catch (URISyntaxException | IllegalArgumentException e) {
-        log.error("Invalid server URI {}: {}", server, e.getMessage());
+        logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage());
       }
     }
 
@@ -100,7 +99,7 @@
       throw new ProvisionException("No valid Elasticsearch servers configured");
     }
 
-    log.info("Elasticsearch servers: {}", hosts);
+    logger.atInfo().log("Elasticsearch servers: %s", hosts);
   }
 
   Config getConfig() {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticException.java b/java/com/google/gerrit/elasticsearch/ElasticException.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticException.java
rename to java/com/google/gerrit/elasticsearch/ElasticException.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
new file mode 100644
index 0000000..f694a05
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -0,0 +1,126 @@
+// 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.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 {
+    final MappingProperties groups;
+
+    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
+      this.groups = ElasticMapping.createMapping(schema, adapter);
+    }
+  }
+
+  private 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, client.adapter());
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(InternalGroup group) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(group), indexName, type, client.adapter())
+            .add(new UpdateRequest<>(schema, group));
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, 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 getDeleteActions(AccountGroup.UUID g) {
+    return delete(type, g);
+  }
+
+  @Override
+  protected String getMappings() {
+    return getMappingsForSingleType(GROUPS, mapping.groups);
+  }
+
+  @Override
+  protected String getId(InternalGroup group) {
+    return group.getGroupUUID().get();
+  }
+
+  @Override
+  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
+    }
+
+    AccountGroup.UUID uuid =
+        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 78%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 6bc51ce..15d6126 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,18 +23,17 @@
 import java.util.Map;
 
 public class ElasticIndexModule extends AbstractIndexModule {
-
   public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new ElasticIndexModule(versions, threads);
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new ElasticIndexModule(versions, threads, slave);
   }
 
-  public static ElasticIndexModule latestVersion() {
-    return new ElasticIndexModule(null, 0);
+  public static ElasticIndexModule latestVersion(boolean slave) {
+    return new ElasticIndexModule(null, 0, slave);
   }
 
-  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads) {
-    super(singleVersions, threads);
+  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+    super(singleVersions, threads, slave);
   }
 
   @Override
@@ -58,6 +58,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 91%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
index 249e93c..a777f47 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gson.JsonParser;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -25,12 +26,10 @@
 import org.apache.http.StatusLine;
 import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 class ElasticIndexVersionDiscovery {
-  private static final Logger log = LoggerFactory.getLogger(ElasticIndexVersionDiscovery.class);
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ElasticRestClientProvider client;
 
@@ -50,7 +49,7 @@
           String.format(
               "Failed to discover index versions for %s: %d: %s",
               name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
-      log.error(message);
+      logger.atSevere().log(message);
       throw new IOException(message);
     }
 
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
similarity index 79%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
index cff1911..8011efa 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 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;
@@ -29,26 +32,25 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.TreeMap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ElasticIndexVersionManager extends VersionManager {
-  private static final Logger log = LoggerFactory.getLogger(ElasticIndexVersionManager.class);
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String prefix;
   private final ElasticIndexVersionDiscovery versionDiscovery;
 
   @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
@@ -57,17 +59,17 @@
     TreeMap<Integer, Version<V>> versions = new TreeMap<>();
     try {
       List<String> discovered = versionDiscovery.discover(prefix, def.getName());
-      log.debug("Discovered versions for {}: {}", def.getName(), discovered);
+      logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered);
       for (String version : discovered) {
         Integer v = Ints.tryParse(version);
         if (v == null || version.length() != 4) {
-          log.warn("Unrecognized version in index {}: {}", def.getName(), version);
+          logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version);
           continue;
         }
         versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
       }
     } catch (IOException e) {
-      log.error("Error scanning index: " + def.getName(), e);
+      logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName());
     }
 
     for (Schema<V> schema : def.getSchemas().values()) {
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..8510559
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -0,0 +1,124 @@
+// 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.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, ElasticQueryAdapter adapter) {
+      this.projects = ElasticMapping.createMapping(schema, adapter);
+    }
+  }
+
+  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 client,
+      @Assisted Schema<ProjectData> schema) {
+    super(cfg, sitePaths, schema, client, PROJECTS);
+    this.projectCache = projectCache;
+    this.schema = schema;
+    this.mapping = new ProjectMapping(schema, client.adapter());
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(projectState.getProject().getName(), indexName, type, client.adapter())
+            .add(new UpdateRequest<>(schema, projectState));
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, 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), type, sortArray);
+  }
+
+  @Override
+  protected String getDeleteActions(Project.NameKey nameKey) {
+    return delete(type, nameKey);
+  }
+
+  @Override
+  protected String getMappings() {
+    return getMappingsForSingleType(PROJECTS, mapping.projects);
+  }
+
+  @Override
+  protected String getId(ProjectData projectState) {
+    return projectState.getProject().getName();
+  }
+
+  @Override
+  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
+    }
+
+    Project.NameKey nameKey =
+        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/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
similarity index 94%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
rename to java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index 0c28dd1..40c1bbb 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -28,6 +28,7 @@
   private final String exactFieldType;
   private final String stringFieldType;
   private final String indexProperty;
+  private final String rawFieldsKey;
   private final String versionDiscoveryUrl;
 
   ElasticQueryAdapter(ElasticVersion version) {
@@ -40,6 +41,7 @@
     this.exactFieldType = "keyword";
     this.stringFieldType = "text";
     this.indexProperty = "true";
+    this.rawFieldsKey = "_source";
   }
 
   void setIgnoreUnmapped(JsonObject properties) {
@@ -74,6 +76,10 @@
     return indexProperty;
   }
 
+  String rawFieldsKey() {
+    return rawFieldsKey;
+  }
+
   boolean usePostV5Type() {
     return usePostV5Type;
   }
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/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
similarity index 93%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
rename to java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index 9c1cf02..e9839b7 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gson.JsonParser;
@@ -32,12 +33,10 @@
 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 static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ElasticConfiguration cfg;
 
@@ -65,7 +64,7 @@
         if (client == null) {
           client = build();
           ElasticVersion version = getVersion();
-          log.info("Elasticsearch integration version {}", version);
+          logger.atInfo().log("Elasticsearch integration version %s", version);
           adapter = new ElasticQueryAdapter(version);
         }
       }
@@ -120,7 +119,7 @@
               .getAsJsonObject()
               .get("number")
               .getAsString();
-      log.info("Connected to Elasticsearch version {}", version);
+      logger.atInfo().log("Connected to Elasticsearch version %s", version);
       return ElasticVersion.forVersion(version);
     } catch (IOException e) {
       throw new FailedToGetVersion(e);
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
rename to java/com/google/gerrit/elasticsearch/ElasticSetting.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
rename to java/com/google/gerrit/elasticsearch/ElasticVersion.java
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/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
similarity index 96%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 3d59ad3..bb7cff7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
 import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
similarity index 96%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index cf8503d..4e88955 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
 import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
 
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
new file mode 100644
index 0000000..4806acc
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,98 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+java_library(
+    name = "elasticsearch_test_utils",
+    testonly = True,
+    srcs = [
+        "ElasticContainer.java",
+        "ElasticTestUtils.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib/guice",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/testcontainers",
+        "//lib/testcontainers:testcontainers-elasticsearch",
+    ],
+)
+
+ELASTICSEARCH_DEPS = [
+    ":elasticsearch_test_utils",
+    "//java/com/google/gerrit/elasticsearch",
+    "//java/com/google/gerrit/testing:gerrit-test-util",
+    "//lib/guice",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+]
+
+QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
+
+TYPES = [
+    "account",
+    "change",
+    "group",
+    "project",
+]
+
+SUFFIX = "sTest.java"
+
+ELASTICSEARCH_TESTS_V5 = {i: "ElasticV5Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V6 = {i: "ElasticV6Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V7 = {i: "ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TAGS = [
+    "docker",
+    "elastic",
+    "exclusive",
+]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V5" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V5.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V6" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS + ["flaky"],
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V6.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V7" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS + ["flaky"],
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name] + [
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
+    ],
+) for name, src in ELASTICSEARCH_TESTS_V7.items()]
+
+junit_tests(
+    name = "elasticsearch_tests",
+    size = "small",
+    srcs = glob(
+        ["*Test.java"],
+        exclude = ["Elastic*Query*" + SUFFIX],
+    ),
+    tags = ["elastic"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
similarity index 100%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
similarity index 97%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index b68e866..8583d2d 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.elasticsearch;
 
 import org.apache.http.HttpHost;
-import org.junit.internal.AssumptionViolatedException;
+import org.junit.AssumptionViolatedException;
 import org.testcontainers.elasticsearch.ElasticsearchContainer;
 
 /* Helper class for running ES integration tests in docker 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 100%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index 01a523b..c8ce54a 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.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 ElasticV5QueryAccountsTest 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/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index d60b7ec..cfdfa98 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -16,7 +16,9 @@
 
 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.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 ElasticV5QueryChangesTest 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/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index e8d9a78..832a7bd 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.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 ElasticV5QueryGroupsTest 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/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
similarity index 82%
copy from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
copy to javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index d60b7ec..29d3fa4 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -15,15 +15,22 @@
 package com.google.gerrit.elasticsearch;
 
 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.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 ElasticV5QueryChangesTest extends AbstractQueryChangesTest {
+public class ElasticV5QueryProjectsTest 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/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index c4a787f..8833907 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.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 ElasticV6QueryAccountsTest 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/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 536ba74..8ba753c 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -16,7 +16,9 @@
 
 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.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,10 @@
 import org.junit.BeforeClass;
 
 public class ElasticV6QueryChangesTest 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/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 22186f5..cecb085 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.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 ElasticV6QueryGroupsTest 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/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
similarity index 81%
copy from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
copy to javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index 22186f5..47e9b10 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.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 ElasticV6QueryGroupsTest extends AbstractQueryGroupsTest {
+public class ElasticV6QueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
   private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
@@ -34,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_5);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 8634709..bddbbc9 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.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 ElasticV7QueryAccountsTest 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/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
similarity index 91%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index f46364e..5dcf159 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -16,7 +16,9 @@
 
 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.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.apache.http.client.methods.HttpPost;
@@ -29,6 +31,10 @@
 import org.junit.BeforeClass;
 
 public class ElasticV7QueryChangesTest 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/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
similarity index 88%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 33f6666..54be7b9 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.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 ElasticV7QueryGroupsTest 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/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
similarity index 82%
copy from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
copy to javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 33f6666..e8b4a2c 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.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 ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
+public class ElasticV7QueryProjectsTest 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/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
similarity index 100%
rename from gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
rename to javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java