Add an action to sync the remote Valkey cache (#3032)

This uses two cursors (one for hosts and one for domains) to track our
progress in "catching up", or syncing recent changes to the database,
using the update-timestamp field. When the cache is in use and fully
caught up, these
cursors should be kept relatively up-to-date to the actual time, i.e.
less than one hour behind
This commit is contained in:
gbrodman
2026-05-05 15:29:43 +00:00
committed by GitHub
parent f44642fe28
commit 81b3a2fc5b
10 changed files with 480 additions and 28 deletions
@@ -0,0 +1,228 @@
// 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.batch;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.model.common.Cursor.CursorType.REMOTE_CACHE_DOMAIN_SYNC;
import static google.registry.model.common.Cursor.CursorType.REMOTE_CACHE_HOST_SYNC;
import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.persistActiveDomain;
import static google.registry.testing.DatabaseHelper.persistActiveHost;
import static google.registry.testing.DatabaseHelper.persistDeletedDomain;
import static google.registry.testing.DatabaseHelper.persistDeletedHost;
import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import com.google.common.collect.ImmutableList;
import google.registry.cache.SimplifiedJedisClient;
import google.registry.model.common.Cursor;
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.DatabaseHelper;
import google.registry.testing.FakeClock;
import google.registry.testing.FakeLockHandler;
import google.registry.testing.FakeResponse;
import java.time.Instant;
import java.util.Optional;
import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
/** Unit tests for {@link SyncRemoteCacheAction}. */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SyncRemoteCacheActionTest {
private final FakeClock clock = new FakeClock(DateTime.parse("2025-01-01T00:00:00Z"));
@RegisterExtension
final JpaIntegrationTestExtension jpa =
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
@Mock private SimplifiedJedisClient<Domain> domainJedisClient;
@Mock private SimplifiedJedisClient<Host> hostJedisClient;
private final FakeResponse response = new FakeResponse();
private FakeLockHandler lockHandler = new FakeLockHandler(true);
private SyncRemoteCacheAction action;
@BeforeEach
void beforeEach() {
createTld("tld");
action =
new SyncRemoteCacheAction(
lockHandler, response, Optional.of(domainJedisClient), Optional.of(hostJedisClient));
}
@Test
void test_noJedisConfig() {
action = new SyncRemoteCacheAction(lockHandler, response, Optional.empty(), Optional.empty());
action.run();
assertThat(response.getStatus()).isEqualTo(SC_NO_CONTENT);
assertThat(response.getPayload()).contains("No Jedis/Valkey configuration found");
}
@Test
void test_lockAcquisitionFails() {
lockHandler = new FakeLockHandler(false);
action =
new SyncRemoteCacheAction(
lockHandler, response, Optional.of(domainJedisClient), Optional.of(hostJedisClient));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_NO_CONTENT);
assertThat(response.getPayload()).contains("Could not acquire lock");
}
@Test
void test_exceptionThrown() {
doThrow(new RuntimeException("Redis failed")).when(domainJedisClient).deleteAll(any());
persistActiveDomain("example.tld"); // So there is something to process
action.run();
assertThat(response.getStatus()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
assertThat(response.getPayload()).contains("Errored out with cause");
}
@Test
void test_syncDomains_noDomains() {
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verifyNoInteractions(domainJedisClient);
assertThat(DatabaseHelper.loadByKeyIfPresent(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)))
.isEmpty();
}
@Test
void test_syncDomains_withDomains() {
Domain domain1 = persistActiveDomain("example1.tld");
clock.advanceOneMilli();
Domain domain2 = persistActiveDomain("example2.tld");
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verify(domainJedisClient)
.setAll(
eq(
ImmutableList.of(
new SimplifiedJedisClient.JedisResource<>("example1.tld", domain1),
new SimplifiedJedisClient.JedisResource<>("example2.tld", domain2))));
assertThat(
DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC))
.getCursorTimeInstant()
.toString())
.isEqualTo("2025-01-01T00:00:00.001Z");
}
@Test
void test_syncDomains_withDeletedDomains() {
Domain activeDomain = persistActiveDomain("active.tld");
persistDeletedDomain("deleted.tld", clock.nowUtc().minusDays(1));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verify(domainJedisClient)
.setAll(
eq(
ImmutableList.of(
new SimplifiedJedisClient.JedisResource<>("active.tld", activeDomain))));
verify(domainJedisClient).deleteAll(eq(ImmutableList.of("deleted.tld")));
}
@Test
void testCursorTime_skipsOldChange() {
persistActiveDomain("example1.tld");
clock.advanceOneMilli();
Instant cursorTime = clock.now();
DatabaseHelper.persistResource(Cursor.createGlobal(REMOTE_CACHE_DOMAIN_SYNC, cursorTime));
clock.advanceOneMilli();
Domain domain2 = persistActiveDomain("example2.tld");
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verify(domainJedisClient)
.setAll(
eq(
ImmutableList.of(
new SimplifiedJedisClient.JedisResource<>("example2.tld", domain2))));
}
@Test
void test_syncHosts_noHosts() {
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verifyNoInteractions(hostJedisClient);
assertThat(DatabaseHelper.loadByKeyIfPresent(Cursor.createGlobalVKey(REMOTE_CACHE_HOST_SYNC)))
.isEmpty();
}
@Test
void test_syncHosts_withHosts() {
Host host1 = persistActiveHost("ns1.example.tld");
clock.advanceOneMilli();
Host host2 = persistActiveHost("ns2.example.tld");
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verify(hostJedisClient)
.setAll(
eq(
ImmutableList.of(
new SimplifiedJedisClient.JedisResource<>(host1.getRepoId(), host1),
new SimplifiedJedisClient.JedisResource<>(host2.getRepoId(), host2))));
assertThat(
DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_HOST_SYNC))
.getCursorTimeInstant()
.toString())
.isEqualTo("2025-01-01T00:00:00.001Z");
}
@Test
void test_syncHosts_withDeletedHosts() {
Host active = persistActiveHost("ns1.example.tld");
Host deleted = persistDeletedHost("ns2.example.tld", clock.nowUtc().minusDays(1));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
verify(hostJedisClient)
.setAll(
eq(
ImmutableList.of(
new SimplifiedJedisClient.JedisResource<>(active.getRepoId(), active))));
verify(hostJedisClient).deleteAll(eq(ImmutableList.of(deleted.getRepoId())));
}
}
@@ -51,6 +51,7 @@ BACKEND /_dr/task/resaveEntity ResaveEntityAction
BACKEND /_dr/task/sendExpiringCertificateNotificationEmail SendExpiringCertificateNotificationEmailAction GET n APP ADMIN
BACKEND /_dr/task/syncGroupMembers SyncGroupMembersAction POST n APP ADMIN
BACKEND /_dr/task/syncRegistrarsSheet SyncRegistrarsSheetAction POST n APP ADMIN
BACKEND /_dr/task/syncRemoteCache SyncRemoteCacheAction POST n APP ADMIN
BACKEND /_dr/task/tmchCrl TmchCrlAction POST y APP ADMIN
BACKEND /_dr/task/tmchDnl TmchDnlAction POST y APP ADMIN
BACKEND /_dr/task/tmchSmdrl TmchSmdrlAction POST y APP ADMIN