Add in-memory CodeOwnerConfigCache across requests

TransientCodeOwnerConfigCache was originally transient to prevent
returning stale CodeOwnerConfig entries when getFromCurrentRevision
passed revision = null.

This change introduces CodeOwnerConfigCache, a thread-safe in-memory
cache keyed strictly by (CodeOwnerConfig.Key, ObjectId revision)
configured via CacheModule in BackendModule, exposing it to Gerrit's
cache metrics (Prometheus) and administrative commands (show-caches,
flush-caches).

Because entries are keyed by immutable Git commit ObjectIds, entries
never become stale, and eviction is governed purely by the configured
maximum cache size (LRU).

TransientCodeOwnerConfigCache maintains the L1 (request-local) -> L2
(process-wide) cache hierarchy:
- L1 hits return in O(1) time without Git ref resolution.
- On an L1 miss, branch revision is resolved once and L2 is checked.
- On an L2 hit, L1 is populated for fast subsequent lookups, respecting
  the per-request cache size limit.
- On an L2 miss, the config is loaded and cached in both L1 and L2.
- Non-existent branches are cached in L1 to prevent repeated disk I/O.

Cost/Benefit:
- Benefit: Eliminates repeated Git tree walks, distributed blob reads,
  and OWNERS parsing across the push and submit requirement validation
  lifecycle, saving an estimated ~500 ms to 1.5+ seconds per patch set
  upload on large repositories like chromium/src.
- Cost: Up to ~10-50 MB JVM heap overhead for 10k cached configs.

Change-Id: I1983e074bd896aa2fc1594bb68e0801aa653853b
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index 55421e9..afc5f71 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -65,6 +65,8 @@
     DynamicSet.bind(binder(), CommentAddedListener.class).to(OnCodeOwnerApproval.class);
     DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerOverride.class);
     DynamicSet.bind(binder(), ReviewerAddedListener.class).to(CodeOwnersOnAddReviewer.class);
+
+    install(CodeOwnerConfigCache.module());
   }
 
   @Provides
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCache.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCache.java
new file mode 100644
index 0000000..604a1e4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCache.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2026 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;
+import com.google.common.cache.Cache;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Process-wide, thread-safe in-memory cache for {@link CodeOwnerConfig}s across requests.
+ *
+ * <p>Entries are strictly keyed by (codeOwnerConfigKey, revision ObjectId). Since Git commit
+ * ObjectIds are immutable, entries cached here will never become stale even when branches are
+ * updated. Eviction is governed purely by the configured maximum cache size (LRU).
+ */
+@Singleton
+public class CodeOwnerConfigCache {
+  public static final String CACHE_NAME = "code_owner_configs";
+  public static final int DEFAULT_MAX_CACHE_SIZE = 10_000;
+
+  private final Cache<Key, Optional<CodeOwnerConfig>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        // CacheBinding only exposes maximumWeight; with no custom weigher, Gerrit's
+        // DefaultMemoryCacheFactory uses singletonWeigher(), so weight corresponds to entry count.
+        cache(CACHE_NAME, Key.class, new TypeLiteral<Optional<CodeOwnerConfig>>() {})
+            .maximumWeight(DEFAULT_MAX_CACHE_SIZE);
+        bind(CodeOwnerConfigCache.class);
+      }
+    };
+  }
+
+  @AutoValue
+  public abstract static class Key {
+    static Key create(CodeOwnerConfig.Key configKey, ObjectId revision) {
+      return new AutoValue_CodeOwnerConfigCache_Key(configKey, revision.toObjectId());
+    }
+
+    abstract CodeOwnerConfig.Key configKey();
+
+    abstract ObjectId revision();
+  }
+
+  @Inject
+  public CodeOwnerConfigCache(@Named(CACHE_NAME) Cache<Key, Optional<CodeOwnerConfig>> cache) {
+    this.cache = cache;
+  }
+
+  public Optional<Optional<CodeOwnerConfig>> getIfPresent(
+      CodeOwnerConfig.Key key, ObjectId revision) {
+    Objects.requireNonNull(key, "key");
+    Objects.requireNonNull(revision, "revision");
+    return Optional.ofNullable(cache.getIfPresent(Key.create(key, revision)));
+  }
+
+  public void put(CodeOwnerConfig.Key key, ObjectId revision, Optional<CodeOwnerConfig> config) {
+    Objects.requireNonNull(key, "key");
+    Objects.requireNonNull(revision, "revision");
+    Objects.requireNonNull(config, "config");
+    cache.put(Key.create(key, revision), config);
+  }
+
+  public long size() {
+    return cache.size();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java
index 14f0b98..e384912 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java
@@ -34,8 +34,8 @@
 /**
  * Class to load and cache {@link CodeOwnerConfig}s within a request.
  *
- * <p>This cache is transient, which means the code owner configs stay cached only for the lifetime
- * of the {@code TransientCodeOwnerConfigCache} instance.
+ * <p>This cache maintains a transient request-scoped cache and consults the process-wide {@link
+ * CodeOwnerConfigCache} when resolving code owner configs.
  *
  * <p><strong>Note</strong>: This class is not thread-safe.
  */
@@ -44,6 +44,7 @@
 
   private final GitRepositoryManager repoManager;
   private final CodeOwners codeOwners;
+  private final CodeOwnerConfigCache persistentCache;
   private final Optional<Integer> maxCacheSize;
   private final Counters counters;
   private final HashMap<CacheKey, Optional<CodeOwnerConfig>> cache = new HashMap<>();
@@ -53,9 +54,11 @@
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
       CodeOwners codeOwners,
+      CodeOwnerConfigCache persistentCache,
       CodeOwnerMetrics codeOwnerMetrics) {
     this.repoManager = repoManager;
     this.codeOwners = codeOwners;
+    this.persistentCache = persistentCache;
     this.maxCacheSize =
         codeOwnersPluginConfiguration.getGlobalConfig().getMaxCodeOwnerConfigCacheSize();
     this.counters = new Counters(codeOwnerMetrics);
@@ -68,13 +71,60 @@
   @Override
   public Optional<CodeOwnerConfig> get(
       CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
+    // 1. Check L1 (request-local HashMap) first.
     CacheKey cacheKey = CacheKey.create(codeOwnerConfigKey, revision);
     Optional<CodeOwnerConfig> cachedCodeOwnerConfig = cache.get(cacheKey);
     if (cachedCodeOwnerConfig != null) {
       counters.incrementCacheReads();
       return cachedCodeOwnerConfig;
     }
-    return loadAndCache(cacheKey);
+
+    // 2. L1 miss: resolve branch revision once if null before querying L2.
+    ObjectId targetRevision = revision;
+    if (targetRevision == null) {
+      Optional<ObjectId> branchRevision = getRevision(codeOwnerConfigKey.branchNameKey());
+      if (branchRevision.isEmpty()) {
+        // branch does not exist, cache negative lookup in L1 and return
+        putInL1(cacheKey, Optional.empty());
+        return Optional.empty();
+      }
+      targetRevision = branchRevision.get();
+    }
+
+    // 3. Check L2 (process-wide cache).
+    Optional<Optional<CodeOwnerConfig>> persistentConfig =
+        persistentCache.getIfPresent(codeOwnerConfigKey, targetRevision);
+    if (persistentConfig.isPresent()) {
+      counters.incrementCacheReads();
+      // Populate L1 cache so subsequent reads in this request are O(1) without touching L2
+      putInL1(cacheKey, persistentConfig.get());
+      return persistentConfig.get();
+    }
+
+    // 4. L2 miss: load using resolved revision to avoid duplicate ref lookup.
+    counters.incrementBackendReads();
+    Optional<CodeOwnerConfig> codeOwnerConfig = codeOwners.get(codeOwnerConfigKey, targetRevision);
+    putInL1(cacheKey, codeOwnerConfig);
+
+    persistentCache.put(codeOwnerConfigKey, targetRevision, codeOwnerConfig);
+    return codeOwnerConfig;
+  }
+
+  /**
+   * Puts an entry into the request-local L1 cache if the configured size limit allows.
+   *
+   * <p>Note: {@code maxCacheSize} is {@link Optional#empty()} when unlimited (which is also the
+   * case if configured &le; 0 in {@code gerrit.config}). Thus, the warning is only logged when a
+   * positive limit is exceeded.
+   */
+  private void putInL1(CacheKey cacheKey, Optional<CodeOwnerConfig> codeOwnerConfig) {
+    if (maxCacheSize.isEmpty() || cache.size() < maxCacheSize.get()) {
+      cache.put(cacheKey, codeOwnerConfig);
+    } else {
+      logger.atWarning().atMostEvery(1, TimeUnit.DAYS).log(
+          "exceeded limit of %s (project = %s, limit = %s)",
+          getClass().getSimpleName(), cacheKey.codeOwnerConfigKey().project(), maxCacheSize.get());
+    }
   }
 
   /**
@@ -86,31 +136,6 @@
     return get(codeOwnerConfigKey, /* revision= */ null);
   }
 
-  /** Load a code owner config and puts it into the cache. */
-  private Optional<CodeOwnerConfig> loadAndCache(CacheKey cacheKey) {
-    counters.incrementBackendReads();
-    Optional<CodeOwnerConfig> codeOwnerConfig;
-    if (cacheKey.revision().isPresent()) {
-      codeOwnerConfig = codeOwners.get(cacheKey.codeOwnerConfigKey(), cacheKey.revision().get());
-    } else {
-      Optional<ObjectId> revision = getRevision(cacheKey.codeOwnerConfigKey().branchNameKey());
-      if (revision.isPresent()) {
-        codeOwnerConfig = codeOwners.get(cacheKey.codeOwnerConfigKey(), revision.get());
-      } else {
-        // branch does not exists, hence the code owner config also doesn't exist
-        codeOwnerConfig = Optional.empty();
-      }
-    }
-    if (!maxCacheSize.isPresent() || cache.size() < maxCacheSize.get()) {
-      cache.put(cacheKey, codeOwnerConfig);
-    } else if (maxCacheSize.isPresent()) {
-      logger.atWarning().atMostEvery(1, TimeUnit.DAYS).log(
-          "exceeded limit of %s (project = %s, limit = %s)",
-          getClass().getSimpleName(), cacheKey.codeOwnerConfigKey().project(), maxCacheSize.get());
-    }
-    return codeOwnerConfig;
-  }
-
   /**
    * Gets the revision for the given branch.
    *
@@ -152,6 +177,7 @@
     return counters;
   }
 
+  /** Performance counters for cache reads and backend reads. */
   public static class Counters {
     private final CodeOwnerMetrics codeOwnerMetrics;
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index c139ec7..0e085b3 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -15,6 +15,7 @@
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCacheTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCacheTest.java
new file mode 100644
index 0000000..7a7f8c8
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigCacheTest.java
@@ -0,0 +1,248 @@
+// Copyright (C) 2026 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.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Key;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link CodeOwnerConfigCache} and its integration with {@link
+ * TransientCodeOwnerConfigCache}.
+ */
+public class CodeOwnerConfigCacheTest extends AbstractCodeOwnersTest {
+  private static final ObjectId REVISION_1 =
+      ObjectId.fromString("1111111111111111111111111111111111111111");
+  private static final ObjectId REVISION_2 =
+      ObjectId.fromString("2222222222222222222222222222222222222222");
+
+  private CodeOwnerConfigCache codeOwnerConfigCache;
+
+  @Before
+  public void setUp() {
+    codeOwnerConfigCache = plugin.getSysInjector().getInstance(CodeOwnerConfigCache.class);
+  }
+
+  @Test
+  public void isBoundAsSingletonInPluginInjector() {
+    CodeOwnerConfigCache secondInstance =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigCache.class);
+    assertThat(codeOwnerConfigCache).isSameInstanceAs(secondInstance);
+  }
+
+  @Test
+  public void registeredInDynamicMapOfCaches() {
+    DynamicMap<Cache<?, ?>> cacheMap =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<Cache<?, ?>>>() {});
+    Cache<?, ?> cache = cacheMap.get("code-owners", CodeOwnerConfigCache.CACHE_NAME);
+    assertThat(cache).isNotNull();
+  }
+
+  @Test
+  public void returnsEmptyIfNotPresent() {
+    CodeOwnerConfig.Key key =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/foo");
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_1)).isEmpty();
+  }
+
+  @Test
+  public void cachesConfigByRevision() {
+    CodeOwnerConfig.Key key =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/foo");
+    CodeOwnerConfig config = CodeOwnerConfig.builder(key, REVISION_1).build();
+
+    codeOwnerConfigCache.put(key, REVISION_1, Optional.of(config));
+
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_1)).hasValue(Optional.of(config));
+  }
+
+  @Test
+  public void cachesNegativeLookup() {
+    CodeOwnerConfig.Key key =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/foo");
+    codeOwnerConfigCache.put(key, REVISION_1, Optional.empty());
+
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_1)).hasValue(Optional.empty());
+  }
+
+  @Test
+  public void revisionMismatchDoesNotReturnStaleConfig() {
+    CodeOwnerConfig.Key key =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/foo");
+    CodeOwnerConfig configRev1 = CodeOwnerConfig.builder(key, REVISION_1).build();
+
+    // Cache entry for revision 1
+    codeOwnerConfigCache.put(key, REVISION_1, Optional.of(configRev1));
+
+    // Lookup for revision 2 should miss, preventing stale data after branch update
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_2)).isEmpty();
+  }
+
+  @Test
+  public void disabledWhenMaxCacheSizeIsZero() {
+    Cache<CodeOwnerConfigCache.Key, Optional<CodeOwnerConfig>> cache =
+        CacheBuilder.newBuilder().maximumSize(0).build();
+    CodeOwnerConfigCache disabledCache = new CodeOwnerConfigCache(cache);
+    CodeOwnerConfig.Key key =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/foo");
+    CodeOwnerConfig config = CodeOwnerConfig.builder(key, REVISION_1).build();
+
+    disabledCache.put(key, REVISION_1, Optional.of(config));
+    // When size is 0, maximumSize(0) evicts immediately
+    assertThat(disabledCache.getIfPresent(key, REVISION_1)).isEmpty();
+  }
+
+  @Test
+  public void l2HitPopulatesL1InTransientCache() {
+    Project.NameKey project = Project.nameKey("project");
+    String branch = "master";
+    CodeOwnerConfig.Key key = CodeOwnerConfig.Key.create(project, branch, "/foo");
+    CodeOwnerConfig config = CodeOwnerConfig.builder(key, REVISION_1).build();
+
+    // Populate L2 directly
+    codeOwnerConfigCache.put(key, REVISION_1, Optional.of(config));
+
+    TransientCodeOwnerConfigCache transientCache =
+        new TransientCodeOwnerConfigCache(
+            plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class),
+            plugin.getSysInjector().getInstance(GitRepositoryManager.class),
+            plugin.getSysInjector().getInstance(CodeOwners.class),
+            codeOwnerConfigCache,
+            plugin.getSysInjector().getInstance(CodeOwnerMetrics.class));
+
+    // First call: L1 miss, L2 hit -> returns config and populates L1
+    Optional<CodeOwnerConfig> result1 = transientCache.get(key, REVISION_1);
+    assertThat(result1).hasValue(config);
+    assertThat(transientCache.getCounters().getCacheReadCount()).isEqualTo(1);
+    assertThat(transientCache.getCounters().getBackendReadCount()).isEqualTo(0);
+
+    // Second call: L1 hit directly from local HashMap
+    Optional<CodeOwnerConfig> result2 = transientCache.get(key, REVISION_1);
+    assertThat(result2).hasValue(config);
+    assertThat(transientCache.getCounters().getCacheReadCount()).isEqualTo(2);
+    assertThat(transientCache.getCounters().getBackendReadCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void l1MaxCacheSizeRespectedOnL2Hit() {
+    Project.NameKey project = Project.nameKey("project");
+    String branch = "master";
+    CodeOwnerConfig.Key key1 = CodeOwnerConfig.Key.create(project, branch, "/foo");
+    CodeOwnerConfig.Key key2 = CodeOwnerConfig.Key.create(project, branch, "/bar");
+    CodeOwnerConfig config1 = CodeOwnerConfig.builder(key1, REVISION_1).build();
+    CodeOwnerConfig config2 = CodeOwnerConfig.builder(key2, REVISION_1).build();
+
+    codeOwnerConfigCache.put(key1, REVISION_1, Optional.of(config1));
+    codeOwnerConfigCache.put(key2, REVISION_1, Optional.of(config2));
+
+    CodeOwnersPluginConfiguration mockConfig = mock(CodeOwnersPluginConfiguration.class);
+    CodeOwnersPluginGlobalConfigSnapshot mockSnapshot =
+        mock(CodeOwnersPluginGlobalConfigSnapshot.class);
+    when(mockConfig.getGlobalConfig()).thenReturn(mockSnapshot);
+    // Limit L1 to 1 entry
+    when(mockSnapshot.getMaxCodeOwnerConfigCacheSize()).thenReturn(Optional.of(1));
+
+    TransientCodeOwnerConfigCache transientCache =
+        new TransientCodeOwnerConfigCache(
+            mockConfig,
+            plugin.getSysInjector().getInstance(GitRepositoryManager.class),
+            plugin.getSysInjector().getInstance(CodeOwners.class),
+            codeOwnerConfigCache,
+            plugin.getSysInjector().getInstance(CodeOwnerMetrics.class));
+
+    // First L2 hit populates L1 (size reaches 1)
+    Optional<CodeOwnerConfig> result1 = transientCache.get(key1, REVISION_1);
+    assertThat(result1).hasValue(config1);
+
+    // Second L2 hit: L1 size limit is reached, so key2 must not expand L1 indefinitely
+    Optional<CodeOwnerConfig> result2 = transientCache.get(key2, REVISION_1);
+    assertThat(result2).hasValue(config2);
+  }
+
+  @Test
+  public void l2MissLoadsFromBackendAndPopulatesBothL1AndL2() {
+    Project.NameKey project = Project.nameKey("project");
+    String branch = "master";
+    CodeOwnerConfig.Key key = CodeOwnerConfig.Key.create(project, branch, "/foo");
+    CodeOwnerConfig config = CodeOwnerConfig.builder(key, REVISION_1).build();
+
+    CodeOwners mockCodeOwners = mock(CodeOwners.class);
+    when(mockCodeOwners.get(key, REVISION_1)).thenReturn(Optional.of(config));
+
+    TransientCodeOwnerConfigCache transientCache =
+        new TransientCodeOwnerConfigCache(
+            plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class),
+            plugin.getSysInjector().getInstance(GitRepositoryManager.class),
+            mockCodeOwners,
+            codeOwnerConfigCache,
+            plugin.getSysInjector().getInstance(CodeOwnerMetrics.class));
+
+    // Initially L2 is empty
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_1)).isEmpty();
+
+    // Call get: L1 miss, L2 miss -> loads from CodeOwners backend, populates L1 and L2
+    Optional<CodeOwnerConfig> result = transientCache.get(key, REVISION_1);
+    assertThat(result).hasValue(config);
+    assertThat(transientCache.getCounters().getCacheReadCount()).isEqualTo(0);
+    assertThat(transientCache.getCounters().getBackendReadCount()).isEqualTo(1);
+
+    // Verify L2 is now populated
+    assertThat(codeOwnerConfigCache.getIfPresent(key, REVISION_1)).hasValue(Optional.of(config));
+
+    // Verify subsequent call to transientCache hits L1
+    Optional<CodeOwnerConfig> result2 = transientCache.get(key, REVISION_1);
+    assertThat(result2).hasValue(config);
+    assertThat(transientCache.getCounters().getCacheReadCount()).isEqualTo(1);
+    assertThat(transientCache.getCounters().getBackendReadCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void nonExistentBranchCachedAsEmptyInL1() {
+    CodeOwnerConfig.Key key = CodeOwnerConfig.Key.create(project, "non-existent-branch", "/foo");
+
+    TransientCodeOwnerConfigCache transientCache =
+        new TransientCodeOwnerConfigCache(
+            plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class),
+            plugin.getSysInjector().getInstance(GitRepositoryManager.class),
+            plugin.getSysInjector().getInstance(CodeOwners.class),
+            codeOwnerConfigCache,
+            plugin.getSysInjector().getInstance(CodeOwnerMetrics.class));
+
+    // When revision is null, resolving against a non-existent branch returns Optional.empty()
+    Optional<CodeOwnerConfig> result1 = transientCache.get(key, /* revision= */ null);
+    assertThat(result1).isEmpty();
+
+    // Subsequent read returns cached Optional.empty() from L1 without re-querying disk
+    Optional<CodeOwnerConfig> result2 = transientCache.get(key, /* revision= */ null);
+    assertThat(result2).isEmpty();
+    assertThat(transientCache.getCounters().getCacheReadCount()).isEqualTo(1);
+    assertThat(transientCache.getCounters().getBackendReadCount()).isEqualTo(0);
+  }
+}