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 ≤ 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); + } +}