From 149fb66ac5876f6383217561246280b8c40a6b54 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Thu, 9 Oct 2025 13:22:24 -0400 Subject: [PATCH] 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). --- .../flows/domain/DomainCreateFlow.java | 13 +- .../flows/domain/DomainDeletionTimeCache.java | 122 ++++++++++++++++++ .../domain/DomainDeletionTimeCacheModule.java | 30 +++++ .../registry/model/ForeignKeyUtils.java | 11 +- .../registry/module/RegistryComponent.java | 2 + .../module/backend/BackendComponent.java | 2 + .../module/frontend/FrontendComponent.java | 2 + .../registry/module/tools/ToolsComponent.java | 2 + .../registry/flows/EppTestComponent.java | 6 + .../flows/domain/DomainCreateFlowTest.java | 5 +- .../domain/DomainDeletionTimeCacheTest.java | 85 ++++++++++++ .../frontend/FrontendTestComponent.java | 2 + 12 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCache.java create mode 100644 core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCacheModule.java create mode 100644 core/src/test/java/google/registry/flows/domain/DomainDeletionTimeCacheTest.java diff --git a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java index 4901ea961..e06e6f4ed 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java @@ -18,7 +18,6 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet; import static google.registry.dns.DnsUtils.requestDomainDnsRefresh; import static google.registry.flows.FlowUtils.persistEntityChanges; import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn; -import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist; import static google.registry.flows.domain.DomainFlowUtils.COLLISION_MESSAGE; import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld; import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount; @@ -224,6 +223,7 @@ public final class DomainCreateFlow implements MutatingFlow { @Inject DomainCreateFlowCustomLogic flowCustomLogic; @Inject DomainFlowTmchUtils tmchUtils; @Inject DomainPricingLogic pricingLogic; + @Inject DomainDeletionTimeCache domainDeletionTimeCache; @Inject DomainCreateFlow() {} @@ -239,13 +239,13 @@ public final class DomainCreateFlow implements MutatingFlow { validateRegistrarIsLoggedIn(registrarId); verifyRegistrarIsActive(registrarId); extensionManager.validate(); + verifyDomainDoesNotExist(); DateTime now = tm().getTransactionTime(); DomainCommand.Create command = cloneAndLinkReferences((Create) resourceCommand, now); Period period = command.getPeriod(); verifyUnitIsYears(period); int years = period.getValue(); validateRegistrationPeriod(years); - verifyResourceDoesNotExist(Domain.class, targetId, now, registrarId); // Validate that this is actually a legal domain name on a TLD that the registrar has access to. InternetDomainName domainName = validateDomainName(command.getDomainName()); String domainLabel = domainName.parts().getFirst(); @@ -649,6 +649,15 @@ public final class DomainCreateFlow implements MutatingFlow { .build(); } + private void verifyDomainDoesNotExist() throws ResourceCreateContentionException { + Optional previousDeletionTime = + domainDeletionTimeCache.getDeletionTimeForDomain(targetId); + if (previousDeletionTime.isPresent() + && !tm().getTransactionTime().isAfter(previousDeletionTime.get())) { + throw new ResourceCreateContentionException(targetId); + } + } + private static BillingEvent createEapBillingEvent( FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) { return new BillingEvent.Builder() diff --git a/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCache.java b/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCache.java new file mode 100644 index 000000000..864344538 --- /dev/null +++ b/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCache.java @@ -0,0 +1,122 @@ +// Copyright 2025 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.flows.domain; + +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.github.benmanes.caffeine.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.google.common.collect.ImmutableSet; +import google.registry.model.ForeignKeyUtils; +import google.registry.model.domain.Domain; +import java.util.Optional; +import org.joda.time.DateTime; + +/** + * Functionally-static loading cache that keeps track of deletion (AKA drop) times for domains. + * + *

Some domain names may have many create requests issued shortly before (and directly after) the + * name is released due to a previous registrant deleting it. In those cases, caching the deletion + * time of the existing domain allows us to short-circuit the request and avoid any load on the + * database checking the existing domain (at least, in cases where the request hits a particular + * node more than once). + * + *

The cache is fairly short-lived (as we're concerned about many requests at basically the same + * time), and entries also expire when the drop actually happens. If the domain is re-created after + * a drop, the next load attempt will populate the cache with a deletion time of END_OF_TIME, which + * will be read from the cache by subsequent attempts. + * + *

We take advantage of the fact that Caffeine caches don't store nulls returned from the + * CacheLoader, so a null result (meaning the domain doesn't exist) won't affect future calls (this + * avoids a stale-cache situation where the cache "thinks" the domain doesn't exist, but it does). + * Put another way, if a domain really doesn't exist, we'll re-attempt the database load every time. + * + *

We don't explicitly set the cache inside domain create/delete flows, in case the transaction + * fails at commit time. It's better to have stale data, or to require an additional database load, + * than to have incorrect data. + * + *

Note: this should be injected as a singleton -- it's essentially static, but we have it as a + * non-static object for concurrent testing purposes. + */ +public class DomainDeletionTimeCache { + + // Max expiry time is ten minutes + private static final int MAX_EXPIRY_MILLIS = 10 * 60 * 1000; + private static final int MAX_ENTRIES = 500; + private static final int NANOS_IN_ONE_MILLISECOND = 100000; + + /** + * Expire after the max duration, or after the domain is set to drop (whichever comes first). + * + *

If the domain has already been deleted (the deletion time is <= now), the entry will + * immediately be expired/removed. + * + *

NB: the Expiry class requires the return value in nanoseconds, not milliseconds + */ + private static final Expiry EXPIRY_POLICY = + new Expiry<>() { + @Override + public long expireAfterCreate(String key, DateTime value, long currentTime) { + long millisUntilDeletion = value.getMillis() - tm().getTransactionTime().getMillis(); + return NANOS_IN_ONE_MILLISECOND + * Math.max(0L, Math.min(MAX_EXPIRY_MILLIS, millisUntilDeletion)); + } + + /** Reset the time entirely on update, as if we were creating the entry anew. */ + @Override + public long expireAfterUpdate( + String key, DateTime value, long currentTime, long currentDuration) { + return expireAfterCreate(key, value, currentTime); + } + + /** Reads do not change the expiry duration. */ + @Override + public long expireAfterRead( + String key, DateTime value, long currentTime, long currentDuration) { + return currentDuration; + } + }; + + /** Attempt to load the domain's deletion time if the domain exists. */ + private static final CacheLoader CACHE_LOADER = + (domainName) -> { + ForeignKeyUtils.MostRecentResource mostRecentResource = + ForeignKeyUtils.loadMostRecentResources( + Domain.class, ImmutableSet.of(domainName), false) + .get(domainName); + return mostRecentResource == null ? null : mostRecentResource.deletionTime(); + }; + + public static DomainDeletionTimeCache create() { + return new DomainDeletionTimeCache( + Caffeine.newBuilder() + .expireAfter(EXPIRY_POLICY) + .maximumSize(MAX_ENTRIES) + .build(CACHE_LOADER)); + } + + private final LoadingCache cache; + + private DomainDeletionTimeCache(LoadingCache cache) { + this.cache = cache; + } + + /** Returns the domain's deletion time, or null if it doesn't currently exist. */ + public Optional getDeletionTimeForDomain(String domainName) { + return Optional.ofNullable(cache.get(domainName)); + } +} diff --git a/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCacheModule.java b/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCacheModule.java new file mode 100644 index 000000000..6c0c32cab --- /dev/null +++ b/core/src/main/java/google/registry/flows/domain/DomainDeletionTimeCacheModule.java @@ -0,0 +1,30 @@ +// Copyright 2025 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.flows.domain; + +import dagger.Module; +import dagger.Provides; +import jakarta.inject.Singleton; + +/** Dagger module to provide the {@link DomainDeletionTimeCache}. */ +@Module +public class DomainDeletionTimeCacheModule { + + @Provides + @Singleton + public static DomainDeletionTimeCache provideDomainDeletionTimeCache() { + return DomainDeletionTimeCache.create(); + } +} diff --git a/core/src/main/java/google/registry/model/ForeignKeyUtils.java b/core/src/main/java/google/registry/model/ForeignKeyUtils.java index b1be56ed9..98010f63a 100644 --- a/core/src/main/java/google/registry/model/ForeignKeyUtils.java +++ b/core/src/main/java/google/registry/model/ForeignKeyUtils.java @@ -86,7 +86,7 @@ public final class ForeignKeyUtils { */ public static ImmutableMap> load( Class clazz, Collection foreignKeys, final DateTime now) { - return load(clazz, foreignKeys, false).entrySet().stream() + return loadMostRecentResources(clazz, foreignKeys, false).entrySet().stream() .filter(e -> now.isBefore(e.getValue().deletionTime())) .collect(toImmutableMap(Entry::getKey, e -> VKey.create(clazz, e.getValue().repoId()))); } @@ -104,8 +104,9 @@ public final class ForeignKeyUtils { * same max {@code deleteTime}, usually {@code END_OF_TIME}, lest this method throws an error due * to duplicate keys. */ - private static ImmutableMap load( - Class clazz, Collection foreignKeys, boolean useReplicaTm) { + public static + ImmutableMap loadMostRecentResources( + Class clazz, Collection foreignKeys, boolean useReplicaTm) { String fkProperty = RESOURCE_TYPE_TO_FK_PROPERTY.get(clazz); JpaTransactionManager tmToUse = useReplicaTm ? replicaTm() : tm(); return tmToUse.reTransact( @@ -148,7 +149,7 @@ public final class ForeignKeyUtils { ImmutableList foreignKeys = keys.stream().map(key -> (String) key.getKey()).collect(toImmutableList()); ImmutableMap existingKeys = - ForeignKeyUtils.load(clazz, foreignKeys, true); + ForeignKeyUtils.loadMostRecentResources(clazz, foreignKeys, true); // The above map only contains keys that exist in the database, so we re-add the // missing ones with Optional.empty() values for caching. return Maps.asMap( @@ -234,7 +235,7 @@ public final class ForeignKeyUtils { e -> VKey.create(clazz, e.getValue().get().repoId()))); } - record MostRecentResource(String repoId, DateTime deletionTime) { + public record MostRecentResource(String repoId, DateTime deletionTime) { static MostRecentResource create(String repoId, DateTime deletionTime) { return new MostRecentResource(repoId, deletionTime); diff --git a/core/src/main/java/google/registry/module/RegistryComponent.java b/core/src/main/java/google/registry/module/RegistryComponent.java index afd01c12b..d41b3b262 100644 --- a/core/src/main/java/google/registry/module/RegistryComponent.java +++ b/core/src/main/java/google/registry/module/RegistryComponent.java @@ -30,6 +30,7 @@ import google.registry.export.DriveModule; import google.registry.export.sheet.SheetsServiceModule; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; +import google.registry.flows.domain.DomainDeletionTimeCacheModule; import google.registry.groups.DirectoryModule; import google.registry.groups.GmailModule; import google.registry.groups.GroupsModule; @@ -66,6 +67,7 @@ import jakarta.inject.Singleton; CredentialModule.class, CustomLogicFactoryModule.class, DirectoryModule.class, + DomainDeletionTimeCacheModule.class, DriveModule.class, GmailModule.class, GroupsModule.class, diff --git a/core/src/main/java/google/registry/module/backend/BackendComponent.java b/core/src/main/java/google/registry/module/backend/BackendComponent.java index 5c8b96a70..99fe604f2 100644 --- a/core/src/main/java/google/registry/module/backend/BackendComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendComponent.java @@ -26,6 +26,7 @@ import google.registry.export.DriveModule; import google.registry.export.sheet.SheetsServiceModule; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; +import google.registry.flows.domain.DomainDeletionTimeCacheModule; import google.registry.groups.DirectoryModule; import google.registry.groups.GmailModule; import google.registry.groups.GroupsModule; @@ -56,6 +57,7 @@ import jakarta.inject.Singleton; CloudTasksUtilsModule.class, CredentialModule.class, CustomLogicFactoryModule.class, + DomainDeletionTimeCacheModule.class, DirectoryModule.class, DriveModule.class, GmailModule.class, diff --git a/core/src/main/java/google/registry/module/frontend/FrontendComponent.java b/core/src/main/java/google/registry/module/frontend/FrontendComponent.java index 74835b1f5..10515a91a 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendComponent.java @@ -22,6 +22,7 @@ import google.registry.config.CredentialModule; import google.registry.config.RegistryConfig.ConfigModule; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; +import google.registry.flows.domain.DomainDeletionTimeCacheModule; import google.registry.groups.DirectoryModule; import google.registry.groups.GmailModule; import google.registry.groups.GroupsModule; @@ -50,6 +51,7 @@ import jakarta.inject.Singleton; CustomLogicFactoryModule.class, CloudTasksUtilsModule.class, DirectoryModule.class, + DomainDeletionTimeCacheModule.class, FrontendRequestComponentModule.class, GmailModule.class, GroupsModule.class, diff --git a/core/src/main/java/google/registry/module/tools/ToolsComponent.java b/core/src/main/java/google/registry/module/tools/ToolsComponent.java index 74dfbc050..855cbb78f 100644 --- a/core/src/main/java/google/registry/module/tools/ToolsComponent.java +++ b/core/src/main/java/google/registry/module/tools/ToolsComponent.java @@ -23,6 +23,7 @@ import google.registry.config.RegistryConfig.ConfigModule; import google.registry.export.DriveModule; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; +import google.registry.flows.domain.DomainDeletionTimeCacheModule; import google.registry.groups.DirectoryModule; import google.registry.groups.GroupsModule; import google.registry.groups.GroupssettingsModule; @@ -47,6 +48,7 @@ import jakarta.inject.Singleton; CustomLogicFactoryModule.class, CloudTasksUtilsModule.class, DirectoryModule.class, + DomainDeletionTimeCacheModule.class, DriveModule.class, GroupsModule.class, GroupssettingsModule.class, diff --git a/core/src/test/java/google/registry/flows/EppTestComponent.java b/core/src/test/java/google/registry/flows/EppTestComponent.java index f9ad83d80..3298a7521 100644 --- a/core/src/test/java/google/registry/flows/EppTestComponent.java +++ b/core/src/test/java/google/registry/flows/EppTestComponent.java @@ -25,6 +25,7 @@ import google.registry.config.RegistryConfig.ConfigModule; import google.registry.config.RegistryConfig.ConfigModule.TmchCaMode; import google.registry.flows.custom.CustomLogicFactory; import google.registry.flows.custom.TestCustomLogicFactory; +import google.registry.flows.domain.DomainDeletionTimeCache; import google.registry.flows.domain.DomainFlowTmchUtils; import google.registry.monitoring.whitebox.EppMetric; import google.registry.request.RequestScope; @@ -126,6 +127,11 @@ public interface EppTestComponent { ServerTridProvider provideServerTridProvider() { return new FakeServerTridProvider(); } + + @Provides + DomainDeletionTimeCache provideDomainDeletionTimeCache() { + return DomainDeletionTimeCache.create(); + } } class FakeServerTridProvider implements ServerTridProvider { diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java index df4e01c38..04c7d1926 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java @@ -149,7 +149,6 @@ import google.registry.flows.domain.token.AllocationTokenFlowUtils.AlreadyRedeem import google.registry.flows.domain.token.AllocationTokenFlowUtils.NonexistentAllocationTokenException; import google.registry.flows.exceptions.ContactsProhibitedException; import google.registry.flows.exceptions.OnlyToolCanPassMetadataException; -import google.registry.flows.exceptions.ResourceAlreadyExistsForThisClientException; import google.registry.flows.exceptions.ResourceCreateContentionException; import google.registry.model.billing.BillingBase; import google.registry.model.billing.BillingBase.Flag; @@ -1238,8 +1237,8 @@ class DomainCreateFlowTest extends ResourceFlowTestCase getDeletionTimeFromCache(String domainName) { + return tm().transact(() -> cache.getDeletionTimeForDomain(domainName)); + } +} diff --git a/core/src/test/java/google/registry/module/frontend/FrontendTestComponent.java b/core/src/test/java/google/registry/module/frontend/FrontendTestComponent.java index 0a0256bcc..c238b847e 100644 --- a/core/src/test/java/google/registry/module/frontend/FrontendTestComponent.java +++ b/core/src/test/java/google/registry/module/frontend/FrontendTestComponent.java @@ -20,6 +20,7 @@ import google.registry.config.CredentialModule; import google.registry.config.RegistryConfig; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; +import google.registry.flows.domain.DomainDeletionTimeCacheModule; import google.registry.groups.GmailModule; import google.registry.groups.GroupsModule; import google.registry.groups.GroupssettingsModule; @@ -43,6 +44,7 @@ import jakarta.inject.Singleton; CredentialModule.class, CustomLogicFactoryModule.class, CloudTasksUtilsModule.class, + DomainDeletionTimeCacheModule.class, FrontendRequestComponent.FrontendRequestComponentModule.class, GmailModule.class, GroupsModule.class,