Add support for forwarding cache eviction for custom caches

Allow to specify cache.pattern in the plugin configuration, which
will add additional patterns to the regex to test if a cache eviction
should be forwarded. This will allow caches created by other plugins
to be handled in addition to the default core caches.

For example:

  [cache]
    synchronize = true
    pattern = ^my_cache.*
    pattern = other_cache

Note that evictions for core caches are always forwarded. Specifying
cache.pattern only adds extra matches; it doesn't override forwarding
of eviction for the core caches.

Change-Id: Ia415d53a3c08d744324e88a6f7115a761f94c1f6
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
index 6c56a8c..4ae47fe 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
@@ -28,6 +28,9 @@
 import com.google.inject.Singleton;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -55,6 +58,7 @@
 
   // cache section
   static final String CACHE_SECTION = "cache";
+  static final String PATTERN_KEY = "pattern";
 
   // event section
   static final String EVENT_SECTION = "event";
@@ -242,15 +246,21 @@
 
   public static class Cache extends Forwarding {
     private final int threadPoolSize;
+    private final List<String> patterns;
 
     private Cache(Config cfg) {
       super(cfg, CACHE_SECTION);
       threadPoolSize = getInt(cfg, CACHE_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
+      patterns = Arrays.asList(cfg.getStringList(CACHE_SECTION, null, PATTERN_KEY));
     }
 
     public int threadPoolSize() {
       return threadPoolSize;
     }
+
+    public List<String> patterns() {
+      return Collections.unmodifiableList(patterns);
+    }
   }
 
   public static class Event extends Forwarding {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java
index 43e3642..f8d71f3 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java
@@ -14,9 +14,12 @@
 
 package com.ericsson.gerrit.plugins.highavailability.cache;
 
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Pattern;
 
@@ -34,8 +37,11 @@
 
   private final Pattern pattern;
 
-  public CachePatternMatcher() {
-    this.pattern = Pattern.compile(Joiner.on("|").join(DEFAULT_PATTERNS));
+  @Inject
+  public CachePatternMatcher(Configuration cfg) {
+    List<String> patterns = new ArrayList<>(DEFAULT_PATTERNS);
+    patterns.addAll(cfg.cache().patterns());
+    this.pattern = Pattern.compile(Joiner.on("|").join(patterns));
   }
 
   public boolean matches(String cacheName) {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
index 6b047d3..94c3d30 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
@@ -15,7 +15,7 @@
 package com.ericsson.gerrit.plugins.highavailability.cache;
 
 public final class Constants {
-
+  public static final String GERRIT = "gerrit";
   public static final String PROJECT_LIST = "project_list";
   public static final String ACCOUNTS = "accounts";
   public static final String GROUPS = "groups";
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
index b388b0b..253ff4a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
@@ -19,6 +19,7 @@
 
 import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -37,7 +38,6 @@
 class CacheRestApiServlet extends HttpServlet {
   private static final int CACHENAME_INDEX = 1;
   private static final long serialVersionUID = -1L;
-  private static final String GERRIT = "gerrit";
   private static final Logger logger = LoggerFactory.getLogger(CacheRestApiServlet.class);
 
   private final DynamicMap<Cache<?, ?>> cacheMap;
@@ -57,10 +57,17 @@
       String cacheName = params.get(CACHENAME_INDEX);
       String json = req.getReader().readLine();
       Object key = GsonParser.fromJson(cacheName, json);
-      Cache<?, ?> cache = cacheMap.get(GERRIT, cacheName);
-      Context.setForwardedEvent(true);
-      evictCache(cache, cacheName, key);
-      rsp.setStatus(SC_NO_CONTENT);
+      CacheParameters cacheKey = getCacheParameters(cacheName);
+      Cache<?, ?> cache = cacheMap.get(cacheKey.pluginName, cacheKey.cacheName);
+      if (cache == null) {
+        String msg = String.format("cache %s not found", cacheName);
+        logger.error("Failed to process eviction request: " + msg);
+        sendError(rsp, SC_BAD_REQUEST, msg);
+      } else {
+        Context.setForwardedEvent(true);
+        evictCache(cache, cacheKey.cacheName, key);
+        rsp.setStatus(SC_NO_CONTENT);
+      }
     } catch (IOException e) {
       logger.error("Failed to process eviction request: " + e.getMessage(), e);
       sendError(rsp, SC_BAD_REQUEST, e.getMessage());
@@ -69,6 +76,26 @@
     }
   }
 
+  @VisibleForTesting
+  public static class CacheParameters {
+    public final String pluginName;
+    public final String cacheName;
+
+    public CacheParameters(String pluginName, String cacheName) {
+      this.pluginName = pluginName;
+      this.cacheName = cacheName;
+    }
+  }
+
+  @VisibleForTesting
+  public static CacheParameters getCacheParameters(String cache) {
+    int dot = cache.indexOf(".");
+    if (dot > 0) {
+      return new CacheParameters(cache.substring(0, dot), cache.substring(dot + 1));
+    }
+    return new CacheParameters(Constants.GERRIT, cache);
+  }
+
   private static void sendError(HttpServletResponse rsp, int statusCode, String message) {
     try {
       rsp.sendError(statusCode, message);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
index 23f42ab..8a177de 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
@@ -44,7 +44,11 @@
         key = gson.fromJson(Strings.nullToEmpty(json), Object.class);
         break;
       default:
-        key = gson.fromJson(Strings.nullToEmpty(json).trim(), String.class);
+        try {
+          key = gson.fromJson(Strings.nullToEmpty(json).trim(), String.class);
+        } catch (Exception e) {
+          key = gson.fromJson(Strings.nullToEmpty(json), Object.class);
+        }
     }
     return key;
   }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 8ec9432..1ab2858 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -62,6 +62,13 @@
 :   Maximum number of threads used to send cache evictions to the target instance.
     Defaults to 1.
 
+cache.pattern
+:   Pattern to match names of custom caches for which evictions should be
+    forwarded (in addition to the core caches that are always forwarded). May be
+    specified more than once to add multiple patterns.
+    Defaults to an empty list, meaning only evictions of the core caches are
+    forwarded.
+
 event.synchronize
 :   Whether to synchronize stream events.
     Defaults to true.
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
index 4ffce0a..cf9e29f 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
@@ -29,6 +29,7 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAIN_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAX_TRIES_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PASSWORD_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.PATTERN_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PEER_INFO_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.RETRY_INTERVAL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.SHARED_DIRECTORY_KEY;
@@ -73,6 +74,7 @@
   private static final String RELATIVE_SHARED_DIRECTORY = "relative/dir";
   private static final Path SITE_PATH = Paths.get("/site_path");
   private static final String ERROR_MESSAGE = "some error message";
+  private static final String[] CUSTOM_CACHE_PATTERNS = {"^my_cache.*", "other"};
 
   @Mock private PluginConfigFactory cfgFactoryMock;
   @Mock private Config configMock;
@@ -85,6 +87,8 @@
     when(cfgFactoryMock.getGlobalPluginConfig(pluginName)).thenReturn(configMock);
     when(configMock.getString(MAIN_SECTION, null, SHARED_DIRECTORY_KEY))
         .thenReturn(SHARED_DIRECTORY);
+    when(configMock.getStringList(CACHE_SECTION, null, PATTERN_KEY))
+        .thenReturn(CUSTOM_CACHE_PATTERNS);
     site = new SitePaths(SITE_PATH);
   }
 
@@ -333,8 +337,15 @@
   @Test
   public void testGetCachePatterns() throws Exception {
     initializeConfiguration();
-    CachePatternMatcher matcher = new CachePatternMatcher();
-    for (String cache : ImmutableList.of("accounts_byemail", "ldap_groups", "project_list")) {
+    CachePatternMatcher matcher = new CachePatternMatcher(configuration);
+    for (String cache :
+        ImmutableList.of(
+            "accounts_byemail",
+            "ldap_groups",
+            "project_list",
+            "my_cache_a",
+            "my_cache_b",
+            "other")) {
       assertThat(matcher.matches(cache)).isTrue();
     }
     for (String cache : ImmutableList.of("ldap_groups_by_include", "foo")) {
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
index d57c0e1..84ac4db 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
+import static com.google.common.truth.Truth.assertThat;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
 import static org.mockito.Mockito.doThrow;
@@ -22,6 +23,7 @@
 import static org.mockito.Mockito.when;
 
 import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.CacheRestApiServlet.CacheParameters;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import java.io.BufferedReader;
@@ -78,14 +80,15 @@
   }
 
   @Test
-  public void evictDefault() throws Exception {
-    configureMocksFor(Constants.PROJECTS);
+  public void evictPluginCache() throws Exception {
+    configureMocksFor("my-plugin", "my-cache");
     verifyResponseIsOK();
   }
 
-  private void verifyResponseIsOK() throws Exception {
-    servlet.doPost(request, response);
-    verify(response).setStatus(SC_NO_CONTENT);
+  @Test
+  public void evictDefault() throws Exception {
+    configureMocksFor(Constants.PROJECTS);
+    verifyResponseIsOK();
   }
 
   @Test
@@ -106,10 +109,34 @@
     verify(response).sendError(SC_BAD_REQUEST, errorMessage);
   }
 
+  @Test
+  public void cacheParameters() throws Exception {
+    CacheParameters key = CacheRestApiServlet.getCacheParameters("accounts_by_name");
+    assertThat(key.pluginName).isEqualTo(Constants.GERRIT);
+    assertThat(key.cacheName).isEqualTo("accounts_by_name");
+
+    key = CacheRestApiServlet.getCacheParameters("my_plugin.my_cache");
+    assertThat(key.pluginName).isEqualTo("my_plugin");
+    assertThat(key.cacheName).isEqualTo("my_cache");
+  }
+
+  private void verifyResponseIsOK() throws Exception {
+    servlet.doPost(request, response);
+    verify(response).setStatus(SC_NO_CONTENT);
+  }
+
+  private void configureMocksFor(String cacheName) throws Exception {
+    configureMocksFor(Constants.GERRIT, cacheName);
+  }
+
   @SuppressWarnings("unchecked")
-  private void configureMocksFor(String cacheName) throws IOException {
-    when(cacheMap.get("gerrit", cacheName)).thenReturn(mock(Cache.class));
-    when(request.getPathInfo()).thenReturn("/" + cacheName);
+  private void configureMocksFor(String pluginName, String cacheName) throws Exception {
+    when(cacheMap.get(pluginName, cacheName)).thenReturn(mock(Cache.class));
+    if (Constants.GERRIT.equals(pluginName)) {
+      when(request.getPathInfo()).thenReturn("/" + cacheName);
+    } else {
+      when(request.getPathInfo()).thenReturn("/" + pluginName + "." + cacheName);
+    }
     when(request.getReader()).thenReturn(reader);
 
     if (Constants.PROJECTS.equals(cacheName)) {