Merge branch 'stable-3.5' into stable-3.6

* stable-3.5:
  test: Simplify SSL setup
  Add 8.9.* to supported versions
  test: Always enable SSL for ES containers
  Bump testcontainers to 1.19.7
  Remove unused build var
  test: Add assert for closing indexes
  test: Use the 'withTag' helper to get DockerImageName
  Include an 'Accept' header for Content-Type in requests
  Add a debug log while insert/replace change index operation

Release-Notes: skip
Change-Id: I1426e2fd38b21d64c81804560b96b34a07ccd466
diff --git a/BUILD b/BUILD
index 0f6f594..858c03a 100644
--- a/BUILD
+++ b/BUILD
@@ -122,7 +122,8 @@
         exclude = ["src/test/java/**/Elastic*Query*" + SUFFIX],
     ),
     tags = ["elastic"],
-    deps = PLUGIN_TEST_DEPS + [
+    deps = ELASTICSEARCH_DEPS + PLUGIN_TEST_DEPS + [
+        ":elasticsearch_test_utils",
         ":index-elasticsearch__plugin",
     ],
 )
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 2a5fcde..0379ed9 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -18,6 +18,8 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
@@ -70,7 +72,12 @@
   final String prefix;
 
   @Inject
-  ElasticConfiguration(@GerritServerConfig Config cfg) {
+  ElasticConfiguration(@GerritServerConfig Config cfg, IndexConfig indexConfig) {
+    if (PaginationType.NONE == indexConfig.paginationType()) {
+      throw new ProvisionException(
+          "The 'index.paginationType = NONE' configuration is not supported by Elasticsearch");
+    }
+
     this.cfg = cfg;
     this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
     this.username =
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index 1668450..9ea9fcb 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -40,8 +40,10 @@
           || fieldType == FieldType.LONG) {
         mapping.addNumber(name);
       } else if (fieldType == FieldType.FULL_TEXT) {
-        mapping.addStringWithAnalyzer(name);
-      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
+        mapping.addStringWithAnalyzer(name, "custom_with_char_filter");
+      } else if (fieldType == FieldType.PREFIX) {
+        mapping.addStringWithAnalyzer(name, "keyword_tokenizer");
+      } else if (fieldType == FieldType.STORED_ONLY) {
         mapping.addString(name);
       } else {
         throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
@@ -101,9 +103,9 @@
       return this;
     }
 
-    Builder addStringWithAnalyzer(String name) {
+    Builder addStringWithAnalyzer(String name, String analyzer) {
       FieldProperties key = new FieldProperties(adapter.stringFieldType());
-      key.analyzer = "custom_with_char_filter";
+      key.analyzer = analyzer;
       fields.put(name, key);
       return this;
     }
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index cde5560..3580089 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.time.Instant;
 
 public class ElasticQueryBuilder {
 
@@ -119,9 +118,8 @@
   }
 
   private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
+    if (r.getMinTimestamp().toEpochMilli() == 0) {
+      return QueryBuilders.rangeQuery(r.getField().getName()).gt(r.getMaxTimestamp());
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -129,15 +127,14 @@
   private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
+      if (r.getMaxTimestamp().toEpochMilli() == Long.MAX_VALUE) {
         // The time range only has the start value, search from the start to the max supported value
         // Long.MAX_VALUE
-        return QueryBuilders.rangeQuery(r.getField().getName())
-            .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
+        return QueryBuilders.rangeQuery(r.getField().getName()).gte(r.getMinTimestamp());
       }
       return QueryBuilders.rangeQuery(r.getField().getName())
-          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
-          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
+          .gte(r.getMinTimestamp())
+          .lte(r.getMaxTimestamp());
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
index 0059574..2fcfc90 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -59,6 +59,7 @@
 
       FieldProperties analyzer = new FieldProperties();
       analyzer.customWithCharFilter = customAnalyzer;
+      analyzer.keywordTokenizer = ImmutableMap.of("tokenizer", "keyword");
       fields.put("analyzer", analyzer);
       return this;
     }
@@ -92,6 +93,7 @@
     String[] mappings;
     FieldProperties customMapping;
     FieldProperties customWithCharFilter;
+    Map<String, String> keywordTokenizer;
 
     FieldProperties() {}
 
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 74fe50c..5d0dead 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -10,6 +10,16 @@
 value is not configured during site initialization, defaults to 10000, which is the default value
 of `index.max_result_window` in Elasticsearch.
 
+### index.paginationType
+
+The pagination type to use when index queries are repeated to obtain the next set of results.
+Supported values are: `OFFSET` and `SEARCH_AFTER`. For more information, refer to
+[`index.paginationType`](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#index.paginationType).
+
+Defaults to `OFFSET`.
+Note: paginationType `NONE` is not supported and Gerrit will not start if it is configured (results
+in `ProvisionException`).
+
 ## Section elasticsearch
 
 For compatibility information, please refer to the [project homepage](https://www.gerritcodereview.com/elasticsearch.html).
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
index 7e044c3..9d20edc 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -25,6 +25,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexConfig;
 import com.google.inject.ProvisionException;
 import java.util.Arrays;
 import org.apache.http.HttpHost;
@@ -35,7 +36,7 @@
   @Test
   public void singleServerNoOtherConfig() throws Exception {
     Config cfg = newConfig();
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertHosts(esCfg, "http://elastic:1234");
     assertThat(esCfg.username).isNull();
     assertThat(esCfg.password).isNull();
@@ -46,7 +47,7 @@
   public void serverWithoutPortSpecified() throws Exception {
     Config cfg = new Config();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertHosts(esCfg, "http://elastic:9200");
   }
 
@@ -54,7 +55,7 @@
   public void prefix() throws Exception {
     Config cfg = newConfig();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertThat(esCfg.prefix).isEqualTo("myprefix");
   }
 
@@ -63,7 +64,7 @@
     Config cfg = newConfig();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertThat(esCfg.username).isEqualTo("myself");
     assertThat(esCfg.password).isEqualTo("s3kr3t");
   }
@@ -72,7 +73,7 @@
   public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
     Config cfg = newConfig();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
     assertThat(esCfg.password).isEqualTo("s3kr3t");
   }
@@ -85,20 +86,20 @@
         null,
         KEY_SERVER,
         ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
   }
 
   @Test
   public void noServers() throws Exception {
-    assertProvisionException(new Config());
+    assertProvisionException(new Config(), "No valid Elasticsearch servers configured");
   }
 
   @Test
   public void singleServerInvalid() throws Exception {
     Config cfg = new Config();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
-    assertProvisionException(cfg);
+    assertProvisionException(cfg, "No valid Elasticsearch servers configured");
   }
 
   @Test
@@ -106,24 +107,35 @@
     Config cfg = new Config();
     cfg.setStringList(
         SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    ElasticConfiguration esCfg = newElasticConfig(cfg);
     assertHosts(esCfg, "http://elastic1:1234");
   }
 
+  @Test
+  public void unsupportedPaginationTypeNone() {
+    Config cfg = new Config();
+    cfg.setString("index", null, "paginationType", "NONE");
+    assertProvisionException(
+        cfg, "The 'index.paginationType = NONE' configuration is not supported by Elasticsearch");
+  }
+
   private static Config newConfig() {
     Config config = new Config();
     config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
     return config;
   }
 
+  private static ElasticConfiguration newElasticConfig(Config cfg) {
+    return new ElasticConfiguration(cfg, IndexConfig.fromConfig(cfg).build());
+  }
+
   private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
     assertThat(Arrays.asList(cfg.getHosts()).stream().map(HttpHost::toURI).collect(toList()))
         .containsExactly(hostURIs);
   }
 
-  private void assertProvisionException(Config cfg) {
-    ProvisionException thrown =
-        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
-    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
+  private void assertProvisionException(Config cfg, String msg) {
+    ProvisionException thrown = assertThrows(ProvisionException.class, () -> newElasticConfig(cfg));
+    assertThat(thrown).hasMessageThat().contains(msg);
   }
 }