diff --git a/core/src/main/java/google/registry/cache/CacheMetrics.java b/core/src/main/java/google/registry/cache/CacheMetrics.java new file mode 100644 index 000000000..130d63cd6 --- /dev/null +++ b/core/src/main/java/google/registry/cache/CacheMetrics.java @@ -0,0 +1,51 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// 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 google.registry.cache; + +import com.google.common.collect.ImmutableSet; +import com.google.monitoring.metrics.IncrementableMetric; +import com.google.monitoring.metrics.LabelDescriptor; +import com.google.monitoring.metrics.MetricRegistryImpl; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +/** Metrics tracking effectiveness of local and remote EPP resource caching. */ +@Singleton +public class CacheMetrics { + + public enum CacheHitType { + LOCAL, + REMOTE, + MISS, + MISS_NONEXISTENT + } + + private static final ImmutableSet LABEL_DESCRIPTORS = + ImmutableSet.of( + LabelDescriptor.create("cache_name", "The type of the cache (domain/host)."), + LabelDescriptor.create("hit_type", "The type of cache hit or miss.")); + + private static final IncrementableMetric cacheLookups = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/cache/lookups", "Count of cache lookups", "count", LABEL_DESCRIPTORS); + + @Inject + public CacheMetrics() {} + + public void recordLookup(String cacheName, CacheHitType hitType) { + cacheLookups.increment(cacheName, hitType.toString()); + } +} diff --git a/core/src/main/java/google/registry/cache/CacheModule.java b/core/src/main/java/google/registry/cache/CacheModule.java index cd8e5d46e..d43c64e74 100644 --- a/core/src/main/java/google/registry/cache/CacheModule.java +++ b/core/src/main/java/google/registry/cache/CacheModule.java @@ -92,22 +92,23 @@ public final class CacheModule { @Provides @Singleton public static DomainCache provideDomainCache( - Optional domainJedisClient, Clock clock) { - if (domainJedisClient.isEmpty()) { + Optional jedisClient, Clock clock, CacheMetrics cacheMetrics) { + if (jedisClient.isEmpty()) { return domainName -> ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); } - return new MultilayerDomainCache(domainJedisClient.get(), clock); + return new MultilayerDomainCache(jedisClient.get(), clock, cacheMetrics); } @Provides @Singleton - public static HostCache provideHostCache(Optional hostJedisClient) { - if (hostJedisClient.isEmpty()) { + public static HostCache provideHostCache( + Optional jedisClient, CacheMetrics cacheMetrics) { + if (jedisClient.isEmpty()) { return repoId -> Optional.ofNullable(EppResource.loadByCache(VKey.create(Host.class, repoId))); } - return new MultilayerHostCache(hostJedisClient.get()); + return new MultilayerHostCache(jedisClient.get(), cacheMetrics); } @Provides diff --git a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java index 024c95102..0f0bce054 100644 --- a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java @@ -32,8 +32,9 @@ public class MultilayerDomainCache extends MultilayerEppResourceCache private final Clock clock; - public MultilayerDomainCache(SimplifiedJedisClient jedisClient, Clock clock) { - super(jedisClient); + public MultilayerDomainCache( + SimplifiedJedisClient jedisClient, Clock clock, CacheMetrics cacheMetrics) { + super(jedisClient, cacheMetrics); this.clock = clock; } diff --git a/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java index de53f60e4..36cff38ae 100644 --- a/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java @@ -36,9 +36,12 @@ public abstract class MultilayerEppResourceCache { .build(); private final SimplifiedJedisClient jedisClient; + private final CacheMetrics cacheMetrics; - protected MultilayerEppResourceCache(SimplifiedJedisClient jedisClient) { + protected MultilayerEppResourceCache( + SimplifiedJedisClient jedisClient, CacheMetrics cacheMetrics) { this.jedisClient = jedisClient; + this.cacheMetrics = cacheMetrics; } protected abstract Optional loadFromDatabase(String key); @@ -51,6 +54,7 @@ public abstract class MultilayerEppResourceCache { // hopefully the resource is in the local cache Optional possibleValue = Optional.ofNullable(localCache.getIfPresent(key)); if (possibleValue.isPresent()) { + cacheMetrics.recordLookup(clazz.getSimpleName(), CacheMetrics.CacheHitType.LOCAL); return possibleValue; } @@ -58,19 +62,22 @@ public abstract class MultilayerEppResourceCache { possibleValue = jedisClient.get(clazz, key); if (possibleValue.isPresent()) { localCache.put(key, possibleValue.get()); + cacheMetrics.recordLookup(clazz.getSimpleName(), CacheMetrics.CacheHitType.REMOTE); return possibleValue; } // lastly, try the DB - return loadFromDatabase(key) - .map( - v -> { - // Optional has no direct "peek" functionality to fill the caches - if (shouldPersistToRemoteCache(v)) { - jedisClient.set(new SimplifiedJedisClient.JedisResource<>(key, v)); - } - localCache.put(key, v); - return v; - }); + possibleValue = loadFromDatabase(key); + if (possibleValue.isEmpty()) { + cacheMetrics.recordLookup(clazz.getSimpleName(), CacheMetrics.CacheHitType.MISS_NONEXISTENT); + return possibleValue; + } + V value = possibleValue.get(); + if (shouldPersistToRemoteCache(value)) { + jedisClient.set(new SimplifiedJedisClient.JedisResource<>(key, value)); + } + localCache.put(key, value); + cacheMetrics.recordLookup(clazz.getSimpleName(), CacheMetrics.CacheHitType.MISS); + return possibleValue; } } diff --git a/core/src/main/java/google/registry/cache/MultilayerHostCache.java b/core/src/main/java/google/registry/cache/MultilayerHostCache.java index 8bb21be7d..a0b4f587f 100644 --- a/core/src/main/java/google/registry/cache/MultilayerHostCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerHostCache.java @@ -27,8 +27,8 @@ import java.util.Optional; */ public class MultilayerHostCache extends MultilayerEppResourceCache implements HostCache { - public MultilayerHostCache(SimplifiedJedisClient jedisClient) { - super(jedisClient); + public MultilayerHostCache(SimplifiedJedisClient jedisClient, CacheMetrics cacheMetrics) { + super(jedisClient, cacheMetrics); } @Override diff --git a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java index f09302e18..b09d9c399 100644 --- a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java +++ b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java @@ -43,11 +43,12 @@ public class MultilayerDomainCacheTest { private final SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); private final FakeClock clock = new FakeClock(); + private final CacheMetrics cacheMetrics = mock(CacheMetrics.class); private MultilayerDomainCache cache; @BeforeEach void beforeEach() { - cache = new MultilayerDomainCache(jedisClient, clock); + cache = new MultilayerDomainCache(jedisClient, clock, cacheMetrics); createTld("tld"); } @@ -59,10 +60,13 @@ public class MultilayerDomainCacheTest { // We should have filled the caches after one attempt to load from Valkey verify(jedisClient).get(Domain.class, "example.tld"); verify(jedisClient).set(new SimplifiedJedisClient.JedisResource<>("example.tld", domain)); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); // Further loads hit the local cache assertThat(cache.loadByDomainName("example.tld")).hasValue(domain); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.LOCAL); verifyNoMoreInteractions(jedisClient); + verifyNoMoreInteractions(cacheMetrics); } @Test @@ -72,6 +76,8 @@ public class MultilayerDomainCacheTest { // We hit the Valkey cache first when(jedisClient.get(Domain.class, "example.tld")).thenReturn(Optional.of(domain)); assertThat(cache.loadByDomainName("example.tld")).hasValue(domain); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.REMOTE); + verifyNoMoreInteractions(cacheMetrics); } @Test @@ -83,11 +89,15 @@ public class MultilayerDomainCacheTest { // This time, we don't populate the remote cache because it's prober data verify(jedisClient).get(Domain.class, "example.tld"); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); verifyNoMoreInteractions(jedisClient); + verifyNoMoreInteractions(cacheMetrics); } @Test void testLoad_missing() { assertThat(cache.loadByDomainName("nonexistent.tld")).isEmpty(); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS_NONEXISTENT); + verifyNoMoreInteractions(cacheMetrics); } } diff --git a/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java index 3a2dedf38..9bf5107ec 100644 --- a/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java +++ b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java @@ -38,11 +38,12 @@ public class MultilayerHostCacheTest { new JpaTestExtensions.Builder().buildIntegrationTestExtension(); private final SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + private final CacheMetrics cacheMetrics = mock(CacheMetrics.class); private MultilayerHostCache cache; @BeforeEach void beforeEach() { - cache = new MultilayerHostCache(jedisClient); + cache = new MultilayerHostCache(jedisClient, cacheMetrics); } @Test @@ -53,10 +54,13 @@ public class MultilayerHostCacheTest { // We should have filled the caches after one attempt to load from Valkey verify(jedisClient).get(Host.class, host.getRepoId()); verify(jedisClient).set(new SimplifiedJedisClient.JedisResource<>(host.getRepoId(), host)); + verify(cacheMetrics).recordLookup("Host", CacheMetrics.CacheHitType.MISS); // Further loads hit the local cache assertThat(cache.loadByRepoId(host.getRepoId())).hasValue(host); + verify(cacheMetrics).recordLookup("Host", CacheMetrics.CacheHitType.LOCAL); verifyNoMoreInteractions(jedisClient); + verifyNoMoreInteractions(cacheMetrics); } @Test @@ -66,10 +70,14 @@ public class MultilayerHostCacheTest { // We hit the Valkey cache first when(jedisClient.get(Host.class, host.getRepoId())).thenReturn(Optional.of(host)); assertThat(cache.loadByRepoId(host.getRepoId())).hasValue(host); + verify(cacheMetrics).recordLookup("Host", CacheMetrics.CacheHitType.REMOTE); + verifyNoMoreInteractions(cacheMetrics); } @Test void testLoad_missing() { assertThat(cache.loadByRepoId("nonexistent")).isEmpty(); + verify(cacheMetrics).recordLookup("Host", CacheMetrics.CacheHitType.MISS_NONEXISTENT); + verifyNoMoreInteractions(cacheMetrics); } }