Skip to content

Commit 149fb66

Browse files
authored
Add cache for deletion times of existing domains (#2840)
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 8c96940 commit 149fb66

File tree

12 files changed

+272
-10
lines changed

12 files changed

+272
-10
lines changed

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

Lines changed: 11 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

@@ -239,13 +239,13 @@ public EppResponse run() throws EppException {
239239
validateRegistrarIsLoggedIn(registrarId);
240240
verifyRegistrarIsActive(registrarId);
241241
extensionManager.validate();
242+
verifyDomainDoesNotExist();
242243
DateTime now = tm().getTransactionTime();
243244
DomainCommand.Create command = cloneAndLinkReferences((Create) resourceCommand, now);
244245
Period period = command.getPeriod();
245246
verifyUnitIsYears(period);
246247
int years = period.getValue();
247248
validateRegistrationPeriod(years);
248-
verifyResourceDoesNotExist(Domain.class, targetId, now, registrarId);
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,15 @@ private Autorenew createAutorenewPollMessage(
649649
.build();
650650
}
651651

652+
private void verifyDomainDoesNotExist() throws ResourceCreateContentionException {
653+
Optional<DateTime> previousDeletionTime =
654+
domainDeletionTimeCache.getDeletionTimeForDomain(targetId);
655+
if (previousDeletionTime.isPresent()
656+
&& !tm().getTransactionTime().isAfter(previousDeletionTime.get())) {
657+
throw new ResourceCreateContentionException(targetId);
658+
}
659+
}
660+
652661
private static BillingEvent createEapBillingEvent(
653662
FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) {
654663
return new BillingEvent.Builder()
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 com.google.common.collect.ImmutableSet;
24+
import google.registry.model.ForeignKeyUtils;
25+
import google.registry.model.domain.Domain;
26+
import java.util.Optional;
27+
import org.joda.time.DateTime;
28+
29+
/**
30+
* Functionally-static loading cache that keeps track of deletion (AKA 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. If the domain is re-created after
40+
* a drop, the next load attempt will populate the cache with a deletion time of END_OF_TIME, which
41+
* will be read from the cache by subsequent attempts.
42+
*
43+
* <p>We take advantage of the fact that Caffeine caches don't store nulls returned from the
44+
* CacheLoader, so a null result (meaning the domain doesn't exist) won't affect future calls (this
45+
* avoids a stale-cache situation where the cache "thinks" the domain doesn't exist, but it does).
46+
* Put another way, if a domain really doesn't exist, we'll re-attempt the database load every time.
47+
*
48+
* <p>We don't explicitly set the cache inside domain create/delete flows, in case the transaction
49+
* fails at commit time. It's better to have stale data, or to require an additional database load,
50+
* than to have incorrect data.
51+
*
52+
* <p>Note: this should be injected as a singleton -- it's essentially static, but we have it as a
53+
* non-static object for concurrent testing purposes.
54+
*/
55+
public class DomainDeletionTimeCache {
56+
57+
// Max expiry time is ten minutes
58+
private static final int MAX_EXPIRY_MILLIS = 10 * 60 * 1000;
59+
private static final int MAX_ENTRIES = 500;
60+
private static final int NANOS_IN_ONE_MILLISECOND = 100000;
61+
62+
/**
63+
* Expire after the max duration, or after the domain is set to drop (whichever comes first).
64+
*
65+
* <p>If the domain has already been deleted (the deletion time is <= now), the entry will
66+
* immediately be expired/removed.
67+
*
68+
* <p>NB: the Expiry class requires the return value in <b>nanoseconds</b>, not milliseconds
69+
*/
70+
private static final Expiry<String, DateTime> EXPIRY_POLICY =
71+
new Expiry<>() {
72+
@Override
73+
public long expireAfterCreate(String key, DateTime value, long currentTime) {
74+
long millisUntilDeletion = value.getMillis() - tm().getTransactionTime().getMillis();
75+
return NANOS_IN_ONE_MILLISECOND
76+
* Math.max(0L, Math.min(MAX_EXPIRY_MILLIS, millisUntilDeletion));
77+
}
78+
79+
/** Reset the time entirely on update, as if we were creating the entry anew. */
80+
@Override
81+
public long expireAfterUpdate(
82+
String key, DateTime value, long currentTime, long currentDuration) {
83+
return expireAfterCreate(key, value, currentTime);
84+
}
85+
86+
/** Reads do not change the expiry duration. */
87+
@Override
88+
public long expireAfterRead(
89+
String key, DateTime value, long currentTime, long currentDuration) {
90+
return currentDuration;
91+
}
92+
};
93+
94+
/** Attempt to load the domain's deletion time if the domain exists. */
95+
private static final CacheLoader<String, DateTime> CACHE_LOADER =
96+
(domainName) -> {
97+
ForeignKeyUtils.MostRecentResource mostRecentResource =
98+
ForeignKeyUtils.loadMostRecentResources(
99+
Domain.class, ImmutableSet.of(domainName), false)
100+
.get(domainName);
101+
return mostRecentResource == null ? null : mostRecentResource.deletionTime();
102+
};
103+
104+
public static DomainDeletionTimeCache create() {
105+
return new DomainDeletionTimeCache(
106+
Caffeine.newBuilder()
107+
.expireAfter(EXPIRY_POLICY)
108+
.maximumSize(MAX_ENTRIES)
109+
.build(CACHE_LOADER));
110+
}
111+
112+
private final LoadingCache<String, DateTime> cache;
113+
114+
private DomainDeletionTimeCache(LoadingCache<String, DateTime> cache) {
115+
this.cache = cache;
116+
}
117+
118+
/** Returns the domain's deletion time, or null if it doesn't currently exist. */
119+
public Optional<DateTime> getDeletionTimeForDomain(String domainName) {
120+
return Optional.ofNullable(cache.get(domainName));
121+
}
122+
}
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/model/ForeignKeyUtils.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public static <E extends EppResource> VKey<E> load(
8686
*/
8787
public static <E extends EppResource> ImmutableMap<String, VKey<E>> load(
8888
Class<E> clazz, Collection<String> foreignKeys, final DateTime now) {
89-
return load(clazz, foreignKeys, false).entrySet().stream()
89+
return loadMostRecentResources(clazz, foreignKeys, false).entrySet().stream()
9090
.filter(e -> now.isBefore(e.getValue().deletionTime()))
9191
.collect(toImmutableMap(Entry::getKey, e -> VKey.create(clazz, e.getValue().repoId())));
9292
}
@@ -104,8 +104,9 @@ public static <E extends EppResource> ImmutableMap<String, VKey<E>> load(
104104
* same max {@code deleteTime}, usually {@code END_OF_TIME}, lest this method throws an error due
105105
* to duplicate keys.
106106
*/
107-
private static <E extends EppResource> ImmutableMap<String, MostRecentResource> load(
108-
Class<E> clazz, Collection<String> foreignKeys, boolean useReplicaTm) {
107+
public static <E extends EppResource>
108+
ImmutableMap<String, MostRecentResource> loadMostRecentResources(
109+
Class<E> clazz, Collection<String> foreignKeys, boolean useReplicaTm) {
109110
String fkProperty = RESOURCE_TYPE_TO_FK_PROPERTY.get(clazz);
110111
JpaTransactionManager tmToUse = useReplicaTm ? replicaTm() : tm();
111112
return tmToUse.reTransact(
@@ -148,7 +149,7 @@ public Optional<MostRecentResource> load(VKey<? extends EppResource> key) {
148149
ImmutableList<String> foreignKeys =
149150
keys.stream().map(key -> (String) key.getKey()).collect(toImmutableList());
150151
ImmutableMap<String, MostRecentResource> existingKeys =
151-
ForeignKeyUtils.load(clazz, foreignKeys, true);
152+
ForeignKeyUtils.loadMostRecentResources(clazz, foreignKeys, true);
152153
// The above map only contains keys that exist in the database, so we re-add the
153154
// missing ones with Optional.empty() values for caching.
154155
return Maps.asMap(
@@ -234,7 +235,7 @@ public static <E extends EppResource> ImmutableMap<String, VKey<E>> loadByCache(
234235
e -> VKey.create(clazz, e.getValue().get().repoId())));
235236
}
236237

237-
record MostRecentResource(String repoId, DateTime deletionTime) {
238+
public record MostRecentResource(String repoId, DateTime deletionTime) {
238239

239240
static MostRecentResource create(String repoId, DateTime deletionTime) {
240241
return new MostRecentResource(repoId, deletionTime);

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 {

core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@
149149
import google.registry.flows.domain.token.AllocationTokenFlowUtils.NonexistentAllocationTokenException;
150150
import google.registry.flows.exceptions.ContactsProhibitedException;
151151
import google.registry.flows.exceptions.OnlyToolCanPassMetadataException;
152-
import google.registry.flows.exceptions.ResourceAlreadyExistsForThisClientException;
153152
import google.registry.flows.exceptions.ResourceCreateContentionException;
154153
import google.registry.model.billing.BillingBase;
155154
import google.registry.model.billing.BillingBase.Flag;
@@ -1238,8 +1237,8 @@ void testFailure_unknownCurrency() {
12381237
void testFailure_alreadyExists() throws Exception {
12391238
persistContactsAndHosts();
12401239
persistActiveDomain(getUniqueIdFromCommand());
1241-
ResourceAlreadyExistsForThisClientException thrown =
1242-
assertThrows(ResourceAlreadyExistsForThisClientException.class, this::runFlow);
1240+
ResourceCreateContentionException thrown =
1241+
assertThrows(ResourceCreateContentionException.class, this::runFlow);
12431242
assertAboutEppExceptions()
12441243
.that(thrown)
12451244
.marshalsToXml()

0 commit comments

Comments
 (0)