Merge changes I257382ad,I8ce16c97

* changes:
  ChecksSubmitRule: Catch all RuntimeExceptions
  Populate CombinedCheckState in query path from a cache
diff --git a/BUILD b/BUILD
index bfbc89d..c28bc66 100644
--- a/BUILD
+++ b/BUILD
@@ -21,4 +21,5 @@
     resource_jars = ["//plugins/checks/gr-checks:gr-checks-static"],
     resource_strip_prefix = "plugins/checks/resources",
     resources = glob(["resources/**/*"]),
+    deps = ["//plugins/checks/proto:cache_java_proto"],
 )
diff --git a/java/com/google/gerrit/plugins/checks/Checks.java b/java/com/google/gerrit/plugins/checks/Checks.java
index 4c80cd4..40a9c4c 100644
--- a/java/com/google/gerrit/plugins/checks/Checks.java
+++ b/java/com/google/gerrit/plugins/checks/Checks.java
@@ -65,6 +65,10 @@
   /**
    * Returns the combined check state of a given patch set.
    *
+   * <p>Most callers should prefer {@link
+   * com.google.gerrit.plugins.checks.CombinedCheckStateCache#reload} to automatically fix up the
+   * cache in case primary storage differs from the cached value.
+   *
    * @param projectName the name of the project.
    * @param patchSetId the ID of the patch set
    * @return the {@link CombinedCheckState} of the current patch set.
diff --git a/java/com/google/gerrit/plugins/checks/CombinedCheckStateCache.java b/java/com/google/gerrit/plugins/checks/CombinedCheckStateCache.java
new file mode 100644
index 0000000..c348550
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CombinedCheckStateCache.java
@@ -0,0 +1,243 @@
+// Copyright (C) 2019 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.plugins.checks;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.CacheStats;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.plugins.checks.api.CombinedCheckState;
+import com.google.gerrit.plugins.checks.cache.proto.Cache.CombinedCheckStateCacheKeyProto;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Cache of {@link CombinedCheckState} per change.
+ *
+ * <p>In the absence of plugin-defined index fields, this cache is used to performantly populate the
+ * {@code combinedState} field in {@code ChangeCheckInfo} in the query path.
+ */
+@Singleton
+public class CombinedCheckStateCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String NAME = "combined_check_state";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      public void configure() {
+        persist(NAME, CombinedCheckStateCacheKeyProto.class, CombinedCheckState.class)
+            .version(1)
+            .maximumWeight(10000)
+            .diskLimit(-1)
+            .keySerializer(new ProtobufSerializer<>(CombinedCheckStateCacheKeyProto.parser()))
+            .valueSerializer(new EnumCacheSerializer<>(CombinedCheckState.class))
+            .loader(Loader.class);
+      }
+    };
+  }
+
+  @Singleton
+  static class Metrics {
+    // Pair of metric and manual counters, to work around the fact that metric classes have no
+    // getters.
+    private final Timer1<Boolean> reloadLatency;
+    private final AtomicLongMap<Boolean> reloadCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      reloadLatency =
+          metricMaker.newTimer(
+              "checks/reload_combined_check_state",
+              new Description("Latency for reloading combined check state")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              Field.ofBoolean(
+                  "updated", "whether reloading resulted in updating the cached value"));
+      reloadCount = AtomicLongMap.create();
+    }
+
+    void recordReload(boolean updated, Duration elapsed) {
+      reloadLatency.record(updated, elapsed.toNanos(), NANOSECONDS);
+      reloadCount.incrementAndGet(updated);
+    }
+
+    long getReloadCount(boolean updated) {
+      return reloadCount.get(updated);
+    }
+  }
+
+  private final LoadingCache<CombinedCheckStateCacheKeyProto, CombinedCheckState> cache;
+  private final Loader loader;
+  private final Metrics metrics;
+
+  @Inject
+  CombinedCheckStateCache(
+      @Named(NAME) LoadingCache<CombinedCheckStateCacheKeyProto, CombinedCheckState> cache,
+      Loader loader,
+      Metrics metrics) {
+    this.cache = cache;
+    this.loader = loader;
+    this.metrics = metrics;
+  }
+
+  /**
+   * Get the state from the cache, computing it from checks ref if necessary.
+   *
+   * @param project project containing the change.
+   * @param psId patch set to which the state corresponds.
+   * @return combined check state.
+   */
+  public CombinedCheckState get(Project.NameKey project, PatchSet.Id psId) {
+    try {
+      return cache.get(key(project, psId));
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  /**
+   * Load the state from primary storage, and update the state in the cache only if it changed.
+   *
+   * <p>This method does a cache lookup followed by a write, which is inherently racy. It is
+   * intended to be used whenever we need to recompute the combined check state, for example when
+   * sending a {@code ChangeCheckInfo} to the client. As a result, inconsistencies between the cache
+   * and the actual state should tend to get fixed up immediately after a user views the change.
+   *
+   * @param project project containing the change.
+   * @param psId patch set to which the state corresponds.
+   * @return combined check state.
+   */
+  public CombinedCheckState reload(Project.NameKey project, PatchSet.Id psId) {
+    // Possible future optimization: short-circuit before calling this method, if an individual
+    // check transitioned between two CheckStates which would result in the same CombinedCheckState.
+    Stopwatch sw = Stopwatch.createStarted();
+    // Arbitrarily assume that the cache was updated unless we can conclusively prove it wasn't.
+    boolean updated = true;
+    try {
+      CombinedCheckStateCacheKeyProto key = key(project, psId);
+      CombinedCheckState newState = loader.load(key);
+      CombinedCheckState oldState = cache.getIfPresent(key);
+      if (newState != oldState) {
+        cache.put(key, newState);
+      } else {
+        updated = false;
+      }
+      return newState;
+    } finally {
+      metrics.recordReload(updated, sw.elapsed());
+    }
+  }
+
+  /**
+   * Update the state in the cache only if it changed.
+   *
+   * <p>This method returns no value and should be called when the caller may have recently updated
+   * the value in primary storage, but does not need the actual value. There is no guarantee that an
+   * update initiated by this method completes synchronously.
+   *
+   * <p>This method does a cache lookup followed by a write, which is inherently racy.
+   * Inconsistencies between the cache and the actual state should tend to get fixed up immediately
+   * after a user views the change, since the read path calls {@link #reload(Project.NameKey,
+   * PatchSet.Id)}.
+   *
+   * @param project project containing the change.
+   * @param psId patch set to which the state corresponds.
+   */
+  public void updateIfNecessary(Project.NameKey project, PatchSet.Id psId) {
+    // Possible future optimization: make this whole method async, in the FanOutExecutor.
+    try {
+      reload(project, psId);
+    } catch (RuntimeException e) {
+      logger.atWarning().withCause(e).log(
+          "failed to reload CombinedCheckState for %s in %s", psId, project);
+    }
+  }
+
+  /**
+   * Directly put a state into the cache.
+   *
+   * @param project project containing the change.
+   * @param psId patch set to which the state corresponds.
+   * @param state combined check state.
+   */
+  @VisibleForTesting
+  public void putForTest(Project.NameKey project, PatchSet.Id psId, CombinedCheckState state) {
+    cache.put(key(project, psId), state);
+  }
+
+  @VisibleForTesting
+  public long getReloadCount(boolean updated) {
+    return metrics.getReloadCount(updated);
+  }
+
+  @VisibleForTesting
+  public CacheStats getStats() {
+    return cache.stats();
+  }
+
+  private static CombinedCheckStateCacheKeyProto key(Project.NameKey project, PatchSet.Id psId) {
+    return CombinedCheckStateCacheKeyProto.newBuilder()
+        .setProject(project.get())
+        .setChangeId(psId.changeId().get())
+        .setPatchSetId(psId.get())
+        .build();
+  }
+
+  @Singleton
+  private static class Loader
+      extends CacheLoader<CombinedCheckStateCacheKeyProto, CombinedCheckState> {
+    private final Checks checks;
+
+    @Inject
+    Loader(Checks checks) {
+      this.checks = checks;
+    }
+
+    @Override
+    public CombinedCheckState load(CombinedCheckStateCacheKeyProto key) {
+      try {
+        return checks.getCombinedCheckState(
+            Project.nameKey(key.getProject()),
+            PatchSet.id(Change.id(key.getChangeId()), key.getPatchSetId()));
+      } catch (IOException e) {
+        throw new StorageException(e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/Module.java b/java/com/google/gerrit/plugins/checks/Module.java
index 102c057..8440501 100644
--- a/java/com/google/gerrit/plugins/checks/Module.java
+++ b/java/com/google/gerrit/plugins/checks/Module.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.plugins.checks.api.ApiModule;
 import com.google.gerrit.plugins.checks.api.ChangeCheckAttributeFactory;
 import com.google.gerrit.plugins.checks.api.ChangeCheckAttributeFactory.GetChangeOptions;
+import com.google.gerrit.plugins.checks.api.ChangeCheckAttributeFactory.QueryChangesOptions;
 import com.google.gerrit.plugins.checks.db.NoteDbCheckersModule;
 import com.google.gerrit.plugins.checks.rules.ChecksSubmitRule;
 import com.google.gerrit.server.DynamicOptions;
@@ -31,12 +32,15 @@
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
 
 public class Module extends FactoryModule {
   @Override
   protected void configure() {
     factory(CheckJson.AssistedFactory.class);
     install(new NoteDbCheckersModule());
+    install(CombinedCheckStateCache.module());
 
     bind(CapabilityDefinition.class)
         .annotatedWith(Exports.named(AdministrateCheckersCapability.NAME))
@@ -56,6 +60,12 @@
     bind(DynamicOptions.DynamicBean.class)
         .annotatedWith(Exports.named(GetChange.class))
         .to(GetChangeOptions.class);
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(QueryChanges.class))
+        .to(QueryChangesOptions.class);
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(Query.class))
+        .to(QueryChangesOptions.class);
 
     install(new ApiModule());
     install(new ChecksSubmitRule.Module());
diff --git a/java/com/google/gerrit/plugins/checks/api/ChangeCheckAttributeFactory.java b/java/com/google/gerrit/plugins/checks/api/ChangeCheckAttributeFactory.java
index d2f76b0..9c48851 100644
--- a/java/com/google/gerrit/plugins/checks/api/ChangeCheckAttributeFactory.java
+++ b/java/com/google/gerrit/plugins/checks/api/ChangeCheckAttributeFactory.java
@@ -14,15 +14,13 @@
 
 package com.google.gerrit.plugins.checks.api;
 
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.checks.Checks;
+import com.google.gerrit.plugins.checks.CombinedCheckStateCache;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
 /**
@@ -31,16 +29,24 @@
  */
 @Singleton
 public class ChangeCheckAttributeFactory implements ChangeAttributeFactory {
+  private static final String COMBINED_OPTION_NAME = "--combined";
+  private static final String COMBINED_OPTION_USAGE = "include combined check state";
+
   public static class GetChangeOptions implements DynamicBean {
-    @Option(name = "--combined", usage = "include combined check state")
+    @Option(name = COMBINED_OPTION_NAME, usage = COMBINED_OPTION_USAGE)
     boolean combined;
   }
 
-  private final Checks checks;
+  public static class QueryChangesOptions implements DynamicBean {
+    @Option(name = COMBINED_OPTION_NAME, usage = COMBINED_OPTION_USAGE)
+    boolean combined;
+  }
+
+  private final CombinedCheckStateCache combinedCheckStateCache;
 
   @Inject
-  ChangeCheckAttributeFactory(Checks checks) {
-    this.checks = checks;
+  ChangeCheckAttributeFactory(CombinedCheckStateCache combinedCheckStateCache) {
+    this.combinedCheckStateCache = combinedCheckStateCache;
   }
 
   @Override
@@ -49,25 +55,28 @@
     if (opts == null) {
       return null;
     }
-    try {
-      if (opts instanceof GetChangeOptions) {
-        return forGetChange(cd, (GetChangeOptions) opts);
-      }
-      // TODO(dborowitz): Compute from cache in query path.
-    } catch (StorageException | IOException e) {
-      throw new RuntimeException(e);
+    if (opts instanceof GetChangeOptions) {
+      return forGetChange(cd, (GetChangeOptions) opts);
+    } else if (opts instanceof QueryChangesOptions) {
+      return forQueryChanges(cd, (QueryChangesOptions) opts);
     }
     throw new IllegalStateException("unexpected options type: " + opts);
   }
 
-  private ChangeCheckInfo forGetChange(ChangeData cd, GetChangeOptions opts)
-      throws StorageException, IOException {
+  private ChangeCheckInfo forGetChange(ChangeData cd, GetChangeOptions opts) {
     if (opts == null || !opts.combined) {
       return null;
     }
-    ChangeCheckInfo info = new ChangeCheckInfo();
-    info.combinedState =
-        checks.getCombinedCheckState(cd.project(), cd.change().currentPatchSetId());
-    return info;
+    // Reload value in cache to fix up inconsistencies between cache and actual state.
+    return new ChangeCheckInfo(
+        combinedCheckStateCache.reload(cd.project(), cd.change().currentPatchSetId()));
+  }
+
+  private ChangeCheckInfo forQueryChanges(ChangeData cd, QueryChangesOptions opts) {
+    if (!opts.combined) {
+      return null;
+    }
+    return new ChangeCheckInfo(
+        combinedCheckStateCache.get(cd.project(), cd.change().currentPatchSetId()));
   }
 }
diff --git a/java/com/google/gerrit/plugins/checks/api/ChangeCheckInfo.java b/java/com/google/gerrit/plugins/checks/api/ChangeCheckInfo.java
index 257b8fc..2089bb5 100644
--- a/java/com/google/gerrit/plugins/checks/api/ChangeCheckInfo.java
+++ b/java/com/google/gerrit/plugins/checks/api/ChangeCheckInfo.java
@@ -27,11 +27,15 @@
 
   public ChangeCheckInfo() {}
 
-  public ChangeCheckInfo(String pluginName, CombinedCheckState combinedState) {
-    this.name = requireNonNull(pluginName);
+  public ChangeCheckInfo(CombinedCheckState combinedState) {
     this.combinedState = requireNonNull(combinedState);
   }
 
+  public ChangeCheckInfo(String pluginName, CombinedCheckState combinedState) {
+    this(combinedState);
+    this.name = requireNonNull(pluginName);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof ChangeCheckInfo)) {
diff --git a/java/com/google/gerrit/plugins/checks/db/NoteDbChecksUpdate.java b/java/com/google/gerrit/plugins/checks/db/NoteDbChecksUpdate.java
index 85d36e7..980a1fe 100644
--- a/java/com/google/gerrit/plugins/checks/db/NoteDbChecksUpdate.java
+++ b/java/com/google/gerrit/plugins/checks/db/NoteDbChecksUpdate.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.plugins.checks.CheckerUuid;
 import com.google.gerrit.plugins.checks.Checkers;
 import com.google.gerrit.plugins.checks.ChecksUpdate;
+import com.google.gerrit.plugins.checks.CombinedCheckStateCache;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -74,6 +75,7 @@
   private final ChangeNoteUtil noteUtil;
   private final Optional<IdentifiedUser> currentUser;
   private final Checkers checkers;
+  private final CombinedCheckStateCache combinedCheckStateCache;
 
   @AssistedInject
   NoteDbChecksUpdate(
@@ -82,9 +84,17 @@
       RetryHelper retryHelper,
       ChangeNoteUtil noteUtil,
       Checkers checkers,
+      CombinedCheckStateCache combinedCheckStateCache,
       @GerritPersonIdent PersonIdent personIdent) {
     this(
-        repoManager, gitRefUpdated, retryHelper, noteUtil, checkers, personIdent, Optional.empty());
+        repoManager,
+        gitRefUpdated,
+        retryHelper,
+        noteUtil,
+        checkers,
+        combinedCheckStateCache,
+        personIdent,
+        Optional.empty());
   }
 
   @AssistedInject
@@ -94,6 +104,7 @@
       RetryHelper retryHelper,
       ChangeNoteUtil noteUtil,
       Checkers checkers,
+      CombinedCheckStateCache combinedCheckStateCache,
       @GerritPersonIdent PersonIdent personIdent,
       @Assisted IdentifiedUser currentUser) {
     this(
@@ -102,6 +113,7 @@
         retryHelper,
         noteUtil,
         checkers,
+        combinedCheckStateCache,
         personIdent,
         Optional.of(currentUser));
   }
@@ -112,6 +124,7 @@
       RetryHelper retryHelper,
       ChangeNoteUtil noteUtil,
       Checkers checkers,
+      CombinedCheckStateCache combinedCheckStateCache,
       @GerritPersonIdent PersonIdent personIdent,
       Optional<IdentifiedUser> currentUser) {
     this.repoManager = repoManager;
@@ -121,6 +134,7 @@
     this.checkers = checkers;
     this.currentUser = currentUser;
     this.personIdent = personIdent;
+    this.combinedCheckStateCache = combinedCheckStateCache;
   }
 
   @Override
@@ -193,6 +207,7 @@
       refUpdate.update();
       RefUpdateUtil.checkResult(refUpdate);
 
+      combinedCheckStateCache.updateIfNecessary(checkKey.repository(), checkKey.patchSet());
       gitRefUpdated.fire(
           checkKey.repository(), refUpdate, currentUser.map(user -> user.state()).orElse(null));
       return readSingleCheck(checkKey, repo, rw, newCommitId);
diff --git a/java/com/google/gerrit/plugins/checks/rules/ChecksSubmitRule.java b/java/com/google/gerrit/plugins/checks/rules/ChecksSubmitRule.java
index 5a8cbf6..3d10ef7 100644
--- a/java/com/google/gerrit/plugins/checks/rules/ChecksSubmitRule.java
+++ b/java/com/google/gerrit/plugins/checks/rules/ChecksSubmitRule.java
@@ -20,10 +20,9 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRecord.Status;
 import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.plugins.checks.Checks;
+import com.google.gerrit.plugins.checks.CombinedCheckStateCache;
 import com.google.gerrit.plugins.checks.api.CombinedCheckState;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -33,7 +32,6 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
 
 @Singleton
@@ -55,11 +53,11 @@
     }
   }
 
-  private final Checks checks;
+  private final CombinedCheckStateCache combinedCheckStateCache;
 
   @Inject
-  public ChecksSubmitRule(Checks checks) {
-    this.checks = checks;
+  public ChecksSubmitRule(CombinedCheckStateCache combinedCheckStateCache) {
+    this.combinedCheckStateCache = combinedCheckStateCache;
   }
 
   @Override
@@ -70,7 +68,7 @@
     PatchSet.Id currentPathSetId;
     try {
       currentPathSetId = changeData.currentPatchSet().getId();
-    } catch (StorageException e) {
+    } catch (RuntimeException e) {
       String errorMessage =
           String.format("failed to load the current patch set of change %s", changeId);
       logger.atSevere().withCause(e).log(errorMessage);
@@ -79,8 +77,9 @@
 
     CombinedCheckState combinedCheckState;
     try {
-      combinedCheckState = checks.getCombinedCheckState(project, currentPathSetId);
-    } catch (IOException | StorageException e) {
+      // Reload value in cache to fix up inconsistencies between cache and actual state.
+      combinedCheckState = combinedCheckStateCache.reload(project, currentPathSetId);
+    } catch (RuntimeException e) {
       String errorMessage =
           String.format("failed to evaluate check states for change %s", changeId);
       logger.atSevere().withCause(e).log(errorMessage);
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD b/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD
index 9c33099..d8b32d7 100644
--- a/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD
@@ -10,6 +10,7 @@
     deps = [
         "//java/com/google/gerrit/git/testing",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/truth",
         "//javatests/com/google/gerrit/acceptance/rest/util",
         "//plugins/checks:checks__plugin",
         "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance",
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/ChangeCheckInfoIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ChangeCheckInfoIT.java
index e95fce3..d153f51 100644
--- a/javatests/com/google/gerrit/plugins/checks/acceptance/api/ChangeCheckInfoIT.java
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ChangeCheckInfoIT.java
@@ -18,35 +18,43 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.plugins.checks.api.BlockingCondition.STATE_NOT_PASSING;
+import static com.google.gerrit.truth.CacheStatsSubject.assertThat;
+import static com.google.gerrit.truth.CacheStatsSubject.cloneStats;
 
+import com.google.common.cache.CacheStats;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.plugins.checks.CheckKey;
 import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.CombinedCheckStateCache;
 import com.google.gerrit.plugins.checks.acceptance.AbstractCheckersTest;
 import com.google.gerrit.plugins.checks.api.ChangeCheckInfo;
+import com.google.gerrit.plugins.checks.api.CheckInput;
 import com.google.gerrit.plugins.checks.api.CheckState;
 import com.google.gerrit.plugins.checks.api.CombinedCheckState;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import java.util.List;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeCheckInfoIT extends AbstractCheckersTest {
+  private CombinedCheckStateCache cache;
   private Change.Id changeId;
   private PatchSet.Id psId;
 
   @Before
   public void setUp() throws Exception {
+    cache = plugin.getSysInjector().getInstance(CombinedCheckStateCache.class);
     psId = createChange().getPatchSetId();
     changeId = psId.changeId();
   }
 
   @Test
-  public void infoRequiresOption() throws Exception {
+  public void infoRequiresOptionViaGet() throws Exception {
     assertThat(
             getChangeCheckInfo(gApi.changes().id(changeId.get()).get(ImmutableListMultimap.of())))
         .isEmpty();
@@ -55,14 +63,25 @@
   }
 
   @Test
-  public void noCheckers() throws Exception {
-    assertThat(checkerOperations.checkersOf(project)).isEmpty();
-    assertThat(getChangeCheckInfo(changeId))
+  public void infoRequiresOptionViaQuery() throws Exception {
+    List<ChangeInfo> changeInfos = gApi.changes().query("change:" + changeId).get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(getChangeCheckInfo(changeInfos.get(0))).isEmpty();
+    assertThat(queryChangeCheckInfo(changeId))
         .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
   }
 
   @Test
-  public void backfillRelevantChecker() throws Exception {
+  public void noCheckers() throws Exception {
+    assertThat(checkerOperations.checkersOf(project)).isEmpty();
+    assertThat(getChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+  }
+
+  @Test
+  public void backfillRelevantCheckerViaGet() throws Exception {
     checkerOperations.newChecker().repository(project).query("topic:foo").create();
     assertThat(getChangeCheckInfo(changeId))
         .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
@@ -72,7 +91,7 @@
   }
 
   @Test
-  public void combinedCheckState() throws Exception {
+  public void combinedCheckStateViaGet() throws Exception {
     CheckerUuid optionalCheckerUuid = checkerOperations.newChecker().repository(project).create();
     CheckerUuid requiredCheckerUuid =
         checkerOperations
@@ -96,11 +115,135 @@
         .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.FAILED));
   }
 
+  @Test
+  public void combinedCheckStateViaQuery() throws Exception {
+    CacheStats start = cloneStats(cache.getStats());
+    long startReloadsFalse = cache.getReloadCount(false);
+    long startReloadsTrue = cache.getReloadCount(true);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    // Cache was prepopulated during update.
+    assertThat(cache.getStats()).since(start).hasHitCount(1);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    assertThat(cache.getStats()).since(start).hasHitCount(2);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+  }
+
+  @Test
+  public void loadingCombinedCheckStateViaGetUpdatesCache() throws Exception {
+    cache.putForTest(project, psId, CombinedCheckState.FAILED);
+
+    CacheStats start = cloneStats(cache.getStats());
+    long startReloadsFalse = cache.getReloadCount(false);
+    long startReloadsTrue = cache.getReloadCount(true);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.FAILED));
+    assertThat(cache.getStats()).since(start).hasHitCount(1);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+
+    assertThat(getChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    // Incurs reloads in both submit rule and attribute factory paths.
+    assertThat(cache.getStats()).since(start).hasHitCount(3);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(1);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(1);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    assertThat(cache.getStats()).since(start).hasHitCount(4);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(1);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(1);
+  }
+
+  @Test
+  public void updatingCheckStateUpdatesCache() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+    cache.putForTest(project, psId, CombinedCheckState.IN_PROGRESS);
+
+    CacheStats start = cloneStats(cache.getStats());
+    long startReloadsFalse = cache.getReloadCount(false);
+    long startReloadsTrue = cache.getReloadCount(true);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.IN_PROGRESS));
+    assertThat(cache.getStats()).since(start).hasHitCount(1);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+
+    // Set non-required checker to FAILED, updating combined check state to WARNING.
+    CheckInput checkInput = new CheckInput();
+    checkInput.state = CheckState.FAILED;
+    checksApiFactory.revision(psId).id(checkerUuid).update(checkInput);
+
+    // Incurs reload after updating check state.
+    assertThat(cache.getStats()).since(start).hasHitCount(2);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(1);
+
+    assertThat(queryChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.WARNING));
+    assertThat(cache.getStats()).since(start).hasHitCount(3);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(0);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(1);
+  }
+
+  @Test
+  public void repeatedlyLoadingCombinedCheckStateViaGetResultsInOnlyNoOpReloads() throws Exception {
+    CacheStats start = cloneStats(cache.getStats());
+    long startReloadsFalse = cache.getReloadCount(false);
+    long startReloadsTrue = cache.getReloadCount(true);
+
+    assertThat(getChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    // Incurs reloads in both submit rule and attribute factory paths.
+    assertThat(cache.getStats()).since(start).hasHitCount(2);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(2);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+
+    assertThat(getChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    assertThat(cache.getStats()).since(start).hasHitCount(4);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(4);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+
+    assertThat(getChangeCheckInfo(changeId))
+        .hasValue(new ChangeCheckInfo("checks", CombinedCheckState.NOT_RELEVANT));
+    assertThat(cache.getStats()).since(start).hasHitCount(6);
+    assertThat(cache.getStats()).since(start).hasMissCount(0);
+    assertThat(cache.getReloadCount(false) - startReloadsFalse).isEqualTo(6);
+    assertThat(cache.getReloadCount(true) - startReloadsTrue).isEqualTo(0);
+  }
+
   private Optional<ChangeCheckInfo> getChangeCheckInfo(Change.Id id) throws Exception {
     return getChangeCheckInfo(
         gApi.changes().id(id.get()).get(ImmutableListMultimap.of("checks--combined", "true")));
   }
 
+  private Optional<ChangeCheckInfo> queryChangeCheckInfo(Change.Id id) throws Exception {
+    List<ChangeInfo> changeInfos =
+        gApi.changes().query("change:" + id).withPluginOption("checks--combined", "true").get();
+    assertThat(changeInfos).hasSize(1);
+    return getChangeCheckInfo(changeInfos.get(0));
+  }
+
   private static Optional<ChangeCheckInfo> getChangeCheckInfo(ChangeInfo changeInfo) {
     if (changeInfo.plugins == null) {
       return Optional.empty();
diff --git a/javatests/com/google/gerrit/plugins/checks/rules/ChecksSubmitRuleTest.java b/javatests/com/google/gerrit/plugins/checks/rules/ChecksSubmitRuleTest.java
index f892b37..7101a0f 100644
--- a/javatests/com/google/gerrit/plugins/checks/rules/ChecksSubmitRuleTest.java
+++ b/javatests/com/google/gerrit/plugins/checks/rules/ChecksSubmitRuleTest.java
@@ -21,8 +21,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.checks.Checks;
+import com.google.gerrit.plugins.checks.CombinedCheckStateCache;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -38,12 +37,12 @@
   @Test
   public void loadingCurrentPatchSetFails() throws Exception {
     ChecksSubmitRule checksSubmitRule =
-        new ChecksSubmitRule(EasyMock.createStrictMock(Checks.class));
+        new ChecksSubmitRule(EasyMock.createStrictMock(CombinedCheckStateCache.class));
 
     ChangeData cd = EasyMock.createStrictMock(ChangeData.class);
     expect(cd.project()).andReturn(Project.nameKey("My-Project"));
     expect(cd.getId()).andReturn(Change.id(1));
-    expect(cd.currentPatchSet()).andThrow(new StorageException("Fail for test"));
+    expect(cd.currentPatchSet()).andThrow(new IllegalStateException("Fail for test"));
     replay(cd);
 
     Collection<SubmitRecord> submitRecords =
@@ -53,12 +52,12 @@
 
   @Test
   public void getCombinedCheckStateFails() throws Exception {
-    Checks checks = EasyMock.createStrictMock(Checks.class);
-    expect(checks.getCombinedCheckState(anyObject(), anyObject()))
-        .andThrow(new StorageException("Fail for test"));
-    replay(checks);
+    CombinedCheckStateCache cache = EasyMock.createStrictMock(CombinedCheckStateCache.class);
+    expect(cache.reload(anyObject(), anyObject()))
+        .andThrow(new IllegalStateException("Fail for test"));
+    replay(cache);
 
-    ChecksSubmitRule checksSubmitRule = new ChecksSubmitRule(checks);
+    ChecksSubmitRule checksSubmitRule = new ChecksSubmitRule(cache);
 
     Change.Id changeId = Change.id(1);
     ChangeData cd = EasyMock.createStrictMock(ChangeData.class);
diff --git a/proto/BUILD b/proto/BUILD
new file mode 100644
index 0000000..6e3b839
--- /dev/null
+++ b/proto/BUILD
@@ -0,0 +1,12 @@
+package(default_visibility = ["//plugins/checks:visibility"])
+
+proto_library(
+    name = "cache_proto",
+    srcs = ["cache.proto"],
+)
+
+java_proto_library(
+    name = "cache_java_proto",
+    visibility = ["//visibility:public"],
+    deps = [":cache_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
new file mode 100644
index 0000000..e658918
--- /dev/null
+++ b/proto/cache.proto
@@ -0,0 +1,32 @@
+// Copyright (C) 2019 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.
+
+syntax = "proto3";
+
+package gerrit.plugins.checks.cache;
+
+option java_package = "com.google.gerrit.plugins.checks.cache.proto";
+
+// Cache key for CombinedCheckStateCache.
+// Next ID: 4
+message CombinedCheckStateCacheKeyProto {
+  // Project name for the change.
+  string project = 1;
+
+  // Change number for the change.
+  int32 change_id = 2;
+
+  // Patch set to get combined state for.
+  int32 patch_set_id = 3;
+}