From 8cf222d1c9781d97ed8e7fcb3dac3b55b9dabba7 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Fri, 24 Apr 2026 15:50:01 -0400 Subject: [PATCH] Add Jedis (for Valkey) caches for domains and hosts (#3013) We add optional Valkey caching of hosts and domains for future use. Eventually, this will allow us to pre-warm large amounts of data in Valkey for quick retrieval during actions like RDAP. Note: this doesn't actually use the caches yet. We use Jedis instead of Redisson for speed purposes (https://www.instaclustr.com/blog/redis-java-clients-and-client-side-caching/) which means that we have to implement our own multilayer cache but that's not the worst thing in the world. Tested on crash with logging and RDAP code that's not included in this PR -- it behaves as you'd expect, where the local cache works for immediate re-lookups and the remote cache works after a restart. --- console-webapp/package-lock.json | 123 ------------- core/build.gradle | 4 + core/gradle.lockfile | 12 +- .../google/registry/cache/CacheModule.java | 163 ++++++++++++++++++ .../google/registry/cache/DomainCache.java | 23 +++ .../java/google/registry/cache/HostCache.java | 23 +++ .../registry/cache/MultilayerDomainCache.java | 62 +++++++ .../cache/MultilayerEppResourceCache.java | 73 ++++++++ .../registry/cache/MultilayerHostCache.java | 49 ++++++ .../registry/cache/SimplifiedJedisClient.java | 80 +++++++++ .../cache/ValkeyCredentialsProvider.java | 46 +++++ .../registry/config/RegistryConfig.java | 8 + .../config/RegistryConfigSettings.java | 6 + .../registry/config/files/default-config.yaml | 4 + .../registry/keyring/KeyringModule.java | 6 + .../google/registry/keyring/api/Keyring.java | 2 + .../secretmanager/SecretManagerKeyring.java | 13 +- .../registry/model/ForeignKeyUtils.java | 2 +- .../model/eppcommon/EppXmlTransformer.java | 4 +- .../registry/module/RegistryComponent.java | 2 + .../cache/MultilayerDomainCacheTest.java | 80 +++++++++ .../cache/MultilayerHostCacheTest.java | 76 ++++++++ .../cache/SimplifiedJedisClientTest.java | 93 ++++++++++ .../module/TestRegistryComponent.java | 2 + .../registry/testing/FakeKeyringModule.java | 6 + dependencies.gradle | 4 + jetty/gradle.lockfile | 11 +- 27 files changed, 845 insertions(+), 132 deletions(-) create mode 100644 core/src/main/java/google/registry/cache/CacheModule.java create mode 100644 core/src/main/java/google/registry/cache/DomainCache.java create mode 100644 core/src/main/java/google/registry/cache/HostCache.java create mode 100644 core/src/main/java/google/registry/cache/MultilayerDomainCache.java create mode 100644 core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java create mode 100644 core/src/main/java/google/registry/cache/MultilayerHostCache.java create mode 100644 core/src/main/java/google/registry/cache/SimplifiedJedisClient.java create mode 100644 core/src/main/java/google/registry/cache/ValkeyCredentialsProvider.java create mode 100644 core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java create mode 100644 core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java create mode 100644 core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java diff --git a/console-webapp/package-lock.json b/console-webapp/package-lock.json index 82cfce53c..5b0bec4b4 100644 --- a/console-webapp/package-lock.json +++ b/console-webapp/package-lock.json @@ -628,18 +628,6 @@ } } }, - "node_modules/@angular/build/node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.18.0" - } - }, "node_modules/@angular/build/node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -653,24 +641,6 @@ "vite": "^6.0.0 || ^7.0.0" } }, - "node_modules/@angular/build/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/build/node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -694,22 +664,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular/build/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/build/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -743,15 +697,6 @@ "node": ">= 12" } }, - "node_modules/@angular/build/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@angular/build/node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -971,24 +916,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@angular/cli/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/cli-spinners": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", @@ -1092,22 +1019,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4695,24 +4606,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@schematics/angular/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/cli-spinners": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", @@ -4816,22 +4709,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", diff --git a/core/build.gradle b/core/build.gradle index dc281b761..12b127b81 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -190,6 +190,9 @@ dependencies { testRuntimeOnly deps['guru.nidi:graphviz-java-all-j2v8'] testImplementation deps['io.github.classgraph:classgraph'] testRuntimeOnly deps['io.github.java-diff-utils:java-diff-utils'] + testImplementation deps['io.github.ss-bhatt:testcontainers-valkey'] + implementation deps['io.protostuff:protostuff-core'] + implementation deps['io.protostuff:protostuff-runtime'] implementation deps['jakarta.inject:jakarta.inject-api'] implementation deps['jakarta.mail:jakarta.mail-api'] implementation deps['jakarta.persistence:jakarta.persistence-api'] @@ -243,6 +246,7 @@ dependencies { implementation deps['org.testcontainers:postgresql'] testImplementation deps['org.testcontainers:selenium'] testImplementation deps['org.testcontainers:testcontainers'] + implementation deps['redis.clients:jedis'] implementation deps['us.fatehi:schemacrawler'] implementation deps['us.fatehi:schemacrawler-api'] implementation deps['us.fatehi:schemacrawler-diagram'] diff --git a/core/gradle.lockfile b/core/gradle.lockfile index a90e004f2..80dfc274d 100644 --- a/core/gradle.lockfile +++ b/core/gradle.lockfile @@ -173,7 +173,7 @@ com.google.cloud:proto-google-cloud-firestore-bundle-v1:3.25.1=compileClasspath, com.google.cloud:proto-google-cloud-firestore-bundle-v1:3.26.5=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,checkstyle,compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,soy,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath com.google.code.gson:gson:2.10.1=soy -com.google.code.gson:gson:2.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.common.html.types:types:1.0.8=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,soy,testCompileClasspath,testRuntimeClasspath com.google.dagger:dagger-compiler:2.59.2=annotationProcessor,testAnnotationProcessor com.google.dagger:dagger-spi:2.59.2=annotationProcessor,testAnnotationProcessor @@ -290,6 +290,7 @@ io.github.classgraph:classgraph:4.8.162=compileClasspath,deploy_jar,nonprodCompi io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,nonprodAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,nonprodAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.16=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.ss-bhatt:testcontainers-valkey:1.0.0=testCompileClasspath,testRuntimeClasspath io.grpc:grpc-alts:1.70.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath io.grpc:grpc-alts:1.80.0=testCompileClasspath,testRuntimeClasspath io.grpc:grpc-api:1.70.0=compileClasspath,nonprodCompileClasspath @@ -396,6 +397,10 @@ io.opentelemetry:opentelemetry-sdk:1.60.1=testCompileClasspath,testRuntimeClassp io.opentelemetry:opentelemetry-semconv:1.26.0-alpha=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.outfoxx:swiftpoet:1.3.1=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.protostuff:protostuff-api:1.8.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.protostuff:protostuff-collectionschema:1.8.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.protostuff:protostuff-core:1.8.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.protostuff:protostuff-runtime:1.8.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta-regexp:jakarta-regexp:1.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=jaxb jakarta.activation:jakarta.activation-api:2.2.0-M1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -456,6 +461,7 @@ org.apache.commons:commons-exec:1.3=testRuntimeClasspath org.apache.commons:commons-lang3:3.18.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath org.apache.commons:commons-lang3:3.20.0=testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.8.1=checkstyle +org.apache.commons:commons-pool2:2.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.3=checkstyle org.apache.ftpserver:ftplet-api:1.2.1=testCompileClasspath,testRuntimeClasspath @@ -563,7 +569,7 @@ org.jetbrains:annotations:17.0.0=compileClasspath,deploy_jar,nonprodCompileClass org.jline:jline:3.30.5=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.joda:joda-money:2.0.3=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.json:json:20230618=soy -org.json:json:20240303=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.json:json:20251224=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jsoup:jsoup:1.22.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,deploy_jar,nonprodAnnotationProcessor,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit-pioneer:junit-pioneer:2.3.0=testCompileClasspath,testRuntimeClasspath @@ -635,6 +641,8 @@ org.webjars.npm:viz.js-graphviz-java:2.1.3=testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.2.2=checkstyle org.yaml:snakeyaml:2.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients:jedis:7.4.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.1.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/core/src/main/java/google/registry/cache/CacheModule.java b/core/src/main/java/google/registry/cache/CacheModule.java new file mode 100644 index 000000000..856946c10 --- /dev/null +++ b/core/src/main/java/google/registry/cache/CacheModule.java @@ -0,0 +1,163 @@ +// 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 static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import dagger.Module; +import dagger.Provides; +import google.registry.config.CredentialModule.ApplicationDefaultCredential; +import google.registry.config.RegistryConfig.Config; +import google.registry.model.EppResource; +import google.registry.model.ForeignKeyUtils; +import google.registry.model.domain.Domain; +import google.registry.model.host.Host; +import google.registry.persistence.VKey; +import google.registry.util.Clock; +import google.registry.util.GoogleCredentialsBundle; +import jakarta.inject.Singleton; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Optional; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisClientConfig; +import redis.clients.jedis.RedisClient; +import redis.clients.jedis.RedisClusterClient; +import redis.clients.jedis.UnifiedJedis; + +/** Dagger module to provide the {@link Jedis}-based cache for Valkey. */ +@Module +public final class CacheModule { + + @Provides + @Singleton + public static Optional provideJedis( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle, + @Config("valkeyHostsAndPorts") Optional> valkeyHostsAndPorts, + @Config("valkeySslSocketFactory") SSLSocketFactory valkeySslSocketFactory) { + if (valkeyHostsAndPorts.map(ImmutableList::isEmpty).orElse(true)) { + return Optional.empty(); + } + ImmutableSet hostsAndPorts = + valkeyHostsAndPorts.get().stream().map(HostAndPort::from).collect(toImmutableSet()); + JedisClientConfig clientConfig = + DefaultJedisClientConfig.builder() + .ssl(true) + .sslSocketFactory(valkeySslSocketFactory) + .credentialsProvider(new ValkeyCredentialsProvider(credentialsBundle)) + .build(); + if (hostsAndPorts.size() > 1) { + return Optional.of( + RedisClusterClient.builder().clientConfig(clientConfig).nodes(hostsAndPorts).build()); + } + return Optional.of( + RedisClient.builder() + .clientConfig(clientConfig) + .hostAndPort(Iterables.getOnlyElement(hostsAndPorts)) + .build()); + } + + @Provides + @Singleton + public static DomainCache provideDomainCache(Optional jedis, Clock clock) { + if (jedis.isEmpty()) { + return domainName -> + ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); + } + SimplifiedJedisClient jedisClient = + SimplifiedJedisClient.create(Domain.class, jedis.get()); + return new MultilayerDomainCache(jedisClient, clock); + } + + @Provides + @Singleton + public static HostCache provideHostCache(Optional jedis) { + if (jedis.isEmpty()) { + return repoId -> + Optional.ofNullable(EppResource.loadByCache(VKey.create(Host.class, repoId))); + } + SimplifiedJedisClient jedisClient = SimplifiedJedisClient.create(Host.class, jedis.get()); + return new MultilayerHostCache(jedisClient); + } + + @Provides + @Singleton + @Config("valkeySslSocketFactory") + static SSLSocketFactory provideValkeySslSocketFactory( + @Config("valkeyCertificateAuthority") String valkeyCertificateAuthority) { + try { + ImmutableList trustedCerts = + CertificateFactory.getInstance("X.509") + .generateCertificates( + new ByteArrayInputStream( + valkeyCertificateAuthority.getBytes(StandardCharsets.UTF_8))) + .stream() + .map(X509Certificate.class::cast) + .collect(toImmutableList()); + + // This is a roundabout way to trust the Cloud Memorystore-issued certificate authority even + // though it's not a root cert (it's an intermediate cert). + TrustManager x509TrustManager = + new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return trustedCerts.toArray(new X509Certificate[0]); + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType) + throws CertificateException { + Exception lastException = null; + for (X509Certificate cert : certs) { + for (X509Certificate trustedCert : trustedCerts) { + try { + cert.verify(trustedCert.getPublicKey()); + return; + } catch (Exception e) { + // Verification failed, try the next one + lastException = e; + } + } + } + throw new CertificateException( + "None of the server certificates were signed by the provided CA", lastException); + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {x509TrustManager}, null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new RuntimeException("Could not create X.509 certificate from provided PEM", e); + } + } +} diff --git a/core/src/main/java/google/registry/cache/DomainCache.java b/core/src/main/java/google/registry/cache/DomainCache.java new file mode 100644 index 000000000..c92d339f7 --- /dev/null +++ b/core/src/main/java/google/registry/cache/DomainCache.java @@ -0,0 +1,23 @@ +// 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 google.registry.model.domain.Domain; +import java.util.Optional; + +/** Interface for some type of cache that loads {@link Domain}s by domain name. */ +public interface DomainCache { + Optional loadByDomainName(String domainName); +} diff --git a/core/src/main/java/google/registry/cache/HostCache.java b/core/src/main/java/google/registry/cache/HostCache.java new file mode 100644 index 000000000..27eaef601 --- /dev/null +++ b/core/src/main/java/google/registry/cache/HostCache.java @@ -0,0 +1,23 @@ +// 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 google.registry.model.host.Host; +import java.util.Optional; + +/** Interface for some type of cache that loads {@link Host}s by repo ID. */ +public interface HostCache { + Optional loadByRepoId(String repoId); +} diff --git a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java new file mode 100644 index 000000000..24869ee40 --- /dev/null +++ b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java @@ -0,0 +1,62 @@ +// 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.ImmutableList; +import google.registry.model.ForeignKeyUtils; +import google.registry.model.domain.Domain; +import google.registry.util.Clock; +import java.time.Instant; +import java.util.Optional; + +/** + * A multi-layer cache for {@link Domain} objects. + * + *

It uses a local Caffeine cache, a remote Jedis cache, and finally the database. + */ +public class MultilayerDomainCache extends MultilayerEppResourceCache + implements DomainCache { + + private final Clock clock; + + public MultilayerDomainCache(SimplifiedJedisClient jedisClient, Clock clock) { + super(jedisClient); + this.clock = clock; + } + + @Override + public Optional loadByDomainName(String domainName) { + return loadFromCaches(domainName); + } + + @Override + protected Optional loadFromDatabase(String domainName) { + // Don't use the cache (avoid caching the same domain twice). Do use the replica SQL instance. + Optional possibleDomain = + Optional.ofNullable( + ForeignKeyUtils.loadMostRecentResourceObjects( + Domain.class, ImmutableList.of(domainName), true) + .get(domainName)); + Instant now = clock.now(); + return possibleDomain + .filter(domain -> now.isBefore(domain.getDeletionTime())) + .map(domain -> domain.cloneProjectedAtInstant(now)); + } + + @Override + protected String getJedisPrefix() { + return "Domain__"; + } +} diff --git a/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java new file mode 100644 index 000000000..d86e874f4 --- /dev/null +++ b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java @@ -0,0 +1,73 @@ +// 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.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import google.registry.config.RegistryConfig; +import google.registry.model.EppResource; +import java.time.Duration; +import java.util.Optional; + +/** + * A multi-layer cache for {@link EppResource}s. + * + *

It uses a local Caffeine cache, a remote Jedis cache, and finally the database. + */ +public abstract class MultilayerEppResourceCache { + + // Don't use a loading cache; it'd complicate the nesting + private final Cache localCache = + Caffeine.newBuilder() + .expireAfterWrite(Duration.ofHours(1)) + .maximumSize(RegistryConfig.getEppResourceMaxCachedEntries()) + .build(); + + private final SimplifiedJedisClient jedisClient; + + protected MultilayerEppResourceCache(SimplifiedJedisClient jedisClient) { + this.jedisClient = jedisClient; + } + + protected abstract Optional loadFromDatabase(String key); + + protected abstract String getJedisPrefix(); + + protected Optional loadFromCaches(String key) { + // hopefully the resource is in the local cache + Optional possibleValue = Optional.ofNullable(localCache.getIfPresent(key)); + if (possibleValue.isPresent()) { + return possibleValue; + } + + // if not, try the remote cache + String jedisKey = getJedisPrefix() + key; + possibleValue = jedisClient.get(jedisKey); + if (possibleValue.isPresent()) { + localCache.put(key, possibleValue.get()); + return possibleValue; + } + + // lastly, try the DB + return loadFromDatabase(key) + .map( + v -> { + // Optional has no direct "peek" functionality to fill the caches + jedisClient.set(jedisKey, v); + localCache.put(key, v); + return v; + }); + } +} diff --git a/core/src/main/java/google/registry/cache/MultilayerHostCache.java b/core/src/main/java/google/registry/cache/MultilayerHostCache.java new file mode 100644 index 000000000..e6a6d53f2 --- /dev/null +++ b/core/src/main/java/google/registry/cache/MultilayerHostCache.java @@ -0,0 +1,49 @@ +// 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 static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm; + +import google.registry.model.host.Host; +import google.registry.persistence.VKey; +import java.util.Optional; + +/** + * A multi-layer cache for {@link Host} objects. + * + *

It uses a local Caffeine cache, a remote Jedis cache, and finally the database. + */ +public class MultilayerHostCache extends MultilayerEppResourceCache implements HostCache { + + public MultilayerHostCache(SimplifiedJedisClient jedisClient) { + super(jedisClient); + } + + @Override + public Optional loadByRepoId(String repoId) { + return loadFromCaches(repoId); + } + + @Override + protected Optional loadFromDatabase(String repoId) { + return replicaTm() + .transact(() -> replicaTm().loadByKeyIfPresent(VKey.create(Host.class, repoId))); + } + + @Override + protected String getJedisPrefix() { + return "Host__"; + } +} diff --git a/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java b/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java new file mode 100644 index 000000000..0e975aece --- /dev/null +++ b/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java @@ -0,0 +1,80 @@ +// 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 static com.google.common.base.Preconditions.checkNotNull; + +import google.registry.model.EppResource; +import io.protostuff.LinkedBuffer; +import io.protostuff.ProtostuffIOUtil; +import io.protostuff.Schema; +import io.protostuff.runtime.RuntimeSchema; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import redis.clients.jedis.UnifiedJedis; + +/** + * A {@link UnifiedJedis} client that handles serialization/deserialization. + * + *

We use protobufs for serialization to handle the immutable collections that our objects use. + * + *

{@link UnifiedJedis} pairs key-value types, so we need the key to be serialized to a byte + * array as well. + */ +public class SimplifiedJedisClient { + + private final Schema valueSchema; + private final UnifiedJedis jedis; + + public static SimplifiedJedisClient create( + Class valueClass, UnifiedJedis jedis) { + Schema valueSchema = RuntimeSchema.getSchema(valueClass); + return new SimplifiedJedisClient<>(valueSchema, jedis); + } + + private SimplifiedJedisClient(Schema valueSchema, UnifiedJedis jedis) { + this.valueSchema = valueSchema; + this.jedis = jedis; + } + + /** Gets the value from the remote cache. Returns null if it does not exist. */ + public Optional get(String key) { + checkNotNull(key, "Key cannot be null"); + byte[] data = jedis.get(key.getBytes(StandardCharsets.UTF_8)); + return Optional.ofNullable(data).map(this::deserialize); + } + + /** Sets the value in the remote cache. */ + public void set(String key, V value) { + checkNotNull(key, "Key cannot be null"); + checkNotNull(value, "Value cannot be null"); + jedis.set(key.getBytes(StandardCharsets.UTF_8), serialize(value)); + } + + private byte[] serialize(V value) { + LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE); + try { + return ProtostuffIOUtil.toByteArray(value, valueSchema, buffer); + } finally { + buffer.clear(); + } + } + + private V deserialize(byte[] data) { + V value = valueSchema.newMessage(); + ProtostuffIOUtil.mergeFrom(data, value, valueSchema); + return value; + } +} diff --git a/core/src/main/java/google/registry/cache/ValkeyCredentialsProvider.java b/core/src/main/java/google/registry/cache/ValkeyCredentialsProvider.java new file mode 100644 index 000000000..c5ffef25c --- /dev/null +++ b/core/src/main/java/google/registry/cache/ValkeyCredentialsProvider.java @@ -0,0 +1,46 @@ +// 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.auth.oauth2.GoogleCredentials; +import google.registry.util.GoogleCredentialsBundle; +import java.io.IOException; +import java.util.function.Supplier; +import redis.clients.jedis.DefaultRedisCredentials; +import redis.clients.jedis.RedisCredentials; + +public class ValkeyCredentialsProvider implements Supplier { + + private static final String MEMORYSTORE_AUTH_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + + private final GoogleCredentials credentials; + + public ValkeyCredentialsProvider(GoogleCredentialsBundle credentialsBundle) { + this.credentials = + credentialsBundle.getGoogleCredentials().createScoped(MEMORYSTORE_AUTH_SCOPE); + } + + @Override + public RedisCredentials get() { + try { + credentials.refreshIfExpired(); + } catch (IOException e) { + throw new RuntimeException("Unable to refresh IAM token for Memorystore", e); + } + String token = credentials.getAccessToken().getTokenValue(); + return new DefaultRedisCredentials(null, token); + } +} diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 30af9b84d..208267cbe 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1463,6 +1463,14 @@ public final class RegistryConfig { return config.mosapi.tldThreadCount; } + @Provides + @Config("valkeyHostsAndPorts") + public static Optional> provideValkeyHostsAndPorts( + RegistryConfigSettings config) { + return Optional.ofNullable(config.valkey) + .map(valkey -> ImmutableList.copyOf(valkey.hostsAndPorts)); + } + private static String formatComments(String text) { return Splitter.on('\n').omitEmptyStrings().trimResults().splitToList(text).stream() .map(s -> "# " + s) diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 368878eb7..c8a84e5ac 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -43,6 +43,7 @@ public class RegistryConfigSettings { public BulkPricingPackageMonitoring bulkPricingPackageMonitoring; public Bsa bsa; public MosApi mosapi; + public Valkey valkey; /** Configuration options that apply to the entire GCP project. */ public static class GcpProject { @@ -267,4 +268,9 @@ public class RegistryConfigSettings { public List services; public int tldThreadCount; } + + /** Configuration for Valkey caching. */ + public static class Valkey { + public List hostsAndPorts; + } } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 5e737a377..0ba35d283 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -639,3 +639,7 @@ mosapi: # ICANN MoSAPI Specification, Section 12.3 tldThreadCount: 4 +valkey: + # Optional: hosts and ports for remote Valkey caching, e.g. + # - "127.0.0.1:6379" + hostsAndPorts: [] diff --git a/core/src/main/java/google/registry/keyring/KeyringModule.java b/core/src/main/java/google/registry/keyring/KeyringModule.java index c9989bfba..bd400f922 100644 --- a/core/src/main/java/google/registry/keyring/KeyringModule.java +++ b/core/src/main/java/google/registry/keyring/KeyringModule.java @@ -52,4 +52,10 @@ public abstract class KeyringModule { int lastColonIndex = instanceConnectionName.lastIndexOf(':'); return instanceConnectionName.substring(lastColonIndex + 1); } + + @Provides + @Config("valkeyCertificateAuthority") + public static String provideValkeyCertificateAuthority(Keyring keyring) { + return keyring.getValkeyCertificateAuthority(); + } } diff --git a/core/src/main/java/google/registry/keyring/api/Keyring.java b/core/src/main/java/google/registry/keyring/api/Keyring.java index 10bad414f..ade446033 100644 --- a/core/src/main/java/google/registry/keyring/api/Keyring.java +++ b/core/src/main/java/google/registry/keyring/api/Keyring.java @@ -163,6 +163,8 @@ public interface Keyring extends AutoCloseable { /** Returns the Cloud SQL connection names of the replica database instances. */ ImmutableList getSqlReplicaConnectionNames(); + String getValkeyCertificateAuthority(); + // Don't throw so try-with-resources works better. @Override void close(); diff --git a/core/src/main/java/google/registry/keyring/secretmanager/SecretManagerKeyring.java b/core/src/main/java/google/registry/keyring/secretmanager/SecretManagerKeyring.java index 6ab7fbafa..6a2b68775 100644 --- a/core/src/main/java/google/registry/keyring/secretmanager/SecretManagerKeyring.java +++ b/core/src/main/java/google/registry/keyring/secretmanager/SecretManagerKeyring.java @@ -69,7 +69,8 @@ public class SecretManagerKeyring implements Keyring { SAFE_BROWSING_API_KEY, SQL_PRIMARY_CONN_NAME, SQL_REPLICA_CONN_NAME, - SQL_REPLICA_CONN_NAMES; + SQL_REPLICA_CONN_NAMES, + VALKEY_CERTIFICATE_AUTHORITY; String getLabel() { return UPPER_UNDERSCORE.to(LOWER_HYPHEN, name()); @@ -181,6 +182,16 @@ public class SecretManagerKeyring implements Keyring { } } + @Override + public String getValkeyCertificateAuthority() { + try { + return getString(StringKeyLabel.VALKEY_CERTIFICATE_AUTHORITY); + } catch (KeyringException e) { + // this is optional + return null; + } + } + /** No persistent resources are maintained for this Keyring implementation. */ @Override public void close() {} diff --git a/core/src/main/java/google/registry/model/ForeignKeyUtils.java b/core/src/main/java/google/registry/model/ForeignKeyUtils.java index bc1760c32..072dc6e6f 100644 --- a/core/src/main/java/google/registry/model/ForeignKeyUtils.java +++ b/core/src/main/java/google/registry/model/ForeignKeyUtils.java @@ -225,7 +225,7 @@ public final class ForeignKeyUtils { } /** Method to load the most recent {@link EppResource}s for the given foreign keys. */ - private static ImmutableMap loadMostRecentResourceObjects( + public static ImmutableMap loadMostRecentResourceObjects( Class clazz, Collection foreignKeys, boolean useReplicaTm) { String fkProperty = RESOURCE_TYPE_TO_FK_PROPERTY.get(clazz); JpaTransactionManager tmToUse = useReplicaTm ? replicaTm() : tm(); diff --git a/core/src/main/java/google/registry/model/eppcommon/EppXmlTransformer.java b/core/src/main/java/google/registry/model/eppcommon/EppXmlTransformer.java index 8b046cc8f..bef931f50 100644 --- a/core/src/main/java/google/registry/model/eppcommon/EppXmlTransformer.java +++ b/core/src/main/java/google/registry/model/eppcommon/EppXmlTransformer.java @@ -99,9 +99,7 @@ public class EppXmlTransformer { } return eppOutput.getResponse().getExtensions().stream() .map(EppResponse.ResponseExtension::getClass) - .filter(EppXmlTransformer::isFeeExtension) - .findAny() - .isPresent(); + .anyMatch(EppXmlTransformer::isFeeExtension); } @VisibleForTesting diff --git a/core/src/main/java/google/registry/module/RegistryComponent.java b/core/src/main/java/google/registry/module/RegistryComponent.java index 7f7170340..6c6fe280e 100644 --- a/core/src/main/java/google/registry/module/RegistryComponent.java +++ b/core/src/main/java/google/registry/module/RegistryComponent.java @@ -21,6 +21,7 @@ import dagger.Module; import dagger.Provides; import google.registry.batch.BatchModule; import google.registry.bigquery.BigqueryModule; +import google.registry.cache.CacheModule; import google.registry.config.CloudTasksUtilsModule; import google.registry.config.CredentialModule; import google.registry.config.RegistryConfig.Config; @@ -61,6 +62,7 @@ import jakarta.inject.Singleton; AuthModule.class, BatchModule.class, BigqueryModule.class, + CacheModule.class, CloudTasksUtilsModule.class, ConfigModule.class, CredentialModule.class, diff --git a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java new file mode 100644 index 000000000..d07ee9368 --- /dev/null +++ b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java @@ -0,0 +1,80 @@ +// 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 static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import google.registry.model.domain.Domain; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; +import google.registry.testing.DatabaseHelper; +import google.registry.testing.FakeClock; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link MultilayerDomainCache}. */ +public class MultilayerDomainCacheTest { + + @RegisterExtension + final JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + + private final SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + private final FakeClock clock = new FakeClock(); + private MultilayerDomainCache cache; + + @BeforeEach + void beforeEach() { + cache = new MultilayerDomainCache(jedisClient, clock); + createTld("tld"); + } + + @Test + void testLoad_fromDatabase_populatesCaches() { + Domain domain = persistActiveDomain("example.tld"); + assertThat(cache.loadByDomainName("example.tld")).hasValue(domain); + + // We should have filled the caches after one attempt to load from Valkey + verify(jedisClient).get("Domain__example.tld"); + verify(jedisClient).set("Domain__example.tld", domain); + + // Further loads hit the local cache + assertThat(cache.loadByDomainName("example.tld")).hasValue(domain); + verifyNoMoreInteractions(jedisClient); + } + + @Test + void testLoad_fromValkey() { + // Note: we don't save the domain to SQL + Domain domain = DatabaseHelper.newDomain("example.tld"); + // We hit the Valkey cache first + when(jedisClient.get(eq("Domain__example.tld"))).thenReturn(Optional.of(domain)); + assertThat(cache.loadByDomainName("example.tld")).hasValue(domain); + } + + @Test + void testLoad_missing() { + assertThat(cache.loadByDomainName("nonexistent.tld")).isEmpty(); + } +} diff --git a/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java new file mode 100644 index 000000000..0f7447cef --- /dev/null +++ b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java @@ -0,0 +1,76 @@ +// 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 static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.DatabaseHelper.persistActiveHost; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import google.registry.model.host.Host; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; +import google.registry.testing.DatabaseHelper; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link MultilayerHostCache}. */ +public class MultilayerHostCacheTest { + + @RegisterExtension + final JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + + private final SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + private MultilayerHostCache cache; + + @BeforeEach + void beforeEach() { + cache = new MultilayerHostCache(jedisClient); + } + + @Test + void testLoad_fromDatabase_populatesCaches() { + Host host = persistActiveHost("ns1.example.tld"); + assertThat(cache.loadByRepoId(host.getRepoId())).hasValue(host); + + // We should have filled the caches after one attempt to load from Valkey + verify(jedisClient).get("Host__" + host.getRepoId()); + verify(jedisClient).set("Host__" + host.getRepoId(), host); + + // Further loads hit the local cache + assertThat(cache.loadByRepoId(host.getRepoId())).hasValue(host); + verifyNoMoreInteractions(jedisClient); + } + + @Test + void testLoad_fromValkey() { + // Note: we don't save the host to SQL + Host host = DatabaseHelper.newHost("ns1.example.tld"); + // We hit the Valkey cache first + when(jedisClient.get(eq("Host__" + host.getRepoId()))).thenReturn(Optional.of(host)); + assertThat(cache.loadByRepoId(host.getRepoId())).hasValue(host); + } + + @Test + void testLoad_missing() { + assertThat(cache.loadByRepoId("nonexistent")).isEmpty(); + } +} diff --git a/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java b/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java new file mode 100644 index 000000000..7cc1bba63 --- /dev/null +++ b/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java @@ -0,0 +1,93 @@ +// 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 static com.google.common.truth.Truth.assertThat; +import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static google.registry.testing.DatabaseHelper.persistActiveHost; + +import google.registry.model.EppResource; +import google.registry.model.domain.Domain; +import google.registry.model.host.Host; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; +import google.registry.testing.FakeClock; +import io.github.ss_bhatt.testcontainers.valkey.ValkeyContainer; +import org.joda.time.DateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.RedisClient; +import redis.clients.jedis.UnifiedJedis; + +/** Tests for {@link SimplifiedJedisClient}. */ +@Testcontainers +public class SimplifiedJedisClientTest { + + @Container private static final ValkeyContainer valkey = new ValkeyContainer(); + + private final FakeClock fakeClock = new FakeClock(DateTime.parse("2025-01-01T00:00:00.000Z")); + + @RegisterExtension + final JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(fakeClock).buildIntegrationTestExtension(); + + @BeforeEach + void beforeEach() { + createTld("tld"); + } + + @Test + void testClient_roundTrip_domain() { + Domain domain = persistActiveDomain("example.tld"); + SimplifiedJedisClient client = createSimplifiedClient(Domain.class); + client.set("Domain__example.tld", domain); + // dsData and gracePeriods get serialized as null instead of the empty set, which is fine + assertAboutImmutableObjects() + .that(client.get("Domain__example.tld").get()) + .isEqualExceptFields(domain, "dsData", "gracePeriods"); + } + + @Test + void testClient_roundTrip_host() { + Host host = persistActiveHost("ns1.example.tld"); + SimplifiedJedisClient client = createSimplifiedClient(Host.class); + client.set("Host__ns1.example.tld", host); + assertThat(client.get("Host__ns1.example.tld")).hasValue(host); + } + + @Test + void testClient_nonexistent() { + SimplifiedJedisClient domainClient = createSimplifiedClient(Domain.class); + SimplifiedJedisClient hostClient = createSimplifiedClient(Host.class); + assertThat(domainClient.get("Domain__nonexistent.tld")).isEmpty(); + assertThat(hostClient.get("Host__ns1.nonexistent.tld")).isEmpty(); + } + + private SimplifiedJedisClient createSimplifiedClient(Class clazz) { + return SimplifiedJedisClient.create(clazz, createJedisClient()); + } + + private UnifiedJedis createJedisClient() { + return RedisClient.builder() + .hostAndPort(new HostAndPort(valkey.getHost(), valkey.getFirstMappedPort())) + .build(); + } +} diff --git a/core/src/test/java/google/registry/module/TestRegistryComponent.java b/core/src/test/java/google/registry/module/TestRegistryComponent.java index 4e1446f29..2147ce4d3 100644 --- a/core/src/test/java/google/registry/module/TestRegistryComponent.java +++ b/core/src/test/java/google/registry/module/TestRegistryComponent.java @@ -19,6 +19,7 @@ import dagger.Component; import dagger.Lazy; import google.registry.batch.BatchModule; import google.registry.bigquery.BigqueryModule; +import google.registry.cache.CacheModule; import google.registry.config.CloudTasksUtilsModule; import google.registry.config.CredentialModule; import google.registry.config.RegistryConfig.ConfigModule; @@ -52,6 +53,7 @@ import jakarta.inject.Singleton; AuthModule.class, BatchModule.class, BigqueryModule.class, + CacheModule.class, CloudTasksUtilsModule.class, ConfigModule.class, CredentialModule.class, diff --git a/core/src/test/java/google/registry/testing/FakeKeyringModule.java b/core/src/test/java/google/registry/testing/FakeKeyringModule.java index 2ea687f77..65db8579b 100644 --- a/core/src/test/java/google/registry/testing/FakeKeyringModule.java +++ b/core/src/test/java/google/registry/testing/FakeKeyringModule.java @@ -170,6 +170,12 @@ public final class FakeKeyringModule { return ImmutableList.of(SQL_REPLICA_CONNECTION_1, SQL_REPLICA_CONNECTION_2); } + @Override + public String getValkeyCertificateAuthority() { + // This isn't necessary for keyring testing + return ""; + } + @Override public void close() {} }; diff --git a/dependencies.gradle b/dependencies.gradle index 736c45162..42735c304 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -135,6 +135,9 @@ ext { 'guru.nidi:graphviz-java-all-j2v8:[0.17.0,)', 'io.github.classgraph:classgraph:[4.8.102,)', 'io.github.java-diff-utils:java-diff-utils:[4.9,)', + 'io.github.ss-bhatt:testcontainers-valkey:1.0.0', + 'io.protostuff:protostuff-core:1.8.0', + 'io.protostuff:protostuff-runtime:1.8.0', 'io.netty:netty-tcnative-boringssl-static:[2.0.36.Final,)', 'jakarta.inject:jakarta.inject-api:[2.0.0,)', 'jakarta.mail:jakarta.mail-api:[2.1.3,)', @@ -209,6 +212,7 @@ ext { 'org.testcontainers:selenium:[1.19.6,)', 'org.testcontainers:testcontainers:[1.19.6,)', 'org.yaml:snakeyaml:[1.17,)', + 'redis.clients:jedis:7.4.1', 'us.fatehi:schemacrawler-api:[16.10.1,)', 'us.fatehi:schemacrawler-diagram:[16.10.1,)', 'us.fatehi:schemacrawler-postgresql:[16.10.1,)', diff --git a/jetty/gradle.lockfile b/jetty/gradle.lockfile index 0c62bc089..0d755845e 100644 --- a/jetty/gradle.lockfile +++ b/jetty/gradle.lockfile @@ -121,7 +121,7 @@ com.google.cloud:grpc-gcp:1.6.1=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.48.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.cloud:proto-google-cloud-firestore-bundle-v1:3.26.5=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=checkstyle,deploy_jar,runtimeClasspath,testRuntimeClasspath -com.google.code.gson:gson:2.12.1=deploy_jar,runtimeClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.common.html.types:types:1.0.8=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.dagger:dagger:2.59.2=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotation:2.48.0=annotationProcessor,testAnnotationProcessor @@ -262,6 +262,10 @@ io.opentelemetry:opentelemetry-sdk:1.47.0=deploy_jar,runtimeClasspath,testRuntim io.opentelemetry:opentelemetry-semconv:1.26.0-alpha=deploy_jar,runtimeClasspath,testRuntimeClasspath io.outfoxx:swiftpoet:1.3.1=deploy_jar,runtimeClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.protostuff:protostuff-api:1.8.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.protostuff:protostuff-collectionschema:1.8.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.protostuff:protostuff-core:1.8.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.protostuff:protostuff-runtime:1.8.0=deploy_jar,runtimeClasspath,testRuntimeClasspath jakarta-regexp:jakarta-regexp:1.4=deploy_jar,runtimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.2.0-M1=deploy_jar,runtimeClasspath,testRuntimeClasspath jakarta.inject:jakarta.inject-api:2.0.1=deploy_jar,runtimeClasspath,testRuntimeClasspath @@ -310,6 +314,7 @@ org.apache.commons:commons-compress:1.28.0=deploy_jar,runtimeClasspath,testRunti org.apache.commons:commons-csv:1.14.1=deploy_jar,runtimeClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.18.0=deploy_jar,runtimeClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.8.1=checkstyle +org.apache.commons:commons-pool2:2.12.1=deploy_jar,runtimeClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents.client5:httpclient5:5.1.3=checkstyle org.apache.httpcomponents.core5:httpcore5-h2:5.1.3=checkstyle @@ -375,7 +380,7 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.1=deploy_jar,runtimeClasspa org.jetbrains:annotations:17.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath org.jline:jline:3.30.5=deploy_jar,runtimeClasspath,testRuntimeClasspath org.joda:joda-money:2.0.3=deploy_jar,runtimeClasspath,testRuntimeClasspath -org.json:json:20240303=deploy_jar,runtimeClasspath,testRuntimeClasspath +org.json:json:20251224=deploy_jar,runtimeClasspath,testRuntimeClasspath org.jsoup:jsoup:1.22.1=deploy_jar,runtimeClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,deploy_jar,runtimeClasspath,testAnnotationProcessor,testRuntimeClasspath org.ogce:xpp3:1.1.6=deploy_jar,runtimeClasspath,testRuntimeClasspath @@ -403,6 +408,8 @@ org.w3c.css:sac:1.3=deploy_jar,runtimeClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.4=deploy_jar,runtimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.2.2=checkstyle org.yaml:snakeyaml:2.4=deploy_jar,runtimeClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=deploy_jar,runtimeClasspath,testRuntimeClasspath +redis.clients:jedis:7.4.1=deploy_jar,runtimeClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.1=deploy_jar,runtimeClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.1=deploy_jar,runtimeClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.1.1=deploy_jar,runtimeClasspath,testRuntimeClasspath