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.
This commit is contained in:
gbrodman
2026-04-24 19:50:01 +00:00
committed by GitHub
parent 903414c76b
commit 8cf222d1c9
27 changed files with 845 additions and 132 deletions
@@ -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<Domain> 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();
}
}
@@ -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<Host> 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();
}
}
@@ -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<Domain> 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<Host> 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<Domain> domainClient = createSimplifiedClient(Domain.class);
SimplifiedJedisClient<Host> hostClient = createSimplifiedClient(Host.class);
assertThat(domainClient.get("Domain__nonexistent.tld")).isEmpty();
assertThat(hostClient.get("Host__ns1.nonexistent.tld")).isEmpty();
}
private <T extends EppResource> SimplifiedJedisClient<T> createSimplifiedClient(Class<T> clazz) {
return SimplifiedJedisClient.create(clazz, createJedisClient());
}
private UnifiedJedis createJedisClient() {
return RedisClient.builder()
.hostAndPort(new HostAndPort(valkey.getHost(), valkey.getFirstMappedPort()))
.build();
}
}
@@ -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,
@@ -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() {}
};