Revert "Rework SearchingChangeCacheImpl"

This reverts commit f5c89c1a6f01aee6295717fb785a4bfbacb8ce86.

Reason for revert:

Bootstrapping doesn't work because the index doesn't give us
all changes. In addition, we would have to filter way more
refs with this cache because the index-backed cache limitted
the ref filtering by recency (= you can't fetch a change from
say 2015) which is another performance bottleneck.

Change-Id: I447eaeb652e530ed560f31de15bb012912e14fff
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 01db7ce..e92b0bd 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -818,23 +818,18 @@
 requires two HTTP requests, and this cache tries to carry state from
 the first request into the second to ensure it can complete.
 
-cache `"change_refs"`::
-+
-Cache entries are used to compute change ref visibility efficiently. Entries
-contain the minimal required information for this task.
+cache `"changes"`::
 +
 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
-at maximum 1024 changes can be held in the cache.
+changes for up to 1024 projects can be held in the cache.
 +
-Default value is 10.000.
+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.
 +
-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.
+The cache should be flushed whenever the database changes table is modified
+outside of Gerrit.
 
 cache `"diff"`::
 +
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index a8552f4..aa5362b 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 ChangeRefCache.Module());
+    modules.add(new SearchingChangeCacheImpl.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 3c4f89b..c280a2d 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 ChangeRefCache.Module());
+    modules.add(new SearchingChangeCacheImpl.Module(slave));
     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 f263786..b0c1c25 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(ChangeRefCache.class).toProvider(Providers.of(null));
+    bind(SearchingChangeCacheImpl.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
deleted file mode 100644
index dc1b946..0000000
--- a/java/com/google/gerrit/server/git/ChangeRefCache.java
+++ /dev/null
@@ -1,226 +0,0 @@
-// 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
new file mode 100644
index 0000000..0692ccf
--- /dev/null
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -0,0 +1,162 @@
+// 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 a2a0ef1..00df11a 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -14,6 +14,7 @@
 
 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;
@@ -21,7 +22,9 @@
 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;
@@ -38,11 +41,12 @@
 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.ChangeRefCache;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 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;
@@ -51,11 +55,10 @@
 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;
@@ -71,7 +74,7 @@
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final ChangeRefCache changeCache;
+  @Nullable private final SearchingChangeCacheImpl changeCache;
   private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
   private final ProjectControl projectControl;
@@ -81,14 +84,14 @@
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
-  private final Map<Change.Id, Branch.NameKey> visibleChanges;
-  private final Set<Change.Id> inVisibleChanges;
+
+  private Map<Change.Id, Branch.NameKey> visibleChanges;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable ChangeRefCache changeCache,
+      @Nullable SearchingChangeCacheImpl changeCache,
       GroupCache groupCache,
       PermissionBackend permissionBackend,
       @GerritServerConfig Config config,
@@ -118,9 +121,6 @@
                     "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,7 +161,6 @@
 
     Map<String, Ref> result = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
-    changeCache.bootstrapIfNecessary(projectState.getNameKey());
 
     for (Ref ref : refs.values()) {
       String name = ref.getName();
@@ -300,70 +299,111 @@
   }
 
   private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
-    if (visibleChanges.containsKey(changeId)) {
-      return true;
-    }
-    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 {
-      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
-      if (metaRef == null) {
-        logger.atWarning().log(
-            "can't read change meta ref for change %s on project %s", project, changeId);
-        inVisibleChanges.add(changeId);
-        return false;
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChanges = visibleChangesByScan(repo);
+      } else {
+        visibleChanges = visibleChangesBySearch();
       }
-
-      ChangeData cd = changeCache.getChangeData(project, changeId, metaRef.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);
     }
+    return visibleChanges.containsKey(changeId);
   }
 
   private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
-    if (id == null) {
-      return false;
+    // Initialize if it wasn't yet
+    if (visibleChanges == null) {
+      visible(repo, id);
     }
-    if (!visible(repo, id)) {
-      // Can't see the change, so can't see the edit.
+    if (id == null) {
       return false;
     }
 
     if (user.isIdentifiedUser()
-        && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))) {
-      // Own edit
+        && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
+        && visible(repo, id)) {
       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 {
-      // 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;
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
     } catch (AuthException e) {
-      return false;
+      // Skip.
     }
+    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 2a030ac..c8cea6f 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 ChangeRefCache.Module());
+    install(new SearchingChangeCacheImpl.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
deleted file mode 100644
index 10e701e..0000000
--- a/javatests/com/google/gerrit/acceptance/git/ChangeRefCacheIT.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// 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.NoHttpd;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-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 ChangeRefCache changeRefCache;
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private RequestScopeOperations requestScopeOperations;
-
-  @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();
-    try (AutoCloseable ignored = disableNoteDb()) {
-      assertUploadPackRefs(
-          "HEAD",
-          "refs/heads/master",
-          RefNames.changeMetaRef(change.getId()),
-          change.currentPatchSet().getId().toRefName());
-    }
-  }
-
-  /**
-   * 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();
-    try (AutoCloseable ignored = disableNoteDb()) {
-      assertUploadPackRefs(
-          "HEAD",
-          "refs/heads/master",
-          RefNames.changeMetaRef(change.getId()),
-          change.currentPatchSet().getId().toRefName());
-    }
-
-    // No change since our first call, so this time we don't bootstrap or touch the NoteDb
-    try (AutoCloseable ignored = disableChangeIndex();
-        AutoCloseable ignored2 = disableNoteDb()) {
-      assertUploadPackRefs(
-          "HEAD",
-          "refs/heads/master",
-          RefNames.changeMetaRef(change.getId()),
-          change.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 useIndexForBootstrappingAndDbForDeltaReload() throws Exception {
-    ChangeData change1 = createChange().getChange();
-    // Bootstrap: No NoteDb access as we expect it to use the index.
-    changeRefCache.resetBootstrappedProjects();
-    try (AutoCloseable ignored = disableNoteDb()) {
-      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.
-    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();
-
-    // Bootstrap: No NoteDb access as we expect it to use the index.
-    changeRefCache.resetBootstrappedProjects();
-    try (AutoCloseable ignored = disableNoteDb()) {
-      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.
-    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.
-    try (AutoCloseable ignored = disableNoteDb()) {
-      changeRefCache.resetBootstrappedProjects();
-      assertUploadPackRefs(
-          "HEAD",
-          "refs/heads/master",
-          RefNames.changeMetaRef(change1.getId()),
-          change1.currentPatchSet().getId().toRefName());
-    }
-
-    try (AutoCloseable ignored = disableChangeIndex()) {
-      // user can see public change
-      requestScopeOperations.setApiUser(user.getId());
-      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.
-    requestScopeOperations.setApiUser(admin.getId());
-    gApi.changes().id(change1.getId().id).setPrivate(true);
-
-    try (AutoCloseable ignored = disableChangeIndex()) {
-      // user can't see private change from admin
-      requestScopeOperations.setApiUser(user.getId());
-      assertUploadPackRefs("HEAD", "refs/heads/master");
-    }
-
-    // admin adds the user as reviewer
-    requestScopeOperations.setApiUser(admin.getId());
-    gApi.changes().id(change1.getId().id).addReviewer(user.email);
-
-    try (AutoCloseable ignored = disableChangeIndex()) {
-      // Use can see private change
-      requestScopeOperations.setApiUser(user.getId());
-      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 79a6957..91d5c32 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -358,6 +358,7 @@
           repo,
           permissionBackend.user(user(user)).project(project),
           // Can't use stored values from the index so DB must be enabled.
+          false,
           "HEAD",
           psRef1,
           metaRef1,
@@ -377,10 +378,10 @@
   @Test
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
     try (Repository repo = repoManager.openRepository(allProjects)) {
-      assertRefs(repo, newFilter(allProjects, user));
+      assertRefs(repo, newFilter(allProjects, user), true);
 
       allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      assertRefs(repo, newFilter(allProjects, user), "refs/sequences/changes");
+      assertRefs(repo, newFilter(allProjects, user), true, "refs/sequences/changes");
     }
   }
 
@@ -728,16 +729,29 @@
    */
   private void assertUploadPackRefs(String... expectedRefs) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(repo, permissionBackend.user(user(user)).project(project), expectedRefs);
+      assertRefs(repo, permissionBackend.user(user(user)).project(project), true, expectedRefs);
     }
   }
 
   private void assertRefs(
-      Repository repo, PermissionBackend.ForProject forProject, String... expectedRefs)
+      Repository repo,
+      PermissionBackend.ForProject forProject,
+      boolean disableDb,
+      String... expectedRefs)
       throws Exception {
-    Map<String, Ref> all = getAllRefs(repo);
-    assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
-        .containsExactlyElementsIn(expectedRefs);
+    AutoCloseable ctx = null;
+    if (disableDb) {
+      ctx = disableNoteDb();
+    }
+    try {
+      Map<String, Ref> all = getAllRefs(repo);
+      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+          .containsExactlyElementsIn(expectedRefs);
+    } finally {
+      if (disableDb) {
+        ctx.close();
+      }
+    }
   }
 
   private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {