Merge "CodeOwnerApprovalCheck: Remove wrong shortcut for fallback code owners"
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 0e34b9f..174b401 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -90,7 +90,7 @@
   private final ChangedFiles changedFiles;
   private final PureRevertCache pureRevertCache;
   private final Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider;
-  private final Provider<CodeOwnerResolver> codeOwnerResolver;
+  private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
   private final ApprovalsUtil approvalsUtil;
   private final CodeOwnerMetrics codeOwnerMetrics;
 
@@ -102,7 +102,7 @@
       ChangedFiles changedFiles,
       PureRevertCache pureRevertCache,
       Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider,
-      Provider<CodeOwnerResolver> codeOwnerResolver,
+      Provider<CodeOwnerResolver> codeOwnerResolverProvider,
       ApprovalsUtil approvalsUtil,
       CodeOwnerMetrics codeOwnerMetrics) {
     this.permissionBackend = permissionBackend;
@@ -111,7 +111,7 @@
     this.changedFiles = changedFiles;
     this.pureRevertCache = pureRevertCache;
     this.codeOwnerConfigHierarchyProvider = codeOwnerConfigHierarchyProvider;
-    this.codeOwnerResolver = codeOwnerResolver;
+    this.codeOwnerResolverProvider = codeOwnerResolverProvider;
     this.approvalsUtil = approvalsUtil;
     this.codeOwnerMetrics = codeOwnerMetrics;
   }
@@ -182,9 +182,10 @@
         "checking if change %d in project %s is submittable",
         changeNotes.getChangeId().get(), changeNotes.getProjectName());
     CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get().enforceVisibility(false);
     try {
       boolean isSubmittable =
-          !getFileStatuses(codeOwnerConfigHierarchy, changeNotes)
+          !getFileStatuses(codeOwnerConfigHierarchy, codeOwnerResolver, changeNotes)
               .anyMatch(
                   fileStatus ->
                       (fileStatus.newPathStatus().isPresent()
@@ -204,6 +205,10 @@
           codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getBackendReadCount());
       codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
           codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getCacheReadCount());
+      codeOwnerMetrics.codeOwnerResolutionsPerChange.record(
+          codeOwnerResolver.getCodeOwnerCounters().getResolutionCount());
+      codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
+          codeOwnerResolver.getCodeOwnerCounters().getCacheReadCount());
     }
   }
 
@@ -213,7 +218,7 @@
    *
    * @param start number of file statuses to skip
    * @param limit the max number of file statuses that should be returned (0 = unlimited)
-   * @see #getFileStatuses(CodeOwnerConfigHierarchy, ChangeNotes)
+   * @see #getFileStatuses(CodeOwnerConfigHierarchy, CodeOwnerResolver, ChangeNotes)
    */
   public ImmutableSet<FileCodeOwnerStatus> getFileStatusesAsSet(
       ChangeNotes changeNotes, int start, int limit)
@@ -225,7 +230,10 @@
           "compute file statuses (project = %s, change = %d, start = %d, limit = %d)",
           changeNotes.getProjectName(), changeNotes.getChangeId().get(), start, limit);
       Stream<FileCodeOwnerStatus> fileStatuses =
-          getFileStatuses(codeOwnerConfigHierarchyProvider.get(), changeNotes);
+          getFileStatuses(
+              codeOwnerConfigHierarchyProvider.get(),
+              codeOwnerResolverProvider.get().enforceVisibility(false),
+              changeNotes);
       if (start > 0) {
         fileStatuses = fileStatuses.skip(start);
       }
@@ -265,7 +273,9 @@
    *     returned
    */
   private Stream<FileCodeOwnerStatus> getFileStatuses(
-      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy, ChangeNotes changeNotes)
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
+      ChangeNotes changeNotes)
       throws ResourceConflictException, IOException, PatchListNotAvailableException,
           DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
@@ -331,10 +341,7 @@
       logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
 
       CodeOwnerResolverResult globalCodeOwners =
-          codeOwnerResolver
-              .get()
-              .enforceVisibility(false)
-              .resolveGlobalCodeOwners(changeNotes.getProjectName());
+          codeOwnerResolver.resolveGlobalCodeOwners(changeNotes.getProjectName());
       logger.atFine().log("global code owners = %s", globalCodeOwners);
 
       ImmutableSet<Account.Id> reviewerAccountIds =
@@ -352,6 +359,7 @@
               changedFile ->
                   getFileStatus(
                       codeOwnerConfigHierarchy,
+                      codeOwnerResolver,
                       branch,
                       revision,
                       globalCodeOwners,
@@ -410,11 +418,14 @@
           "fallbackCodeOwner = %s, isProjectOwner = %s", fallbackCodeOwners, isProjectOwner);
 
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
+      CodeOwnerResolver codeOwnerResolver =
+          codeOwnerResolverProvider.get().enforceVisibility(false);
       return changedFiles.getOrCompute(changeNotes.getProjectName(), patchSet.commitId()).stream()
           .map(
               changedFile ->
                   getFileStatus(
                       codeOwnerConfigHierarchy,
+                      codeOwnerResolver,
                       branch,
                       revision,
                       /* globalCodeOwners= */ CodeOwnerResolverResult.createEmpty(),
@@ -467,6 +478,7 @@
 
   private FileCodeOwnerStatus getFileStatus(
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
@@ -487,6 +499,7 @@
                   newPath ->
                       getPathCodeOwnerStatus(
                           codeOwnerConfigHierarchy,
+                          codeOwnerResolver,
                           branch,
                           revision,
                           globalCodeOwners,
@@ -509,6 +522,7 @@
             Optional.of(
                 getPathCodeOwnerStatus(
                     codeOwnerConfigHierarchy,
+                    codeOwnerResolver,
                     branch,
                     revision,
                     globalCodeOwners,
@@ -529,6 +543,7 @@
 
   private PathCodeOwnerStatus getPathCodeOwnerStatus(
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
@@ -569,7 +584,8 @@
           absolutePath,
           (PathCodeOwnersVisitor)
               pathCodeOwners -> {
-                CodeOwnerResolverResult codeOwners = resolveCodeOwners(pathCodeOwners);
+                CodeOwnerResolverResult codeOwners =
+                    resolveCodeOwners(codeOwnerResolver, pathCodeOwners);
                 logger.atFine().log(
                     "code owners = %s (code owner config folder path = %s, file name = %s)",
                     codeOwners,
@@ -867,10 +883,13 @@
   /**
    * Resolves the given path code owners.
    *
+   * @param codeOwnerResolver the {@code CodeOwnerResolver} that should be used to resolve code
+   *     owners
    * @param pathCodeOwners the path code owners that should be resolved
    */
-  private CodeOwnerResolverResult resolveCodeOwners(PathCodeOwners pathCodeOwners) {
-    return codeOwnerResolver.get().enforceVisibility(false).resolvePathCodeOwners(pathCodeOwners);
+  private CodeOwnerResolverResult resolveCodeOwners(
+      CodeOwnerResolver codeOwnerResolver, PathCodeOwners pathCodeOwners) {
+    return codeOwnerResolver.resolvePathCodeOwners(pathCodeOwners);
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 10b33c8..b363dd2 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
@@ -42,11 +44,16 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Collection;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
 import java.util.function.Predicate;
+import java.util.stream.Stream;
 
 /**
  * Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s.
@@ -81,6 +88,10 @@
  *   <li>users with the {@code Modify Account} global capability can see the secondary emails of all
  *       accounts
  * </ul>
+ *
+ * <p>Resolved code owners are cached within this class so that each email needs to be resolved only
+ * once. To take advantage of this caching callers should reuse {@link CodeOwnerResolver} instances
+ * where possible.
  */
 public class CodeOwnerResolver {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -96,6 +107,7 @@
   private final PathCodeOwners.Factory pathCodeOwnersFactory;
   private final CodeOwnerMetrics codeOwnerMetrics;
   private final UnresolvedImportFormatter unresolvedImportFormatter;
+  private final TransientCodeOwnerCache transientCodeOwnerCache;
 
   // Enforce visibility by default.
   private boolean enforceVisibility = true;
@@ -115,7 +127,8 @@
       AccountControl.Factory accountControlFactory,
       PathCodeOwners.Factory pathCodeOwnersFactory,
       CodeOwnerMetrics codeOwnerMetrics,
-      UnresolvedImportFormatter unresolvedImportFormatter) {
+      UnresolvedImportFormatter unresolvedImportFormatter,
+      TransientCodeOwnerCache transientCodeOwnerCache) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
@@ -125,6 +138,7 @@
     this.pathCodeOwnersFactory = pathCodeOwnersFactory;
     this.codeOwnerMetrics = codeOwnerMetrics;
     this.unresolvedImportFormatter = unresolvedImportFormatter;
+    this.transientCodeOwnerCache = transientCodeOwnerCache;
   }
 
   /**
@@ -137,6 +151,7 @@
   public CodeOwnerResolver enforceVisibility(boolean enforceVisibility) {
     logger.atFine().log("enforceVisibility = %s", enforceVisibility);
     this.enforceVisibility = enforceVisibility;
+    transientCodeOwnerCache.clear();
     return this;
   }
 
@@ -158,6 +173,7 @@
   public CodeOwnerResolver forUser(IdentifiedUser user) {
     logger.atFine().log("user = %s", user.getLoggableName());
     this.user = user;
+    transientCodeOwnerCache.clear();
     return this;
   }
 
@@ -244,10 +260,13 @@
   /**
    * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
    *
+   * <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
+   * parallel (via {@link AccountCache#get(Set)}.
+   *
    * @param codeOwnerReferences the code owner references that should be resolved
    * @param unresolvedImports list of unresolved imports
    * @param pathCodeOwnersMessages messages that were collected when resolving path code owners
-   * @return the {@link CodeOwner} for the given code owner references
+   * @return the resolved code owner references as a {@link CodeOwnerResolverResult}
    */
   private CodeOwnerResolverResult resolve(
       Set<CodeOwnerReference> codeOwnerReferences,
@@ -267,19 +286,8 @@
       AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
       AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
       ImmutableSet<CodeOwner> codeOwners =
-          codeOwnerReferences.stream()
-              .filter(filterOutAllUsersWildCard(ownedByAllUsers))
-              .map(codeOwnerReference -> resolve(messageBuilder, codeOwnerReference))
-              .filter(
-                  codeOwner -> {
-                    if (!codeOwner.isPresent()) {
-                      hasUnresolvedCodeOwners.set(true);
-                      return false;
-                    }
-                    return true;
-                  })
-              .map(Optional::get)
-              .collect(toImmutableSet());
+          resolve(messageBuilder, ownedByAllUsers, hasUnresolvedCodeOwners, codeOwnerReferences);
+
       CodeOwnerResolverResult codeOwnerResolverResult =
           CodeOwnerResolverResult.create(
               codeOwners,
@@ -321,75 +329,103 @@
    */
   public OptionalResultWithMessages<CodeOwner> resolveWithMessages(
       CodeOwnerReference codeOwnerReference) {
+    requireNonNull(codeOwnerReference, "codeOwnerReference");
+
+    if (CodeOwnerResolver.ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
+      return OptionalResultWithMessages.createEmpty(
+          String.format(
+              "cannot resolve code owner email %s: no account with this email exists",
+              CodeOwnerResolver.ALL_USERS_WILDCARD));
+    }
+
     ImmutableList.Builder<String> messageBuilder = ImmutableList.builder();
-    Optional<CodeOwner> codeOwner = resolve(messageBuilder, codeOwnerReference);
-    return OptionalResultWithMessages.create(codeOwner, messageBuilder.build());
+    AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+    AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
+    ImmutableSet<CodeOwner> codeOwners =
+        resolve(
+            messageBuilder,
+            ownedByAllUsers,
+            hasUnresolvedCodeOwners,
+            ImmutableSet.of(codeOwnerReference));
+    ImmutableList<String> messages = messageBuilder.build();
+    if (codeOwners.isEmpty()) {
+      return OptionalResultWithMessages.createEmpty(messages);
+    }
+    return OptionalResultWithMessages.create(Iterables.getOnlyElement(codeOwners), messages);
   }
 
   /**
-   * Resolves a {@link CodeOwnerReference} to a {@link CodeOwner}.
+   * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
    *
-   * <p>This method does not resolve {@link CodeOwnerReference}s that assign the code ownership to
-   * all user by using {@link #ALL_USERS_WILDCARD} as email.
-   *
-   * <p>Debug messages are returned with the result.
+   * <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
+   * parallel (via {@link AccountCache#get(Set)}.
    *
    * @param messages a builder to which debug messages are added
-   * @param codeOwnerReference the code owner reference that should be resolved
-   * @return the code owner to which the given code owner reference was resolved, {@link
-   *     Optional#empty()} if the code owner reference couldn't be resolved
+   * @param ownedByAllUsers a flag that is set if any of the given {@link CodeOwnerReference}s
+   *     assigns code ownership to all users
+   * @param hasUnresolvedCodeOwners a flag that is set any of the given {@link CodeOwnerReference}s
+   *     cannot be resolved
+   * @param codeOwnerReferences the code owner references that should be resolved
+   * @return the resolved code owner references as a {@link CodeOwner}s
    */
-  private Optional<CodeOwner> resolve(
-      ImmutableList.Builder<String> messages, CodeOwnerReference codeOwnerReference) {
-    String email = requireNonNull(codeOwnerReference, "codeOwnerReference").email();
+  private ImmutableSet<CodeOwner> resolve(
+      ImmutableList.Builder<String> messages,
+      AtomicBoolean ownedByAllUsers,
+      AtomicBoolean hasUnresolvedCodeOwners,
+      Set<CodeOwnerReference> codeOwnerReferences) {
+    requireNonNull(codeOwnerReferences, "codeOwnerReferences");
 
-    if (!isEmailDomainAllowed(messages, email)) {
-      return Optional.empty();
-    }
+    ImmutableSet<String> emailsToResolve =
+        codeOwnerReferences.stream()
+            .map(CodeOwnerReference::email)
+            .filter(filterOutAllUsersWildCard(ownedByAllUsers))
+            .collect(toImmutableSet());
 
-    Optional<ImmutableSet<ExternalId>> extIds = lookupExternalIdsForEmail(messages, email);
-    if (!extIds.isPresent()) {
-      return Optional.empty();
-    }
+    ImmutableMap<String, Optional<CodeOwner>> cachedCodeOwnersByEmail =
+        transientCodeOwnerCache.get(emailsToResolve);
 
-    ImmutableSet<AccountState> accountStates = lookupAccounts(messages, extIds.get());
-    ImmutableSet<AccountState> activeAccountStates =
-        removeInactiveAccounts(messages, email, accountStates);
-    if (activeAccountStates.isEmpty()) {
-      messages.add(
-          String.format(
-              "cannot resolve code owner email %s: no active account with this email found",
-              email));
-      return Optional.empty();
-    }
+    ImmutableSet<String> emailsToLookup =
+        emailsToResolve.stream()
+            .filter(email -> !cachedCodeOwnersByEmail.containsKey(email))
+            .filter(filterOutEmailsWithNonAllowedDomains(messages))
+            .collect(toImmutableSet());
 
-    if (activeAccountStates.size() > 1) {
-      messages.add(String.format("cannot resolve code owner email %s: email is ambiguous", email));
-      return Optional.empty();
-    }
+    ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail =
+        lookupExternalIds(messages, emailsToLookup);
 
-    AccountState activeAccountState = Iterables.getOnlyElement(activeAccountStates);
+    Stream<Pair<String, AccountState>> accountsByEmail =
+        lookupAccounts(messages, externalIdsByEmail)
+            .map(removeInactiveAccounts(messages))
+            .filter(filterOutEmailsWithoutAccounts(messages))
+            .filter(filterOutAmbiguousEmails(messages))
+            .map(mapToOnlyAccount(messages));
+
     if (enforceVisibility) {
-      if (!canSee(activeAccountState)) {
-        messages.add(
-            String.format(
-                "cannot resolve code owner email %s: account %s is not visible to user %s",
-                email,
-                activeAccountState.account().id(),
-                user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
-        return Optional.empty();
-      }
-
-      if (!isEmailVisible(messages, activeAccountState, email)) {
-        return Optional.empty();
-      }
+      accountsByEmail =
+          accountsByEmail
+              .filter(filterOutEmailsOfNonVisibleAccounts(messages))
+              .filter(filterOutNonVisibleSecondaryEmails(messages));
     } else {
       messages.add("code owner visibility is not checked");
     }
 
-    CodeOwner codeOwner = CodeOwner.create(activeAccountState.account().id());
-    messages.add(String.format("resolved email %s to account %s", email, codeOwner.accountId()));
-    return Optional.of(codeOwner);
+    ImmutableMap<String, CodeOwner> codeOwnersByEmail =
+        accountsByEmail.map(mapToCodeOwner()).collect(toImmutableMap(Pair::key, Pair::value));
+
+    if (codeOwnersByEmail.keySet().size() < emailsToResolve.size()) {
+      hasUnresolvedCodeOwners.set(true);
+    }
+
+    ImmutableSet<CodeOwner> cachedCodeOwners =
+        cachedCodeOwnersByEmail.values().stream()
+            .filter(Optional::isPresent)
+            .map(Optional::get)
+            .collect(toImmutableSet());
+
+    ImmutableSet.Builder<CodeOwner> codeOwners = ImmutableSet.builder();
+    codeOwners.addAll(cachedCodeOwners);
+    codeOwners.addAll(codeOwnersByEmail.values());
+    return codeOwners.build();
   }
 
   /**
@@ -398,9 +434,9 @@
    * @param ownedByAllUsers flag that is set if any of the emails is the all users wild card (aka
    *     {@code *})
    */
-  private Predicate<CodeOwnerReference> filterOutAllUsersWildCard(AtomicBoolean ownedByAllUsers) {
-    return codeOwnerReference -> {
-      if (ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
+  private Predicate<String> filterOutAllUsersWildCard(AtomicBoolean ownedByAllUsers) {
+    return email -> {
+      if (ALL_USERS_WILDCARD.equals(email)) {
         ownedByAllUsers.set(true);
         return false;
       }
@@ -409,6 +445,25 @@
   }
 
   /**
+   * Creates a predicate to filter out emails that have a non-allowed email domain.
+   *
+   * <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
+   * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<String> filterOutEmailsWithNonAllowedDomains(
+      ImmutableList.Builder<String> messages) {
+    return email -> {
+      boolean isEmailDomainAllowed = isEmailDomainAllowed(messages, email);
+      if (!isEmailDomainAllowed) {
+        transientCodeOwnerCache.cacheNonResolvable(email);
+      }
+      return isEmailDomainAllowed;
+    };
+  }
+
+  /**
    * Whether the domain of the given email is allowed for code owners.
    *
    * <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
@@ -470,64 +525,91 @@
   }
 
   /**
-   * Looks up the external IDs for the given email.
+   * Looks up the external IDs for the given emails.
    *
-   * @param messages a builder to which debug messages are added
-   * @param email the email for which the external IDs should be looked up
+   * <p>Looks up all emails from the external ID cache at once, which is more efficient than looking
+   * up external IDs for emails one by one (see {@link ExternalIds#byEmails(String...)}).
+   *
+   * @param messages builder to which debug messages are added
+   * @param emails the emails for which the external IDs should be looked up
+   * @return external IDs per email
    */
-  private Optional<ImmutableSet<ExternalId>> lookupExternalIdsForEmail(
-      ImmutableList.Builder<String> messages, String email) {
-    ImmutableSet<ExternalId> extIds;
+  private ImmutableMap<String, Collection<ExternalId>> lookupExternalIds(
+      ImmutableList.Builder<String> messages, ImmutableSet<String> emails) {
     try {
-      extIds = externalIds.byEmail(email);
+      ImmutableMap<String, Collection<ExternalId>> extIdsByEmail =
+          externalIds.byEmails(emails.toArray(new String[0])).asMap();
+      emails.stream()
+          .filter(email -> !extIdsByEmail.containsKey(email))
+          .forEach(
+              email -> {
+                transientCodeOwnerCache.cacheNonResolvable(email);
+                messages.add(
+                    String.format(
+                        "cannot resolve code owner email %s: no account with this email exists",
+                        email));
+              });
+      return extIdsByEmail;
     } catch (IOException e) {
       throw new CodeOwnersInternalServerErrorException(
-          String.format("cannot resolve code owner email %s", email), e);
+          String.format("cannot resolve code owner emails: %s", emails), e);
     }
-
-    if (extIds.isEmpty()) {
-      messages.add(
-          String.format(
-              "cannot resolve code owner email %s: no account with this email exists", email));
-      return Optional.empty();
-    }
-
-    return Optional.of(extIds);
   }
 
   /**
    * Looks up the accounts for the given external IDs.
    *
-   * @param messages a builder to which debug messages are added
-   * @param extIds the external IDs for which the accounts should be looked up
+   * <p>Looks up all accounts from the account cache at once, which is more efficient than looking
+   * up accounts one by one (see {@link AccountCache#get(Set)}).
+   *
+   * @param messages builder to which debug messages are added
+   * @param externalIdsByEmail external IDs for which the accounts should be looked up
+   * @return account states per email
    */
-  private ImmutableSet<AccountState> lookupAccounts(
-      ImmutableList.Builder<String> messages, ImmutableSet<ExternalId> extIds) {
-    return extIds.stream()
-        .map(externalId -> lookupAccount(messages, externalId.accountId(), externalId.email()))
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .collect(toImmutableSet());
+  private Stream<Pair<String, Collection<AccountState>>> lookupAccounts(
+      ImmutableList.Builder<String> messages,
+      ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail) {
+    ImmutableSet<Account.Id> accountIds =
+        externalIdsByEmail.values().stream()
+            .flatMap(Collection::stream)
+            .map(ExternalId::accountId)
+            .collect(toImmutableSet());
+    Map<Account.Id, AccountState> accounts = accountCache.get(accountIds);
+    return externalIdsByEmail.entrySet().stream()
+        .map(
+            e ->
+                Pair.of(
+                    e.getKey(),
+                    e.getValue().stream()
+                        .map(
+                            extId -> {
+                              Account.Id accountId = extId.accountId();
+                              AccountState accountState = accounts.get(accountId);
+                              if (accountState == null) {
+                                messages.add(
+                                    String.format(
+                                        "cannot resolve account %s for email %s: account does not"
+                                            + " exists",
+                                        accountId, e.getKey()));
+                              }
+                              return accountState;
+                            })
+                        .filter(Objects::nonNull)
+                        .collect(toImmutableSet())));
   }
 
   /**
-   * Looks up an account by account ID and returns the corresponding {@link AccountState} if it is
-   * found.
+   * Creates a map function that removes inactive accounts from a {@code Pair<String,
+   * Collection<AccountState>>}.
    *
-   * @param messages a builder to which debug messages are added
-   * @param accountId the ID of the account that should be looked up
-   * @param email the email that was resolved to the account ID
-   * @return the {@link AccountState} of the account with the given account ID, if it exists
+   * <p>The pair which is provided as input to the function maps an email to a collection of account
+   * states.
+   *
+   * @param messages builder to which debug messages are added
    */
-  private Optional<AccountState> lookupAccount(
-      ImmutableList.Builder<String> messages, Account.Id accountId, String email) {
-    Optional<AccountState> accountState = accountCache.get(accountId);
-    if (!accountState.isPresent()) {
-      messages.add(
-          String.format(
-              "cannot resolve account %s for email %s: account does not exists", accountId, email));
-    }
-    return accountState;
+  private Function<Pair<String, Collection<AccountState>>, Pair<String, Collection<AccountState>>>
+      removeInactiveAccounts(ImmutableList.Builder<String> messages) {
+    return e -> Pair.of(e.key(), removeInactiveAccounts(messages, e.key(), e.value()));
   }
 
   /**
@@ -541,7 +623,7 @@
   private ImmutableSet<AccountState> removeInactiveAccounts(
       ImmutableList.Builder<String> messages,
       String email,
-      ImmutableSet<AccountState> accountStates) {
+      Collection<AccountState> accountStates) {
     return accountStates.stream()
         .filter(
             accountState -> {
@@ -557,6 +639,99 @@
         .collect(toImmutableSet());
   }
 
+  /**
+   * Creates a predicate to filter out emails without accounts.
+   *
+   * <p>The pair which is provided as input to the predicate maps an email to a collection of
+   * account states. If the collection of account states is empty, the email is filtered out.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, Collection<AccountState>>> filterOutEmailsWithoutAccounts(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      if (e.value().isEmpty()) {
+        String email = e.key();
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format(
+                "cannot resolve code owner email %s: no active account with this email found",
+                email));
+        return false;
+      }
+      return true;
+    };
+  }
+
+  /**
+   * Creates a predicate to filter out ambiguous emails (emails that belong to multiple accounts).
+   *
+   * <p>The pair which is provided as input to the predicate maps an email to a collection of
+   * account states. If the collection of account states contains more than 1 entry, the email is
+   * filtered out.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, Collection<AccountState>>> filterOutAmbiguousEmails(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      if (e.value().size() > 1) {
+        String email = e.key();
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format("cannot resolve code owner email %s: email is ambiguous", email));
+        return false;
+      }
+      return true;
+    };
+  }
+
+  /**
+   * Creates a map function that maps a {@code Pair<String, Collection<AccountState>>} to a {@code
+   * Pair<String, AccountState>}.
+   *
+   * <p>The pair which is provided as input to the function maps an email to a collection of account
+   * states, which must contain exactly one entry. As output the function returns a pair that maps
+   * the email to the only account state.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Function<Pair<String, Collection<AccountState>>, Pair<String, AccountState>>
+      mapToOnlyAccount(ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = Iterables.getOnlyElement(e.value());
+      messages.add(
+          String.format("resolved email %s to account %s", email, accountState.account().id()));
+      return Pair.of(email, accountState);
+    };
+  }
+
+  /**
+   * Creates a predicate to filter out emails that belong to non-visible accounts.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, AccountState>> filterOutEmailsOfNonVisibleAccounts(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = e.value();
+      if (!canSee(accountState)) {
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format(
+                "cannot resolve code owner email %s: account %s is not visible to user %s",
+                email,
+                accountState.account().id(),
+                user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
+        return false;
+      }
+
+      return true;
+    };
+  }
+
   /** Whether the given account can be seen. */
   private boolean canSee(AccountState accountState) {
     AccountControl accountControl =
@@ -565,12 +740,9 @@
   }
 
   /**
-   * Checks whether the email is visible to the {@link #user} or the calling user (if {@link #user}
-   * is unset).
+   * Creates a predicate to filter out non-visible secondary emails.
    *
-   * <p>Primary emails are always visible if the account is visible.
-   *
-   * <p>If the email is a secondary email it is only visible if
+   * <p>A secondary email is only visible if
    *
    * <ul>
    *   <li>it is owned by the {@link #user} or the calling user (if {@link #user} is unset)
@@ -578,16 +750,22 @@
    *       Modify Account} global capability
    * </ul>
    *
-   * @param messages a builder to which debug messages are added
-   * @param visibleAccountState account to which the given email belongs and that is visible to the
-   *     user
-   * @param email email for which it should be checked if it is visible to the user
-   * @return {@code true} if the given email is visible to the user, otherwise {@code false}
+   * @param messages builder to which debug messages are added
    */
-  private boolean isEmailVisible(
-      ImmutableList.Builder<String> messages, AccountState visibleAccountState, String email) {
-    if (!email.equals(visibleAccountState.account().preferredEmail())) {
-      // the email is a secondary email of the account
+  private Predicate<Pair<String, AccountState>> filterOutNonVisibleSecondaryEmails(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = e.value();
+      if (email.equals(accountState.account().preferredEmail())) {
+        // the email is a primary email of the account
+        messages.add(
+            String.format(
+                "account %s is visible to user %s",
+                accountState.account().id(),
+                user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
+        return true;
+      }
 
       if (user != null) {
         if (user.hasEmailAddress(email)) {
@@ -615,46 +793,61 @@
       try {
         if (user != null) {
           if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
+            transientCodeOwnerCache.cacheNonResolvable(email);
             messages.add(
                 String.format(
                     "cannot resolve code owner email %s: account %s is referenced by secondary email"
                         + " but user %s cannot see secondary emails",
-                    email, visibleAccountState.account().id(), user.getLoggableName()));
+                    email, accountState.account().id(), user.getLoggableName()));
             return false;
           }
           messages.add(
               String.format(
                   "resolved code owner email %s: account %s is referenced by secondary email"
                       + " and user %s can see secondary emails",
-                  email, visibleAccountState.account().id(), user.getLoggableName()));
+                  email, accountState.account().id(), user.getLoggableName()));
           return true;
         } else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+          transientCodeOwnerCache.cacheNonResolvable(email);
           messages.add(
               String.format(
                   "cannot resolve code owner email %s: account %s is referenced by secondary email"
                       + " but the calling user %s cannot see secondary emails",
-                  email, visibleAccountState.account().id(), currentUser.get().getLoggableName()));
+                  email, accountState.account().id(), currentUser.get().getLoggableName()));
           return false;
         } else {
           messages.add(
               String.format(
                   "resolved code owner email %s: account %s is referenced by secondary email"
                       + " and the calling user %s can see secondary emails",
-                  email, visibleAccountState.account().id(), currentUser.get().getLoggableName()));
+                  email, accountState.account().id(), currentUser.get().getLoggableName()));
           return true;
         }
-      } catch (PermissionBackendException e) {
+      } catch (PermissionBackendException ex) {
         throw new CodeOwnersInternalServerErrorException(
             String.format(
                 "failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
-            e);
+            ex);
       }
-    }
-    messages.add(
-        String.format(
-            "account %s is visible to user %s",
-            visibleAccountState.account().id(),
-            user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
-    return true;
+    };
+  }
+
+  /**
+   * Creates a map function that maps a {@code Pair<String, AccountState>} to a code owner.
+   *
+   * <p>The pair which is provided as input to the function maps an email to an account states.
+   */
+  private Function<Pair<String, AccountState>, Pair<String, CodeOwner>> mapToCodeOwner() {
+    return e -> {
+      String email = e.key();
+      CodeOwner codeOwner = CodeOwner.create(e.value().account().id());
+      transientCodeOwnerCache.cache(email, codeOwner);
+      return Pair.of(email, codeOwner);
+    };
+  }
+
+  /** Returns the counters for resolutions and cache reads of code owners. */
+  public TransientCodeOwnerCache.Counters getCodeOwnerCounters() {
+    return transientCodeOwnerCache.getCounters();
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/Pair.java b/java/com/google/gerrit/plugins/codeowners/backend/Pair.java
new file mode 100644
index 0000000..ecd0a2e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/Pair.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2021 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.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Key-value pair.
+ *
+ * @param <K> the type of the key
+ * @param <V> the type of the value
+ */
+@AutoValue
+public abstract class Pair<K, V> {
+
+  public abstract K key();
+
+  public abstract V value();
+
+  public static <K, V> Pair<K, V> of(K key, V value) {
+    return new AutoValue_Pair<>(key, value);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java
new file mode 100644
index 0000000..0229860
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2021 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.codeowners.backend;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Class to cache resolved {@link CodeOwner}s within a request.
+ *
+ * <p>This cache is transient, which means the code owners stay cached only for the lifetime of the
+ * {@code TransientCodeOwnerCache} instance.
+ *
+ * <p><strong>Note</strong>: This class is not thread-safe.
+ */
+public class TransientCodeOwnerCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Optional<Integer> maxCacheSize;
+  private final Counters counters;
+  private final HashMap<String, Optional<CodeOwner>> cache = new HashMap<>();
+
+  @Inject
+  TransientCodeOwnerCache(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.maxCacheSize =
+        codeOwnersPluginConfiguration.getGlobalConfig().getMaxCodeOwnerConfigCacheSize();
+    this.counters = new Counters(codeOwnerMetrics);
+  }
+
+  public ImmutableMap<String, Optional<CodeOwner>> get(Set<String> emails) {
+    ImmutableMap<String, Optional<CodeOwner>> cachedCodeOwnersByEmail =
+        cache.entrySet().stream()
+            .filter(e -> emails.contains(e.getKey()))
+            .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+    counters.incrementCacheReads(cachedCodeOwnersByEmail.size());
+    return cachedCodeOwnersByEmail;
+  }
+
+  public void clear() {
+    cache.clear();
+  }
+
+  public void cacheNonResolvable(String email) {
+    cache(email, Optional.empty());
+  }
+
+  public void cache(String email, CodeOwner codeOwner) {
+    cache(email, Optional.of(codeOwner));
+  }
+
+  private void cache(String email, Optional<CodeOwner> codeOwner) {
+    counters.incrementResolutions();
+    if (!maxCacheSize.isPresent() || cache.size() < maxCacheSize.get()) {
+      cache.put(email, codeOwner);
+    } else if (maxCacheSize.isPresent()) {
+      logger.atWarning().atMostEvery(1, TimeUnit.DAYS).log(
+          "exceeded limit of %s", getClass().getSimpleName());
+    }
+  }
+
+  public Counters getCounters() {
+    return counters;
+  }
+
+  public static class Counters {
+    private final CodeOwnerMetrics codeOwnerMetrics;
+
+    private int resolutionCount;
+    private int cacheReadCount;
+
+    private Counters(CodeOwnerMetrics codeOwnerMetrics) {
+      this.codeOwnerMetrics = codeOwnerMetrics;
+    }
+
+    private void incrementCacheReads(long value) {
+      codeOwnerMetrics.countCodeOwnerCacheReads.incrementBy(value);
+      cacheReadCount++;
+    }
+
+    private void incrementResolutions() {
+      codeOwnerMetrics.countCodeOwnerResolutions.increment();
+      resolutionCount++;
+    }
+
+    public int getResolutionCount() {
+      return resolutionCount;
+    }
+
+    public int getCacheReadCount() {
+      return cacheReadCount;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
index c466b8a..472df1a 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
@@ -32,8 +32,10 @@
   static final String KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS = "enableExperimentalRestEndpoints";
 
   @VisibleForTesting static final int DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = 10000;
+  @VisibleForTesting static final int DEFAULT_MAX_CODE_OWNER_CACHE_SIZE = 10000;
 
   private static final String KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = "maxCodeOwnerConfigCacheSize";
+  private static final String KEY_MAX_CODE_OWNER_CACHE_SIZE = "maxCodeOwnerCacheSize";
 
   public interface Factory {
     CodeOwnersPluginGlobalConfigSnapshot create();
@@ -115,30 +117,39 @@
    */
   public Optional<Integer> getMaxCodeOwnerConfigCacheSize() {
     if (maxCodeOwnerConfigCacheSize == null) {
-      maxCodeOwnerConfigCacheSize = readMaxCodeOwnerConfigCacheSize();
+      maxCodeOwnerConfigCacheSize =
+          readMaxCacheSize(
+              KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
     }
     return maxCodeOwnerConfigCacheSize;
   }
 
-  private Optional<Integer> readMaxCodeOwnerConfigCacheSize() {
+  /**
+   * Gets the maximum size for the {@link
+   * com.google.gerrit.plugins.codeowners.backend.TransientCodeOwnerConfigCache}.
+   *
+   * @return the maximum cache size, {@link Optional#empty()} if the cache size is not limited
+   */
+  public Optional<Integer> getMaxCodeOwnerCacheSize() {
+    if (maxCodeOwnerConfigCacheSize == null) {
+      maxCodeOwnerConfigCacheSize =
+          readMaxCacheSize(KEY_MAX_CODE_OWNER_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
+    }
+    return maxCodeOwnerConfigCacheSize;
+  }
+
+  private Optional<Integer> readMaxCacheSize(String key, int defaultCacheSize) {
     try {
       int maxCodeOwnerConfigCacheSize =
-          pluginConfigFactory
-              .getFromGerritConfig(pluginName)
-              .getInt(
-                  KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+          pluginConfigFactory.getFromGerritConfig(pluginName).getInt(key, defaultCacheSize);
       return maxCodeOwnerConfigCacheSize > 0
           ? Optional.of(maxCodeOwnerConfigCacheSize)
           : Optional.empty();
     } catch (IllegalArgumentException e) {
       logger.atWarning().withCause(e).log(
           "Value '%s' in gerrit.config (parameter plugin.%s.%s) is invalid.",
-          pluginConfigFactory
-              .getFromGerritConfig(pluginName)
-              .getString(KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE),
-          pluginName,
-          KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
-      return Optional.empty();
+          pluginConfigFactory.getFromGerritConfig(pluginName).getString(key), pluginName, key);
+      return Optional.of(defaultCacheSize);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
index 89fac59..f3dd7b5 100644
--- a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
@@ -51,17 +51,21 @@
   public final Timer0 runCodeOwnerSubmitRule;
 
   // code owner config metrics
+  public final Histogram0 codeOwnerCacheReadsPerChange;
   public final Histogram0 codeOwnerConfigBackendReadsPerChange;
   public final Histogram0 codeOwnerConfigCacheReadsPerChange;
+  public final Histogram0 codeOwnerResolutionsPerChange;
   public final Timer1<String> loadCodeOwnerConfig;
   public final Timer0 readCodeOwnerConfig;
   public final Timer1<String> parseCodeOwnerConfig;
 
   // counter metrics
+  public final Counter0 countCodeOwnerCacheReads;
   public final Counter0 countCodeOwnerConfigReads;
   public final Counter0 countCodeOwnerConfigCacheReads;
   public final Counter3<ValidationTrigger, ValidationResult, Boolean>
       countCodeOwnerConfigValidations;
+  public final Counter0 countCodeOwnerResolutions;
   public final Counter1<String> countCodeOwnerSubmitRuleErrors;
   public final Counter0 countCodeOwnerSubmitRuleRuns;
   public final Counter1<Boolean> countCodeOwnerSuggestions;
@@ -136,6 +140,9 @@
             "run_code_owner_submit_rule", "Latency for running the code owner submit rule");
 
     // code owner config metrics
+    this.codeOwnerCacheReadsPerChange =
+        createHistogram(
+            "code_owner_cache_reads_per_change", "Number of code owner cache reads per change");
     this.codeOwnerConfigBackendReadsPerChange =
         createHistogram(
             "code_owner_config_backend_reads_per_change",
@@ -144,6 +151,9 @@
         createHistogram(
             "code_owner_config_cache_reads_per_change",
             "Number of code owner config cache reads per change");
+    this.codeOwnerResolutionsPerChange =
+        createHistogram(
+            "code_owner_resolutions_per_change", "Number of code owner resolutions per change");
     this.loadCodeOwnerConfig =
         createTimerWithClassField(
             "load_code_owner_config",
@@ -157,6 +167,9 @@
             "read_code_owner_config", "Latency for reading a code owner config file");
 
     // counter metrics
+    this.countCodeOwnerCacheReads =
+        createCounter(
+            "count_code_owner_config_reads", "Total number of code owner reads from cache");
     this.countCodeOwnerConfigReads =
         createCounter(
             "count_code_owner_config_reads",
@@ -179,6 +192,8 @@
             Field.ofBoolean("dry_run", (metadataBuilder, resolveAllUsers) -> {})
                 .description("Whether the validation was a dry run.")
                 .build());
+    this.countCodeOwnerResolutions =
+        createCounter("count_code_owner_resolutions", "Total number of code owner resolutions");
     this.countCodeOwnerSubmitRuleErrors =
         createCounter1(
             "count_code_owner_submit_rule_errors",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index c2cd1dc..629f42c 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -51,12 +52,13 @@
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdate;
   @Inject private AccountOperations accountOperations;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private TestMetricMaker testMetricMaker;
 
-  private Provider<CodeOwnerResolver> codeOwnerResolver;
+  private Provider<CodeOwnerResolver> codeOwnerResolverProvider;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
-    codeOwnerResolver =
+    codeOwnerResolverProvider =
         plugin.getSysInjector().getInstance(new Key<Provider<CodeOwnerResolver>>() {});
   }
 
@@ -66,7 +68,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolve(/* codeOwnerReference= */ (CodeOwnerReference) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReference");
@@ -75,7 +77,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolve(/* codeOwnerReferences= */ (Set<CodeOwnerReference>) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReferences");
@@ -85,7 +87,9 @@
   public void resolveCodeOwnerReferenceForNonExistingEmail() throws Exception {
     String nonExistingEmail = "non-existing@example.com";
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(nonExistingEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(nonExistingEmail));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -98,7 +102,9 @@
   @Test
   public void resolveCodeOwnerReferenceForEmail() throws Exception {
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
     assertThat(result)
         .hasMessagesThat()
@@ -108,7 +114,7 @@
   @Test
   public void cannotResolveCodeOwnerReferenceForStarAsEmail() throws Exception {
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolveWithMessages(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
     assertThat(result).isEmpty();
@@ -138,7 +144,9 @@
     accountOperations.account(user.id()).forUpdate().inactive().update();
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
   }
 
@@ -156,7 +164,9 @@
                         "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -177,7 +187,7 @@
     }
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(email));
+        codeOwnerResolverProvider.get().resolveWithMessages(CodeOwnerReference.create(email));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -194,7 +204,9 @@
   public void resolveCodeOwnerReferenceForInactiveUser() throws Exception {
     accountOperations.account(user.id()).forUpdate().inactive().update();
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(user.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(user.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -213,7 +225,9 @@
     // user2 cannot see the admin account since they do not share any group and
     // "accounts.visibility" is set to "SAME_GROUP".
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -234,7 +248,9 @@
     // admin has the "Modify Account" global capability and hence can see the secondary email of the
     // user account.
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
     assertThat(result)
         .hasMessagesThat()
@@ -247,7 +263,7 @@
     // user account if another user is the calling user
     requestScopeOperations.setApiUser(user2.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(admin.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -261,7 +277,10 @@
 
     // user can see its own secondary email.
     requestScopeOperations.setApiUser(user.id());
-    result = codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    result =
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
     assertThat(result)
         .hasMessagesThat()
@@ -273,7 +292,7 @@
     // user can see its own secondary email if another user is the calling user.
     requestScopeOperations.setApiUser(user2.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(user.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -296,7 +315,9 @@
     // email of the admin account.
     requestScopeOperations.setApiUser(user.id());
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -309,7 +330,7 @@
     // email of the admin account if another user is the calling user
     requestScopeOperations.setApiUser(admin.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(user.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -328,7 +349,9 @@
         CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwners()).isEmpty();
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -342,7 +365,9 @@
             .build();
 
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -357,7 +382,9 @@
             .build();
 
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).isEmpty();
     assertThat(result.ownedByAllUsers()).isTrue();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -372,7 +399,9 @@
                     admin.email(), "non-existing@example.com"))
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isTrue();
@@ -388,7 +417,9 @@
                     "*", admin.email(), "non-existing@example.com"))
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isTrue();
     assertThat(result.hasUnresolvedCodeOwners()).isTrue();
@@ -400,7 +431,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(/* codeOwnerConfig= */ null, Paths.get("/README.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfig");
@@ -416,7 +447,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(codeOwnerConfig, /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
@@ -427,7 +458,8 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnerResolver.get().resolvePathCodeOwners(/* pathCodeOwners= */ null));
+            () ->
+                codeOwnerResolverProvider.get().resolvePathCodeOwners(/* pathCodeOwners= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("pathCodeOwners");
   }
 
@@ -442,7 +474,7 @@
         assertThrows(
             IllegalStateException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(codeOwnerConfig, Paths.get(relativePath)));
     assertThat(npe)
@@ -462,11 +494,11 @@
 
     // user2 cannot see the admin account since they do not share any group and
     // "accounts.visibility" is set to "SAME_GROUP".
-    assertThat(codeOwnerResolver.get().resolve(adminCodeOwnerReference)).isEmpty();
+    assertThat(codeOwnerResolverProvider.get().resolve(adminCodeOwnerReference)).isEmpty();
 
     // if visibility is not enforced the code owner reference can be resolved regardless
     Optional<CodeOwner> codeOwner =
-        codeOwnerResolver.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
+        codeOwnerResolverProvider.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
     assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
@@ -478,10 +510,10 @@
     TestAccount user2 = accountCreator.user2();
 
     // admin is the current user and can see the account
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(user.email())))
+    assertThat(codeOwnerResolverProvider.get().resolve(CodeOwnerReference.create(user.email())))
         .isPresent();
     assertThat(
-            codeOwnerResolver
+            codeOwnerResolverProvider
                 .get()
                 .forUser(identifiedUserFactory.create(admin.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
@@ -489,7 +521,7 @@
 
     // user2 cannot see the account
     assertThat(
-            codeOwnerResolver
+            codeOwnerResolverProvider
                 .get()
                 .forUser(identifiedUserFactory.create(user2.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
@@ -499,7 +531,8 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
   public void resolveCodeOwnerReferenceForEmailWithNonAllowedEmailDomain() throws Exception {
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create("foo@example.com")))
+    assertThat(
+            codeOwnerResolverProvider.get().resolve(CodeOwnerReference.create("foo@example.com")))
         .isEmpty();
   }
 
@@ -508,7 +541,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnerResolver.get().isEmailDomainAllowed(/* email= */ null));
+            () -> codeOwnerResolverProvider.get().isEmailDomainAllowed(/* email= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("email");
   }
 
@@ -551,7 +584,7 @@
   private void assertIsEmailDomainAllowed(
       String email, boolean expectedResult, String expectedMessage) {
     OptionalResultWithMessages<Boolean> isEmailDomainAllowedResult =
-        codeOwnerResolver.get().isEmailDomainAllowed(email);
+        codeOwnerResolverProvider.get().isEmailDomainAllowed(email);
     assertThat(isEmailDomainAllowedResult.get()).isEqualTo(expectedResult);
     assertThat(isEmailDomainAllowedResult.messages()).containsExactly(expectedMessage);
   }
@@ -559,7 +592,7 @@
   @Test
   public void resolveCodeOwnerReferences() throws Exception {
     CodeOwnerResolverResult result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolve(
                 ImmutableSet.of(
@@ -573,7 +606,7 @@
   @Test
   public void resolveCodeOwnerReferencesNonResolveableCodeOwnersAreFilteredOut() throws Exception {
     CodeOwnerResolverResult result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolve(
                 ImmutableSet.of(
@@ -586,14 +619,64 @@
 
   @Test
   public void isResolvable() throws Exception {
-    assertThat(codeOwnerResolver.get().isResolvable(CodeOwnerReference.create(admin.email())))
+    assertThat(
+            codeOwnerResolverProvider.get().isResolvable(CodeOwnerReference.create(admin.email())))
         .isTrue();
   }
 
   @Test
   public void isNotResolvable() throws Exception {
     assertThat(
-            codeOwnerResolver.get().isResolvable(CodeOwnerReference.create("unknown@example.com")))
+            codeOwnerResolverProvider
+                .get()
+                .isResolvable(CodeOwnerReference.create("unknown@example.com")))
         .isFalse();
   }
+
+  @Test
+  public void emailIsResolvedOnlyOnce() throws Exception {
+    testMetricMaker.reset();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(1);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_config_reads"))
+        .isEqualTo(0);
+
+    // Doing the same lookup again doesn't resolve the code owner again.
+    testMetricMaker.reset();
+    result = codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(0);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_config_reads"))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void nonExistingEmailIsResolvedOnlyOnce() throws Exception {
+    testMetricMaker.reset();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.resolveWithMessages(
+            CodeOwnerReference.create("non-existing@example.com"));
+    assertThat(result).isEmpty();
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(1);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_config_reads"))
+        .isEqualTo(0);
+
+    // Doing the same lookup again doesn't resolve the code owner again.
+    testMetricMaker.reset();
+    result =
+        codeOwnerResolver.resolveWithMessages(
+            CodeOwnerReference.create("non-existing@example.com"));
+    assertThat(result).isEmpty();
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(0);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_config_reads"))
+        .isEqualTo(1);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
index e382566..6a5c03d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
@@ -85,7 +85,36 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.maxCodeOwnerConfigCacheSize", value = "invalid")
   public void maxCodeOwnerConfigCacheSize_invalidConfig() throws Exception {
-    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize()).isEmpty();
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+  }
+
+  @Test
+  public void codeOwnerCacheSizeIsLimitedByDefault() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "0")
+  public void codeOwnerCacheSizeIsUnlimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "10")
+  public void codeOwnerCacheSizeIsLimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize()).value().isEqualTo(10);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "invalid")
+  public void maxCodeOwnerCacheSize_invalidConfig() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
   }
 
   private CodeOwnersPluginGlobalConfigSnapshot cfgSnapshot() {
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index b005dc7..b39e732 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -546,6 +546,16 @@
         code owner config files that are cached per request.\
         By default `10000`.
 
+<a id="pluginCodeOwnersMaxCodeOwnerCacheSize">plugin.@PLUGIN@.maxCodeOwnerCacheSize</a>
+:       When computing code owner file statuses for a change (e.g. to compute
+        the results for the code owners submit rule) emails that are mentioned
+        in relevant code owner config files need to be resolved to Gerrit
+        accounts. The resolved code owners are cached in memory for the time of
+        the request so that this resolution has to be done only once per email.\
+        This configuration parameter allows to set a limit for the number of
+        resolved code owners that are cached per request.\
+        By default `10000`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
diff --git a/resources/Documentation/metrics.md b/resources/Documentation/metrics.md
index 0c4ccf5..b4fc3af 100644
--- a/resources/Documentation/metrics.md
+++ b/resources/Documentation/metrics.md
@@ -48,10 +48,14 @@
 
 ## <a id="codeOwnerConfigMetrics"> Code Owner Config Metrics
 
+* `code_owner_cache_reads_per_change`:
+  Number of code owner cache reads per change.
 * `code_owner_config_cache_reads_per_change`:
   Number of code owner config cache reads per change.
 * `code_owner_config_backend_reads_per_change`:
   Number of code owner config backend reads per change.
+* `code_owner_resolutions_per_change`:
+  Number of code owner resolutions per change.
 * `load_code_owner_config`:
   Latency for loading a code owner config file (read + parse).
 * `parse_code_owner_config`:
@@ -62,6 +66,8 @@
 ## <a id="counterMetrics"> Counter Metrics
 
 * `count_code_owner_config_reads`:
+  Total number of code owner reads from cache.
+* `count_code_owner_config_reads`:
   Total number of code owner config reads from backend.
 * `count_code_owner_config_cache_reads`:
   Total number of code owner config reads from cache.
@@ -73,6 +79,8 @@
       The result of the validation.
     * `dry_run`:
       Whether the validation was a dry run.
+* `count_code_owner_resolutions
+  Total number of code owner resolutions.
 * `count_code_owner_submit_rule_errors`:
   Total number of code owner submit rule errors.
     * `cause`:
diff --git a/ui/owner-status-column.js b/ui/owner-status-column.js
index ae56d36..b9f5631 100644
--- a/ui/owner-status-column.js
+++ b/ui/owner-status-column.js
@@ -48,6 +48,19 @@
 };
 
 class BaseEl extends CodeOwnersModelMixin(Polymer.Element) {
+  static get properties() {
+    return {
+      patchRange: Object,
+
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: 'computeHidden(change, patchRange, ' +
+            'model.status.newerPatchsetUploaded)',
+      },
+    };
+  }
+
   computeHidden(change, patchRange, newerPatchsetUploaded) {
     if ([change, patchRange, newerPatchsetUploaded].includes(undefined)) {
       return true;
@@ -93,19 +106,6 @@
         <div></div>
       `;
   }
-
-  static get properties() {
-    return {
-      patchRange: Object,
-
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: 'computeHidden(change, patchRange, ' +
-          'model.status.newerPatchsetUploaded)',
-      },
-    };
-  }
 }
 
 customElements.define(OwnerStatusColumnHeader.is, OwnerStatusColumnHeader);
@@ -122,12 +122,6 @@
     return {
       path: String,
       oldPath: String,
-      patchRange: Object,
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: 'computeHidden(change, patchRange)',
-      },
       ownerService: Object,
       statusIcon: {
         type: String,