Expose percentage free space metric for each cache

Expose of the amount of free space in the cache as a percentage.

Feature: Issue 13940
Change-Id: I27c1feb2eb06124ab69fe3d30bdeb72f22a2d383
diff --git a/metrics.md b/metrics.md
new file mode 100644
index 0000000..aeb6679
--- /dev/null
+++ b/metrics.md
@@ -0,0 +1,12 @@
+Metrics
+=============
+
+In addition to the [usual metrics](https://gerrit-review.googlesource.com/Documentation/metrics.html#_caches)
+exposed by caches, chronicle-map emits additional metrics that might be useful
+to monitor the state of the cache:
+
+* cache/chroniclemap/percentagae_free_space_<cache-name>
+  : the amount of free space left in the cache as a percentage.
+
+  See the [official documentation](https://javadoc.io/static/net.openhft/chronicle-map/3.20.83/net/openhft/chronicle/map/ChronicleMap.html#percentageFreeSpace--)
+  for more information.
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheFactory.java b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheFactory.java
index 01bc2cc..174ba03 100644
--- a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheFactory.java
+++ b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheFactory.java
@@ -20,6 +20,7 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
@@ -41,14 +42,18 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChronicleMapCacheConfig.Factory configFactory;
+  private final MetricMaker metricMaker;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final List<ChronicleMapCacheImpl<?, ?>> caches;
   private final ScheduledExecutorService cleanup;
 
   @Inject
   ChronicleMapCacheFactory(
-      ChronicleMapCacheConfig.Factory configFactory, DynamicMap<Cache<?, ?>> cacheMap) {
+      ChronicleMapCacheConfig.Factory configFactory,
+      DynamicMap<Cache<?, ?>> cacheMap,
+      MetricMaker metricMaker) {
     this.configFactory = configFactory;
+    this.metricMaker = metricMaker;
     this.caches = new LinkedList<>();
     this.cacheMap = cacheMap;
     this.cleanup =
@@ -74,7 +79,7 @@
             in.version());
     ChronicleMapCacheImpl<K, V> cache = null;
     try {
-      cache = new ChronicleMapCacheImpl<>(in, config, null);
+      cache = new ChronicleMapCacheImpl<>(in, config, null, metricMaker);
     } catch (IOException e) {
       throw new UncheckedIOException(e);
     }
@@ -98,7 +103,7 @@
             in.version());
     ChronicleMapCacheImpl<K, V> cache = null;
     try {
-      cache = new ChronicleMapCacheImpl<>(in, config, loader);
+      cache = new ChronicleMapCacheImpl<>(in, config, loader, metricMaker);
     } catch (IOException e) {
       throw new UncheckedIOException(e);
     }
diff --git a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheImpl.java b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheImpl.java
index 88c93c8..a7a5589 100644
--- a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheImpl.java
+++ b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheImpl.java
@@ -17,6 +17,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.CacheStats;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -47,7 +49,10 @@
 
   @SuppressWarnings("unchecked")
   ChronicleMapCacheImpl(
-      PersistentCacheDef<K, V> def, ChronicleMapCacheConfig config, CacheLoader<K, V> loader)
+      PersistentCacheDef<K, V> def,
+      ChronicleMapCacheConfig config,
+      CacheLoader<K, V> loader,
+      MetricMaker metricMaker)
       throws IOException {
     this.config = config;
     this.loader = loader;
@@ -55,6 +60,8 @@
         new InMemoryLRU<>(
             (int) Math.max(config.getMaxEntries() * config.getpercentageHotKeys() / 100, 1));
 
+    ChronicleMapStorageMetrics metrics = new ChronicleMapStorageMetrics(metricMaker);
+
     final Class<K> keyClass = (Class<K>) def.keyType().getRawType();
     final Class<TimedValue<V>> valueWrapperClass = (Class<TimedValue<V>>) (Class) TimedValue.class;
 
@@ -101,6 +108,28 @@
         config.getMaxBloatFactor(),
         store.remainingAutoResizes(),
         store.percentageFreeSpace());
+
+    metrics.registerCallBackMetrics(def.name(), store);
+  }
+
+  private static class ChronicleMapStorageMetrics {
+
+    private final MetricMaker metricMaker;
+
+    ChronicleMapStorageMetrics(MetricMaker metricMaker) {
+      this.metricMaker = metricMaker;
+    }
+
+    <K, V> void registerCallBackMetrics(String name, ChronicleMap<K, TimedValue<V>> store) {
+      String PERCENTAGE_FREE_SPACE_METRIC = "cache/chroniclemap/percentage_free_space_" + name;
+
+      metricMaker.newCallbackMetric(
+          PERCENTAGE_FREE_SPACE_METRIC,
+          Long.class,
+          new Description(
+              String.format("The amount of free space in the %s cache as a percentage", name)),
+          () -> (long) store.percentageFreeSpace());
+    }
   }
 
   public ChronicleMapCacheConfig getConfig() {
diff --git a/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheTest.java b/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheTest.java
index 124a7f3..e8f8e7d 100644
--- a/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheTest.java
+++ b/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/ChronicleMapCacheTest.java
@@ -14,15 +14,26 @@
 package com.googlesource.gerrit.modules.cache.chroniclemap;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.MetricRegistry;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.gerrit.acceptance.WaitUtil;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.nio.ByteBuffer;
@@ -40,6 +51,8 @@
 import org.junit.rules.TemporaryFolder;
 
 public class ChronicleMapCacheTest {
+  @Inject MetricMaker metricMaker;
+  @Inject MetricRegistry metricRegistry;
 
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
   private SitePaths sitePaths;
@@ -54,6 +67,18 @@
         new FileBasedConfig(
             sitePaths.resolve("etc").resolve("gerrit.config").toFile(), FS.DETECTED);
     gerritConfig.load();
+
+    setupMetrics();
+  }
+
+  public void setupMetrics() {
+    Injector injector = Guice.createInjector(new DropWizardMetricMaker.ApiModule());
+
+    LifecycleManager mgr = new LifecycleManager();
+    mgr.add(injector);
+    mgr.start();
+
+    injector.injectMembers(this);
   }
 
   @Test
@@ -326,6 +351,25 @@
     assertThat(cache.runningOutOfFreeSpace()).isFalse();
   }
 
+  @Test
+  public void shouldTriggerPercentageFreeMetric() throws Exception {
+    String cachedValue = UUID.randomUUID().toString();
+    String freeSpaceMetricName = "cache/chroniclemap/percentage_free_space_" + cachedValue;
+    gerritConfig.setInt("cache", cachedValue, "maxEntries", 2);
+    gerritConfig.setInt("cache", cachedValue, "avgKeySize", cachedValue.getBytes().length);
+    gerritConfig.setInt("cache", cachedValue, "avgValueSize", valueSize(cachedValue));
+    gerritConfig.save();
+
+    ChronicleMapCacheImpl<String, String> cache = newCacheWithMetrics(cachedValue);
+
+    assertThat(getMetric(freeSpaceMetricName).getValue()).isEqualTo(100);
+
+    cache.put(cachedValue, cachedValue);
+
+    WaitUtil.waitUntil(
+        () -> (long) getMetric(freeSpaceMetricName).getValue() < 100, Duration.ofSeconds(2));
+  }
+
   private int valueSize(String value) {
     final TimedValueMarshaller<String> marshaller =
         new TimedValueMarshaller<>(StringCacheSerializer.INSTANCE);
@@ -335,6 +379,11 @@
     return out.toByteArray().length;
   }
 
+  private ChronicleMapCacheImpl<String, String> newCacheWithMetrics(String cachedValue)
+      throws IOException {
+    return newCache(true, cachedValue, null, null, 1, metricMaker);
+  }
+
   private ChronicleMapCacheImpl<String, String> newCache(
       Boolean withLoader,
       @Nullable String cachedValue,
@@ -342,6 +391,23 @@
       @Nullable Duration refreshAfterWrite,
       Integer version)
       throws IOException {
+    return newCache(
+        withLoader,
+        cachedValue,
+        expireAfterWrite,
+        refreshAfterWrite,
+        version,
+        new DisabledMetricMaker());
+  }
+
+  private ChronicleMapCacheImpl<String, String> newCache(
+      Boolean withLoader,
+      @Nullable String cachedValue,
+      @Nullable Duration expireAfterWrite,
+      @Nullable Duration refreshAfterWrite,
+      Integer version,
+      MetricMaker metricMaker)
+      throws IOException {
     TestPersistentCacheDef cacheDef = new TestPersistentCacheDef(cachedValue);
 
     ChronicleMapCacheConfig config =
@@ -355,7 +421,8 @@
             refreshAfterWrite != null ? refreshAfterWrite : Duration.ZERO,
             version);
 
-    return new ChronicleMapCacheImpl<>(cacheDef, config, withLoader ? cacheDef.loader() : null);
+    return new ChronicleMapCacheImpl<>(
+        cacheDef, config, withLoader ? cacheDef.loader() : null, metricMaker);
   }
 
   private ChronicleMapCacheImpl<String, String> newCacheWithLoader(@Nullable String cachedValue)
@@ -375,11 +442,18 @@
     return newCache(false, null, null, null, 1);
   }
 
+  private <V> Gauge<V> getMetric(String name) {
+    @SuppressWarnings("unchecked")
+    Gauge<V> gauge = (Gauge<V>) metricRegistry.getMetrics().get(name);
+    assertWithMessage(name).that(gauge).isNotNull();
+    return gauge;
+  }
+
   public static class TestPersistentCacheDef implements PersistentCacheDef<String, String> {
 
     private final String loadedValue;
 
-    TestPersistentCacheDef(@Nullable String loadedValue) {
+    TestPersistentCacheDef(String loadedValue) {
 
       this.loadedValue = loadedValue;
     }
@@ -406,7 +480,7 @@
 
     @Override
     public String name() {
-      return "foo";
+      return loadedValue;
     }
 
     @Override