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