Merge changes Icdf4293f,I5eda9d41

* changes:
  Remove unused logger instance
  Rework SearchingChangeCacheImpl
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index b587e83..94ee7ab 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -819,18 +819,23 @@
 requires two HTTP requests, and this cache tries to carry state from
 the first request into the second to ensure it can complete.
 
-cache `"changes"`::
+cache `"change_refs"`::
++
+Cache entries are used to compute change ref visibility efficiently. Entries
+contain the minimal required information for this task.
 +
 The size of `memoryLimit` determines the number of projects for which
 all changes will be cached. If the cache is set to 1024, this means all
-changes for up to 1024 projects can be held in the cache.
+at maximum 1024 changes can be held in the cache.
 +
-Default value is 0 (disabled). It is disabled by default due to the fact
-that change updates are not communicated between Gerrit servers. Hence
-this cache should be disabled in an multi-master/multi-slave setup.
+Default value is 10.000.
 +
-The cache should be flushed whenever the database changes table is modified
-outside of Gerrit.
+If the size is set to 0, the cache is disabled. The change index will not
+be used at all. Instead, the change notes cache is used directly.
++
+A good size for this cache is twice the number of changes that the Gerrit
+instance has to allow it to hold all current changes and account for
+growth.
 
 cache `"diff"`::
 +
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index aa5362b..a8552f4 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -66,9 +66,9 @@
 import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
+import com.google.gerrit.server.git.ChangeRefCache;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
@@ -288,7 +288,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
-    modules.add(new SearchingChangeCacheImpl.Module());
+    modules.add(new ChangeRefCache.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index c280a2d..3c4f89b 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -73,8 +73,8 @@
 import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
+import com.google.gerrit.server.git.ChangeRefCache;
 import com.google.gerrit.server.git.GarbageCollectionModule;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.index.IndexModule;
@@ -393,7 +393,7 @@
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
 
-    modules.add(new SearchingChangeCacheImpl.Module(slave));
+    modules.add(new ChangeRefCache.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index b0c1c25..f263786 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -56,8 +56,8 @@
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.git.ChangeRefCache;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -133,7 +133,7 @@
 
     // As Reindex is a batch program, don't assume the index is available for
     // the change cache.
-    bind(SearchingChangeCacheImpl.class).toProvider(Providers.of(null));
+    bind(ChangeRefCache.class).toProvider(Providers.of(null));
 
     bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
         .annotatedWith(AdministrateServerGroups.class)
diff --git a/java/com/google/gerrit/server/git/ChangeRefCache.java b/java/com/google/gerrit/server/git/ChangeRefCache.java
new file mode 100644
index 0000000..dc1b946
--- /dev/null
+++ b/java/com/google/gerrit/server/git/ChangeRefCache.java
@@ -0,0 +1,226 @@
+// 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.server.git;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Cache for the minimal information per change that we need to compute visibility. Used for ref
+ * filtering.
+ *
+ * <p>This class is thread safe.
+ */
+@Singleton
+public class ChangeRefCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  static final String ID_CACHE = "change_refs";
+
+  public static class Module extends CacheModule {
+    @Override
+    protected void configure() {
+      cache(ID_CACHE, Key.class, new TypeLiteral<CachedChange>() {})
+          .maximumWeight(10000)
+          .loader(Loader.class);
+
+      bind(ChangeRefCache.class);
+    }
+  }
+
+  @AutoValue
+  abstract static class Key {
+    abstract Project.NameKey project();
+
+    abstract Change.Id changeId();
+
+    abstract ObjectId metaId();
+  }
+
+  @AutoValue
+  abstract static class CachedChange {
+    // Subset of fields in ChangeData, specifically fields needed to serve
+    // VisibleRefFilter without touching the database. More can be added as
+    // necessary.
+    abstract Change change();
+
+    @Nullable
+    abstract ReviewerSet reviewers();
+  }
+
+  private final LoadingCache<Key, CachedChange> cache;
+  private final ChangeData.Factory changeDataFactory;
+  private final OneOffRequestContext requestContext;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final GerritOptions gerritOptions;
+  private final Config gerritConfig;
+  private final Set<Project.NameKey> bootstrappedProjects;
+
+  @Inject
+  ChangeRefCache(
+      @Named(ID_CACHE) LoadingCache<Key, CachedChange> cache,
+      ChangeData.Factory changeDataFactory,
+      OneOffRequestContext requestContext,
+      Provider<InternalChangeQuery> queryProvider,
+      GerritOptions gerritOptions,
+      @GerritServerConfig Config gerritConfig) {
+    this.cache = cache;
+    this.changeDataFactory = changeDataFactory;
+    this.requestContext = requestContext;
+    this.queryProvider = queryProvider;
+    this.gerritOptions = gerritOptions;
+    this.gerritConfig = gerritConfig;
+    // Uses a CopyOnWriteArraySet internally to keep track of projects that are already
+    // bootstrapped. This is efficient because we read from the set on every call to this method to
+    // check if bootstrapping is required. Writes occur only if we bootstrapped, so once per
+    // project.
+    this.bootstrappedProjects = new CopyOnWriteArraySet<>();
+  }
+
+  /**
+   * Read changes from the cache.
+   *
+   * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
+   * There is no guarantee that additional fields are populated, although they can be.
+   *
+   * @param project project to read.
+   * @param changeId change ID to read
+   * @param metaId object ID of the meta branch to read. This is only used to ensure consistency. It
+   *     does not allow for reading non-current meta versions.
+   * @return change data
+   * @throws IllegalArgumentException in case no change is found
+   */
+  public ChangeData getChangeData(Project.NameKey project, Change.Id changeId, ObjectId metaId) {
+    Key key = new AutoValue_ChangeRefCache_Key(project, changeId, metaId);
+    CachedChange cached = cache.getUnchecked(key);
+    if (cached == null) {
+      throw new IllegalArgumentException("no change found for key " + key);
+    }
+    ChangeData cd = changeDataFactory.create(cached.change());
+    cd.setReviewers(cached.reviewers());
+    return cd;
+  }
+
+  /**
+   * This method bootstraps the cache by querying the change index if it hasn't been bootstrapped
+   * before, in which case it is a cheap no-op.
+   *
+   * @param project the project to bootstrap
+   */
+  public void bootstrapIfNecessary(Project.NameKey project) {
+    if (!gerritOptions.enableMasterFeatures()) {
+      // Bootstrapping using the ChangeIndex is only supported on master in a master-slave replica.
+      return;
+    }
+    if (gerritConfig.getInt("cache", ID_CACHE, "memoryLimit", -1) == 0) {
+      // The cache is disabled, don't bother bootstrapping.
+      return;
+    }
+    if (bootstrappedProjects.contains(project)) {
+      // We have bootstrapped for this project before. If the cache is too small, we might have
+      // evicted all entries by now. Don't bother about this though as we don't want to add the
+      // complexity of checking for existing projects, since that might not be authoritative as well
+      // since we could have already evicted the majority of the entries.
+      return;
+    }
+
+    try (TraceTimer ignored =
+            TraceContext.newTimer("bootstrapping ChangeRef cache for project " + project);
+        ManualRequestContext ignored2 = requestContext.open()) {
+      List<ChangeData> cds =
+          queryProvider
+              .get()
+              .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER, ChangeField.REF_STATE)
+              .byProject(project);
+      for (ChangeData cd : cds) {
+        Set<RefState> refStates = RefState.parseStates(cd.getRefStates()).get(project);
+        Optional<RefState> refState =
+            refStates
+                .stream()
+                .filter(r -> r.ref().equals(RefNames.changeMetaRef(cd.getId())))
+                .findAny();
+        if (!refState.isPresent()) {
+          continue;
+        }
+        cache.put(
+            new AutoValue_ChangeRefCache_Key(project, cd.change().getId(), refState.get().id()),
+            new AutoValue_ChangeRefCache_CachedChange(cd.change(), cd.getReviewers()));
+      }
+      // Mark the project as bootstrapped. We could have bootstrapped it multiple times for
+      // simultaneous requests. We accept this in favor of less thread synchronization and
+      // complexity.
+      bootstrappedProjects.add(project);
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(
+          "unable to bootstrap ChangeRef cache for project " + project);
+    }
+  }
+
+  static class Loader extends CacheLoader<Key, CachedChange> {
+    private final ChangeNotes.Factory notesFactory;
+
+    @Inject
+    Loader(ChangeNotes.Factory notesFactory) {
+      this.notesFactory = notesFactory;
+    }
+
+    @Override
+    public CachedChange load(Key key) throws Exception {
+      ChangeNotes notes = notesFactory.create(key.project(), key.changeId());
+      if (notes.getMetaId().equals(key.metaId())) {
+        return new AutoValue_ChangeRefCache_CachedChange(notes.getChange(), notes.getReviewers());
+      }
+      throw new NoSuchElementException("unable to load change");
+    }
+  }
+
+  @VisibleForTesting
+  public void resetBootstrappedProjects() {
+    bootstrappedProjects.clear();
+  }
+}
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
deleted file mode 100644
index 0692ccf..0000000
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2012 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.server.git;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-
-@Singleton
-public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  static final String ID_CACHE = "changes";
-
-  public static class Module extends CacheModule {
-    private final boolean slave;
-
-    public Module() {
-      this(false);
-    }
-
-    public Module(boolean slave) {
-      this.slave = slave;
-    }
-
-    @Override
-    protected void configure() {
-      if (slave) {
-        bind(SearchingChangeCacheImpl.class).toProvider(Providers.of(null));
-      } else {
-        cache(ID_CACHE, Project.NameKey.class, new TypeLiteral<List<CachedChange>>() {})
-            .maximumWeight(0)
-            .loader(Loader.class);
-
-        bind(SearchingChangeCacheImpl.class);
-        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-            .to(SearchingChangeCacheImpl.class);
-      }
-    }
-  }
-
-  @AutoValue
-  abstract static class CachedChange {
-    // Subset of fields in ChangeData, specifically fields needed to serve
-    // VisibleRefFilter without touching the database. More can be added as
-    // necessary.
-    abstract Change change();
-
-    @Nullable
-    abstract ReviewerSet reviewers();
-  }
-
-  private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
-  private final ChangeData.Factory changeDataFactory;
-
-  @Inject
-  SearchingChangeCacheImpl(
-      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<CachedChange>> cache,
-      ChangeData.Factory changeDataFactory) {
-    this.cache = cache;
-    this.changeDataFactory = changeDataFactory;
-  }
-
-  /**
-   * Read changes for the project from the secondary index.
-   *
-   * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
-   * Additional stored fields are not loaded from the index.
-   *
-   * @param project project to read.
-   * @return list of known changes; empty if no changes.
-   */
-  public List<ChangeData> getChangeData(Project.NameKey project) {
-    try {
-      List<CachedChange> cached = cache.get(project);
-      List<ChangeData> cds = new ArrayList<>(cached.size());
-      for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(cc.change());
-        cd.setReviewers(cc.reviewers());
-        cds.add(cd);
-      }
-      return Collections.unmodifiableList(cds);
-    } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot fetch changes for %s", project);
-      return Collections.emptyList();
-    }
-  }
-
-  @Override
-  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
-    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
-      cache.invalidate(new Project.NameKey(event.getProjectName()));
-    }
-  }
-
-  static class Loader extends CacheLoader<Project.NameKey, List<CachedChange>> {
-    private final OneOffRequestContext requestContext;
-    private final Provider<InternalChangeQuery> queryProvider;
-
-    @Inject
-    Loader(OneOffRequestContext requestContext, Provider<InternalChangeQuery> queryProvider) {
-      this.requestContext = requestContext;
-      this.queryProvider = queryProvider;
-    }
-
-    @Override
-    public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading changes of project %s", key);
-          ManualRequestContext ctx = requestContext.open()) {
-        List<ChangeData> cds =
-            queryProvider
-                .get()
-                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
-                .byProject(key);
-        List<CachedChange> result = new ArrayList<>(cds.size());
-        for (ChangeData cd : cds) {
-          result.add(
-              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
-        }
-        return Collections.unmodifiableList(result);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 879fddd..56c22e1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
@@ -22,10 +21,7 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
@@ -41,12 +37,11 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.ChangeRefCache;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -55,10 +50,11 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -66,15 +62,13 @@
 import org.eclipse.jgit.lib.SymbolicRef;
 
 class DefaultRefFilter {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   interface Factory {
     DefaultRefFilter create(ProjectControl projectControl);
   }
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
+  @Nullable private final ChangeRefCache changeCache;
   private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
   private final ProjectControl projectControl;
@@ -84,14 +78,14 @@
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
-
-  private Map<Change.Id, Branch.NameKey> visibleChanges;
+  private final Map<Change.Id, Branch.NameKey> visibleChanges;
+  private final Set<Change.Id> inVisibleChanges;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
+      @Nullable ChangeRefCache changeCache,
       GroupCache groupCache,
       PermissionBackend permissionBackend,
       @GerritServerConfig Config config,
@@ -121,6 +115,9 @@
                     "Rate of ref filter operations where we skip full evaluation"
                         + " because the user can read all refs")
                 .setRate());
+    // TODO(hiesel): Rework who can see change edits so that we can keep just a single set here.
+    this.visibleChanges = new HashMap<>();
+    this.inVisibleChanges = new HashSet<>();
   }
 
   Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
@@ -161,6 +158,7 @@
 
     Map<String, Ref> result = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
+    changeCache.bootstrapIfNecessary(projectState.getNameKey());
 
     for (Ref ref : refs.values()) {
       String name = ref.getName();
@@ -299,110 +297,64 @@
   }
 
   private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChanges = visibleChangesByScan(repo);
-      } else {
-        visibleChanges = visibleChangesBySearch();
-      }
+    if (visibleChanges.containsKey(changeId)) {
+      return true;
     }
-    return visibleChanges.containsKey(changeId);
+    if (inVisibleChanges.contains(changeId)) {
+      return false;
+    }
+    // TODO(hiesel): The project state should be checked once in the beginning an left alone
+    // thereafter.
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+
+    Project.NameKey project = projectState.getNameKey();
+    try {
+      ChangeData cd =
+          changeCache.getChangeData(
+              project, changeId, repo.exactRef(RefNames.changeMetaRef(changeId)).getObjectId());
+      ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
+      try {
+        permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
+        visibleChanges.put(cd.getId(), cd.change().getDest());
+        return true;
+      } catch (AuthException e) {
+        inVisibleChanges.add(changeId);
+        return false;
+      }
+    } catch (OrmException | IOException e) {
+      throw new PermissionBackendException(
+          "Cannot load change " + changeId + " for project " + project + ", assuming not visible",
+          e);
+    }
   }
 
   private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
-    // Initialize if it wasn't yet
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
     if (id == null) {
       return false;
     }
+    if (!visible(repo, id)) {
+      // Can't see the change, so can't see the edit.
+      return false;
+    }
+
     if (user.isIdentifiedUser()
-        && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visible(repo, id)) {
+        && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))) {
+      // Own edit
       return true;
     }
-    if (visibleChanges.containsKey(id)) {
-      try {
-        // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-        permissionBackendForProject
-            .ref(visibleChanges.get(id).get())
-            .check(RefPermission.READ_PRIVATE_CHANGES);
-        return true;
-      } catch (AuthException e) {
-        return false;
-      }
-    }
-    return false;
-  }
-
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch()
-      throws PermissionBackendException {
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        try {
-          permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-      }
-      return visibleChanges;
-    } catch (OrmException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
-    }
-  }
-
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo)
-      throws PermissionBackendException {
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repo, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
-    }
-
-    Map<Change.Id, Branch.NameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        result.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-    return result;
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
-    if (r.error().isPresent()) {
-      logger.atWarning().withCause(r.error().get()).log(
-          "Failed to load change %s in %s", r.id(), projectState.getName());
-      return null;
-    }
-
-    if (!projectState.statePermitsRead()) {
-      return null;
-    }
 
     try {
-      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
-      return r.notes();
+      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      permissionBackendForProject
+          .ref(visibleChanges.get(id).get())
+          .check(RefPermission.READ_PRIVATE_CHANGES);
+      return true;
     } catch (AuthException e) {
-      // Skip.
+      return false;
     }
-    return null;
   }
 
   private boolean isMetadata(String name) {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index c8cea6f..2a030ac 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -58,10 +58,10 @@
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
+import com.google.gerrit.server.git.ChangeRefCache;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
@@ -168,7 +168,7 @@
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
-    install(new SearchingChangeCacheImpl.Module());
+    install(new ChangeRefCache.Module());
     factory(GarbageCollection.Factory.class);
     install(new AuditModule());
 
diff --git a/javatests/com/google/gerrit/acceptance/git/ChangeRefCacheIT.java b/javatests/com/google/gerrit/acceptance/git/ChangeRefCacheIT.java
new file mode 100644
index 0000000..bf7f726
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ChangeRefCacheIT.java
@@ -0,0 +1,269 @@
+// 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.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.ChangeRefCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests the ChangeRefCache by running ls-remote calls and conditionally disabling the index and
+ * NoteDb. The cache is enabled by default.
+ *
+ * <p>Why are we not just testing ChangeRefCache directly? Our ref filtering code is rather complex
+ * and it is easy to get something wrong there. We want our assumptions about the performance of the
+ * cache to be validated against the entire component rather than just the cache.
+ */
+@NoHttpd
+public class ChangeRefCacheIT extends AbstractDaemonTest {
+
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeRefCache changeRefCache;
+
+  @Before
+  public void setUp() throws Exception {
+    // We want full ref evaluation so that we hit the cache every time.
+    baseConfig.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+  }
+
+  /**
+   * Ensure we use only the change index for getting initial data populated and don't touch NoteDb.
+   */
+  @Test
+  public void useIndexForBootstrapping() throws Exception {
+    ChangeData change = createChange().getChange();
+    // TODO(hiesel) Rework as AutoClosable. Here and below.
+    changeRefCache.resetBootstrappedProjects();
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change.getId()),
+          change.currentPatchSet().getId().toRefName());
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
+  /**
+   * Ensure we use only the change index for getting initial data populated and don't require any
+   * storage backend after that as long the data didn't change.
+   */
+  @Test
+  public void serveResultsFromCacheAfterInitialBootstrap() throws Exception {
+    ChangeData change = createChange().getChange();
+    changeRefCache.resetBootstrappedProjects();
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change.getId()),
+          change.currentPatchSet().getId().toRefName());
+    } finally {
+      enableDb(ctx);
+    }
+
+    // No change since our first call, so this time we don't bootstrap or touch NoteDb
+    AcceptanceTestRequestScope.Context ctx2 = disableDb();
+    try {
+      try (AutoCloseable ignored = disableChangeIndex()) {
+        assertUploadPackRefs(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(change.getId()),
+            change.currentPatchSet().getId().toRefName());
+      }
+    } finally {
+      enableDb(ctx2);
+    }
+  }
+
+  /**
+   * Ensure we use only the change index for getting initial data populated and NoteDb for reloading
+   * data that changed since.
+   */
+  @Test
+  public void useIndexForBootstrappingAndDbForDeltaReload() throws Exception {
+    ChangeData change1 = createChange().getChange();
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    // Bootstrap: No NoteDb access as we expect it to use the index.
+    changeRefCache.resetBootstrappedProjects();
+    try {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName());
+    } finally {
+      enableDb(ctx);
+    }
+    // Delta reload: No index access as we expect it to use NoteDb.
+    ChangeData change2 = createChange().getChange();
+    try (AutoCloseable ignored = disableChangeIndex()) {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName(),
+          RefNames.changeMetaRef(change2.getId()),
+          change2.currentPatchSet().getId().toRefName());
+    }
+  }
+
+  /**
+   * Ensure we use only the change index for getting initial data populated and NoteDb for reloading
+   * data that changed since.
+   */
+  @Test
+  public void useDbForDeltaReloadOnNewPatchSet() throws Exception {
+    ChangeData change1 =
+        pushFactory
+            .create(admin.getIdent(), testRepo, "original subject", "a", "a1")
+            .to("refs/for/master")
+            .getChange();
+
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    // Bootstrap: No NoteDb access as we expect it to use the index.
+    changeRefCache.resetBootstrappedProjects();
+    try {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName());
+    } finally {
+      enableDb(ctx);
+    }
+
+    // Delta reload: No index access as we expect it to use NoteDb.
+    ChangeData change2 =
+        pushFactory
+            .create(
+                admin.getIdent(), testRepo, "subject2", "a", "a2", change1.change().getKey().get())
+            .to("refs/for/master")
+            .getChange();
+    List<PatchSet> patchSets = ImmutableList.copyOf(change2.patchSets());
+    assertThat(patchSets).hasSize(2);
+    try (AutoCloseable ctx2 = disableChangeIndex()) {
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          patchSets.get(0).getId().toRefName(),
+          patchSets.get(1).getId().toRefName());
+    }
+  }
+
+  /**
+   * Ensure we use only the change index for getting initial data populated and NoteDb for reloading
+   * data that changed since.
+   */
+  @Test
+  public void useDbForIterativeFetchingOnMetadataChange() throws Exception {
+    ChangeData change1 =
+        pushFactory
+            .create(admin.getIdent(), testRepo, "original subject", "a", "a1")
+            .to("refs/for/master")
+            .getChange();
+    // Bootstrap: No NoteDb access as we expect it to use the index.
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      changeRefCache.resetBootstrappedProjects();
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName());
+    } finally {
+      enableDb(ctx);
+    }
+
+    try (AutoCloseable ignored = disableChangeIndex()) {
+      // user can see public change
+      setApiUser(user);
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName());
+    }
+
+    // Delta reload: No index access as we expect it to use NoteDb.
+    setApiUser(admin);
+    gApi.changes().id(change1.getId().id).setPrivate(true);
+
+    try (AutoCloseable ignored = disableChangeIndex()) {
+      // user can't see private change from admin
+      setApiUser(user);
+      assertUploadPackRefs("HEAD", "refs/heads/master");
+    }
+
+    // admin adds the user as reviewer
+    setApiUser(admin);
+    gApi.changes().id(change1.getId().id).addReviewer(user.email);
+
+    try (AutoCloseable ignored = disableChangeIndex()) {
+      // Use can see private change
+      setApiUser(user);
+      assertUploadPackRefs(
+          "HEAD",
+          "refs/heads/master",
+          RefNames.changeMetaRef(change1.getId()),
+          change1.currentPatchSet().getId().toRefName());
+    }
+  }
+
+  private void assertUploadPackRefs(String... expectedRefs) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(repo, permissionBackend.user(user(user)).project(project), expectedRefs);
+    }
+  }
+
+  private void assertRefs(
+      Repository repo, PermissionBackend.ForProject forProject, String... expectedRefs)
+      throws Exception {
+    Map<String, Ref> all = getAllRefs(repo);
+    assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+        .containsExactlyElementsIn(expectedRefs);
+  }
+
+  private static Map<String, Ref> getAllRefs(Repository repo) throws IOException {
+    return repo.getRefDatabase()
+        .getRefs()
+        .stream()
+        .collect(toMap(Ref::getName, Function.identity()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index d1c5efb..79a6957 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -25,7 +25,6 @@
 import com.google.common.base.Predicates;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -359,7 +358,6 @@
           repo,
           permissionBackend.user(user(user)).project(project),
           // Can't use stored values from the index so DB must be enabled.
-          false,
           "HEAD",
           psRef1,
           metaRef1,
@@ -379,10 +377,10 @@
   @Test
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
     try (Repository repo = repoManager.openRepository(allProjects)) {
-      assertRefs(repo, newFilter(allProjects, user), true);
+      assertRefs(repo, newFilter(allProjects, user));
 
       allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      assertRefs(repo, newFilter(allProjects, user), true, "refs/sequences/changes");
+      assertRefs(repo, newFilter(allProjects, user), "refs/sequences/changes");
     }
   }
 
@@ -730,29 +728,16 @@
    */
   private void assertUploadPackRefs(String... expectedRefs) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(repo, permissionBackend.user(user(user)).project(project), true, expectedRefs);
+      assertRefs(repo, permissionBackend.user(user(user)).project(project), expectedRefs);
     }
   }
 
   private void assertRefs(
-      Repository repo,
-      PermissionBackend.ForProject forProject,
-      boolean disableDb,
-      String... expectedRefs)
+      Repository repo, PermissionBackend.ForProject forProject, String... expectedRefs)
       throws Exception {
-    AcceptanceTestRequestScope.Context ctx = null;
-    if (disableDb) {
-      ctx = disableDb();
-    }
-    try {
-      Map<String, Ref> all = getAllRefs(repo);
-      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
-          .containsExactlyElementsIn(expectedRefs);
-    } finally {
-      if (disableDb) {
-        enableDb(ctx);
-      }
-    }
+    Map<String, Ref> all = getAllRefs(repo);
+    assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+        .containsExactlyElementsIn(expectedRefs);
   }
 
   private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {