Skip to content

Commit c6cc82b

Browse files
committed
Add cache for deletion times of existing domains
This should help in instances of popular domains dropping, since we won't need to do an additional two database loads every time (assuming the deletion time is in the future).
1 parent ee3866e commit c6cc82b

File tree

10 files changed

+264
-2
lines changed

10 files changed

+264
-2
lines changed

core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import static google.registry.dns.DnsUtils.requestDomainDnsRefresh;
1919
import static google.registry.flows.FlowUtils.persistEntityChanges;
2020
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
21-
import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist;
2221
import static google.registry.flows.domain.DomainFlowUtils.COLLISION_MESSAGE;
2322
import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld;
2423
import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount;
@@ -224,6 +223,7 @@ public final class DomainCreateFlow implements MutatingFlow {
224223
@Inject DomainCreateFlowCustomLogic flowCustomLogic;
225224
@Inject DomainFlowTmchUtils tmchUtils;
226225
@Inject DomainPricingLogic pricingLogic;
226+
@Inject DomainDeletionTimeCache domainDeletionTimeCache;
227227

228228
@Inject DomainCreateFlow() {}
229229

@@ -245,7 +245,7 @@ public EppResponse run() throws EppException {
245245
verifyUnitIsYears(period);
246246
int years = period.getValue();
247247
validateRegistrationPeriod(years);
248-
verifyResourceDoesNotExist(Domain.class, targetId, now, registrarId);
248+
verifyDomainDoesNotExist();
249249
// Validate that this is actually a legal domain name on a TLD that the registrar has access to.
250250
InternetDomainName domainName = validateDomainName(command.getDomainName());
251251
String domainLabel = domainName.parts().getFirst();
@@ -649,6 +649,13 @@ private Autorenew createAutorenewPollMessage(
649649
.build();
650650
}
651651

652+
private void verifyDomainDoesNotExist() throws ResourceCreateContentionException {
653+
DateTime previousDeletionTime = domainDeletionTimeCache.getDeletionTimeForDomain(targetId);
654+
if (previousDeletionTime != null && !previousDeletionTime.isAfter(tm().getTransactionTime())) {
655+
throw new ResourceCreateContentionException(targetId);
656+
}
657+
}
658+
652659
private static BillingEvent createEapBillingEvent(
653660
FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) {
654661
return new BillingEvent.Builder()
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.flows.domain;
16+
17+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
18+
19+
import com.github.benmanes.caffeine.cache.CacheLoader;
20+
import com.github.benmanes.caffeine.cache.Caffeine;
21+
import com.github.benmanes.caffeine.cache.Expiry;
22+
import com.github.benmanes.caffeine.cache.LoadingCache;
23+
import google.registry.model.ForeignKeyUtils;
24+
import google.registry.model.domain.Domain;
25+
import google.registry.persistence.VKey;
26+
import javax.annotation.Nullable;
27+
import org.joda.time.DateTime;
28+
29+
/**
30+
* Static loading cache that keeps track of drop times for domains.
31+
*
32+
* <p>Some domain names may have many create requests issued shortly before (and directly after) the
33+
* name is released due to a previous registrant deleting it. In those cases, caching the deletion
34+
* time of the existing domain allows us to short-circuit the request and avoid any load on the
35+
* database checking the existing domain (at least, in cases where the request hits a particular
36+
* node more than once).
37+
*
38+
* <p>The cache is fairly short-lived (as we're concerned about many requests at basically the same
39+
* time), and entries also expire when the drop actually happens.
40+
*
41+
* <p>We take advantage of the fact that Caffeine caches don't store nulls returned from the
42+
* CacheLoader, so a null result (meaning the domain doesn't exist) won't affect future calls (this
43+
* avoids a stale-cache situation where the cache "thinks" the domain doesn't exist, but it does).
44+
* Put another way, if a domain really doesn't exist, we'll re-attempt the database load every time.
45+
*
46+
* <p>We don't explicitly register creates or deletes in the flows themselves in case the
47+
* transaction fails. It's better to have stale data, or to require an additional database load,
48+
* than to have incorrect data.
49+
*
50+
* <p>Note: this should be injected as a singleton -- it's essentially static, but we have it as a
51+
* non-static object for concurrent testing purposes.
52+
*/
53+
public class DomainDeletionTimeCache {
54+
55+
// Max expiry time is ten minutes
56+
private static final int MAX_EXPIRY_MILLIS = 10 * 60 * 1000;
57+
private static final int MAX_ENTRIES = 500;
58+
private static final int NANOS_IN_ONE_MILLISECOND = 100000;
59+
60+
/**
61+
* Expire after the max duration, or after the domain is set to drop (whichever comes first).
62+
*
63+
* <p>If the domain has already been deleted (the deletion time is <= now), the entry will
64+
* immediately be expired/removed.
65+
*
66+
* <p>NB: the Expiry class requires the return value in <b>nanoseconds</b>, not milliseconds
67+
*/
68+
private static final Expiry<String, DateTime> EXPIRY_POLICY =
69+
new Expiry<>() {
70+
@Override
71+
public long expireAfterCreate(String key, DateTime value, long currentTime) {
72+
long millisUntilDeletion = value.getMillis() - tm().getTransactionTime().getMillis();
73+
return NANOS_IN_ONE_MILLISECOND
74+
* Math.max(0L, Math.min(MAX_EXPIRY_MILLIS, millisUntilDeletion));
75+
}
76+
77+
/** Reset the time entirely on update, as if we were creating the entry anew. */
78+
@Override
79+
public long expireAfterUpdate(
80+
String key, DateTime value, long currentTime, long currentDuration) {
81+
return expireAfterCreate(key, value, currentTime);
82+
}
83+
84+
/** Reads do not change the expiry duration. */
85+
@Override
86+
public long expireAfterRead(
87+
String key, DateTime value, long currentTime, long currentDuration) {
88+
return currentDuration;
89+
}
90+
};
91+
92+
/** Attempt to load the domain's deletion time if the domain exists. */
93+
private static final CacheLoader<String, DateTime> CACHE_LOADER =
94+
(domainName) -> {
95+
VKey<Domain> key =
96+
ForeignKeyUtils.load(Domain.class, domainName, tm().getTransactionTime());
97+
if (key == null) {
98+
// Null means the domain doesn't currently exist and thus cannot have a deletion time
99+
return null;
100+
}
101+
// Otherwise, we cache the existing domain's deletion time so that further queries can
102+
// return quickly and avoid database calls
103+
return tm().loadByKey(key).getDeletionTime();
104+
};
105+
106+
public static DomainDeletionTimeCache create() {
107+
return new DomainDeletionTimeCache(
108+
Caffeine.newBuilder()
109+
.expireAfter(EXPIRY_POLICY)
110+
.maximumSize(MAX_ENTRIES)
111+
.build(CACHE_LOADER));
112+
}
113+
114+
private final LoadingCache<String, DateTime> cache;
115+
116+
private DomainDeletionTimeCache(LoadingCache<String, DateTime> cache) {
117+
this.cache = cache;
118+
}
119+
120+
/** Returns the domain's deletion time, or null if it doesn't currently exist. */
121+
@Nullable
122+
public DateTime getDeletionTimeForDomain(String domainName) {
123+
return cache.get(domainName);
124+
}
125+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.flows.domain;
16+
17+
import dagger.Module;
18+
import dagger.Provides;
19+
import jakarta.inject.Singleton;
20+
21+
/** Dagger module to provide the {@link DomainDeletionTimeCache}. */
22+
@Module
23+
public class DomainDeletionTimeCacheModule {
24+
25+
@Provides
26+
@Singleton
27+
public static DomainDeletionTimeCache provideDomainDeletionTimeCache() {
28+
return DomainDeletionTimeCache.create();
29+
}
30+
}

core/src/main/java/google/registry/module/RegistryComponent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import google.registry.export.sheet.SheetsServiceModule;
3131
import google.registry.flows.ServerTridProviderModule;
3232
import google.registry.flows.custom.CustomLogicFactoryModule;
33+
import google.registry.flows.domain.DomainDeletionTimeCacheModule;
3334
import google.registry.groups.DirectoryModule;
3435
import google.registry.groups.GmailModule;
3536
import google.registry.groups.GroupsModule;
@@ -66,6 +67,7 @@
6667
CredentialModule.class,
6768
CustomLogicFactoryModule.class,
6869
DirectoryModule.class,
70+
DomainDeletionTimeCacheModule.class,
6971
DriveModule.class,
7072
GmailModule.class,
7173
GroupsModule.class,

core/src/main/java/google/registry/module/backend/BackendComponent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import google.registry.export.sheet.SheetsServiceModule;
2727
import google.registry.flows.ServerTridProviderModule;
2828
import google.registry.flows.custom.CustomLogicFactoryModule;
29+
import google.registry.flows.domain.DomainDeletionTimeCacheModule;
2930
import google.registry.groups.DirectoryModule;
3031
import google.registry.groups.GmailModule;
3132
import google.registry.groups.GroupsModule;
@@ -56,6 +57,7 @@
5657
CloudTasksUtilsModule.class,
5758
CredentialModule.class,
5859
CustomLogicFactoryModule.class,
60+
DomainDeletionTimeCacheModule.class,
5961
DirectoryModule.class,
6062
DriveModule.class,
6163
GmailModule.class,

core/src/main/java/google/registry/module/frontend/FrontendComponent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import google.registry.config.RegistryConfig.ConfigModule;
2323
import google.registry.flows.ServerTridProviderModule;
2424
import google.registry.flows.custom.CustomLogicFactoryModule;
25+
import google.registry.flows.domain.DomainDeletionTimeCacheModule;
2526
import google.registry.groups.DirectoryModule;
2627
import google.registry.groups.GmailModule;
2728
import google.registry.groups.GroupsModule;
@@ -50,6 +51,7 @@
5051
CustomLogicFactoryModule.class,
5152
CloudTasksUtilsModule.class,
5253
DirectoryModule.class,
54+
DomainDeletionTimeCacheModule.class,
5355
FrontendRequestComponentModule.class,
5456
GmailModule.class,
5557
GroupsModule.class,

core/src/main/java/google/registry/module/tools/ToolsComponent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import google.registry.export.DriveModule;
2424
import google.registry.flows.ServerTridProviderModule;
2525
import google.registry.flows.custom.CustomLogicFactoryModule;
26+
import google.registry.flows.domain.DomainDeletionTimeCacheModule;
2627
import google.registry.groups.DirectoryModule;
2728
import google.registry.groups.GroupsModule;
2829
import google.registry.groups.GroupssettingsModule;
@@ -47,6 +48,7 @@
4748
CustomLogicFactoryModule.class,
4849
CloudTasksUtilsModule.class,
4950
DirectoryModule.class,
51+
DomainDeletionTimeCacheModule.class,
5052
DriveModule.class,
5153
GroupsModule.class,
5254
GroupssettingsModule.class,

core/src/test/java/google/registry/flows/EppTestComponent.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import google.registry.config.RegistryConfig.ConfigModule.TmchCaMode;
2626
import google.registry.flows.custom.CustomLogicFactory;
2727
import google.registry.flows.custom.TestCustomLogicFactory;
28+
import google.registry.flows.domain.DomainDeletionTimeCache;
2829
import google.registry.flows.domain.DomainFlowTmchUtils;
2930
import google.registry.monitoring.whitebox.EppMetric;
3031
import google.registry.request.RequestScope;
@@ -126,6 +127,11 @@ Sleeper provideSleeper() {
126127
ServerTridProvider provideServerTridProvider() {
127128
return new FakeServerTridProvider();
128129
}
130+
131+
@Provides
132+
DomainDeletionTimeCache provideDomainDeletionTimeCache() {
133+
return DomainDeletionTimeCache.create();
134+
}
129135
}
130136

131137
class FakeServerTridProvider implements ServerTridProvider {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.flows.domain;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
19+
import static google.registry.testing.DatabaseHelper.persistActiveDomain;
20+
import static google.registry.testing.DatabaseHelper.persistDomainAsDeleted;
21+
import static google.registry.util.DateTimeUtils.END_OF_TIME;
22+
23+
import google.registry.model.domain.Domain;
24+
import google.registry.persistence.transaction.JpaTestExtensions;
25+
import google.registry.testing.DatabaseHelper;
26+
import google.registry.testing.FakeClock;
27+
import org.joda.time.DateTime;
28+
import org.junit.jupiter.api.BeforeEach;
29+
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.api.extension.RegisterExtension;
31+
32+
/** Tests for {@link DomainDeletionTimeCache}. */
33+
public class DomainDeletionTimeCacheTest {
34+
35+
private final FakeClock clock = new FakeClock(DateTime.parse("2025-10-01T00:00:00.000Z"));
36+
private final DomainDeletionTimeCache cache = DomainDeletionTimeCache.create();
37+
38+
@RegisterExtension
39+
final JpaTestExtensions.JpaIntegrationTestExtension jpa =
40+
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
41+
42+
@BeforeEach
43+
void beforeEach() {
44+
DatabaseHelper.createTld("tld");
45+
}
46+
47+
@Test
48+
void testDomainAvailable_null() {
49+
assertThat(getDeletionTimeFromCache("nonexistent.tld")).isNull();
50+
}
51+
52+
@Test
53+
void testDomainNotAvailable_notDeleted() {
54+
persistActiveDomain("active.tld");
55+
assertThat(getDeletionTimeFromCache("active.tld")).isEqualTo(END_OF_TIME);
56+
}
57+
58+
@Test
59+
void testDomainAvailable_deletedInFuture() {
60+
persistDomainAsDeleted(persistActiveDomain("domain.tld"), clock.nowUtc().plusDays(1));
61+
assertThat(getDeletionTimeFromCache("domain.tld")).isEqualTo(clock.nowUtc().plusDays(1));
62+
}
63+
64+
@Test
65+
void testCache_returnsOldData() {
66+
Domain domain = persistActiveDomain("domain.tld");
67+
assertThat(getDeletionTimeFromCache("domain.tld")).isEqualTo(END_OF_TIME);
68+
persistDomainAsDeleted(domain, clock.nowUtc().plusDays(1));
69+
// Without intervention, the cache should have the old data
70+
assertThat(getDeletionTimeFromCache("domain.tld")).isEqualTo(END_OF_TIME);
71+
}
72+
73+
@Test
74+
void testCache_returnsNewDataAfterDomainCreate() {
75+
// Null deletion dates (meaning an avilable domain) shouldn't be cached
76+
assertThat(getDeletionTimeFromCache("domain.tld")).isNull();
77+
persistDomainAsDeleted(persistActiveDomain("domain.tld"), clock.nowUtc().plusDays(1));
78+
assertThat(getDeletionTimeFromCache("domain.tld")).isEqualTo(clock.nowUtc().plusDays(1));
79+
}
80+
81+
private DateTime getDeletionTimeFromCache(String domainName) {
82+
return tm().transact(() -> cache.getDeletionTimeForDomain(domainName));
83+
}
84+
}

core/src/test/java/google/registry/module/frontend/FrontendTestComponent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import google.registry.config.RegistryConfig;
2121
import google.registry.flows.ServerTridProviderModule;
2222
import google.registry.flows.custom.CustomLogicFactoryModule;
23+
import google.registry.flows.domain.DomainDeletionTimeCacheModule;
2324
import google.registry.groups.GmailModule;
2425
import google.registry.groups.GroupsModule;
2526
import google.registry.groups.GroupssettingsModule;
@@ -43,6 +44,7 @@
4344
CredentialModule.class,
4445
CustomLogicFactoryModule.class,
4546
CloudTasksUtilsModule.class,
47+
DomainDeletionTimeCacheModule.class,
4648
FrontendRequestComponent.FrontendRequestComponentModule.class,
4749
GmailModule.class,
4850
GroupsModule.class,

0 commit comments

Comments
 (0)