Skip to content

Commit 28e72bd

Browse files
authored
Add a BulkDomainTransferAction (#2893)
This will be necessary if we wish to do larger BTAPPA transfers (or other types of transfers, I suppose). The nomulus command-line tool is not fast enough to quickly transfer thousands of domains within a reasonable timeframe.
1 parent 0777be3 commit 28e72bd

File tree

4 files changed

+438
-5
lines changed

4 files changed

+438
-5
lines changed

core/src/main/java/google/registry/batch/BatchModule.java

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,20 @@
2828
import static google.registry.request.RequestParameters.extractRequiredParameter;
2929
import static google.registry.request.RequestParameters.extractSetOfDatetimeParameters;
3030

31+
import com.google.common.collect.ImmutableList;
3132
import com.google.common.collect.ImmutableSet;
3233
import com.google.common.util.concurrent.RateLimiter;
34+
import com.google.gson.Gson;
35+
import com.google.gson.JsonElement;
36+
import com.google.gson.reflect.TypeToken;
3337
import dagger.Module;
3438
import dagger.Provides;
39+
import google.registry.request.HttpException.BadRequestException;
40+
import google.registry.request.OptionalJsonPayload;
3541
import google.registry.request.Parameter;
3642
import jakarta.inject.Named;
3743
import jakarta.servlet.http.HttpServletRequest;
44+
import java.util.List;
3845
import java.util.Optional;
3946
import org.joda.time.DateTime;
4047

@@ -44,6 +51,8 @@ public class BatchModule {
4451

4552
public static final String PARAM_FAST = "fast";
4653

54+
static final int DEFAULT_MAX_QPS = 10;
55+
4756
@Provides
4857
@Parameter("url")
4958
static String provideUrl(HttpServletRequest req) {
@@ -140,17 +149,49 @@ static boolean provideIsFast(HttpServletRequest req) {
140149
return extractBooleanParameter(req, PARAM_FAST);
141150
}
142151

143-
private static final int DEFAULT_MAX_QPS = 10;
144-
145152
@Provides
146153
@Parameter("maxQps")
147154
static int provideMaxQps(HttpServletRequest req) {
148155
return extractOptionalIntParameter(req, "maxQps").orElse(DEFAULT_MAX_QPS);
149156
}
150157

151158
@Provides
152-
@Named("removeAllDomainContacts")
153-
static RateLimiter provideRemoveAllDomainContactsRateLimiter(@Parameter("maxQps") int maxQps) {
159+
@Named("standardRateLimiter")
160+
static RateLimiter provideStandardRateLimiter(@Parameter("maxQps") int maxQps) {
154161
return RateLimiter.create(maxQps);
155162
}
163+
164+
@Provides
165+
@Parameter("gainingRegistrarId")
166+
static String provideGainingRegistrarId(HttpServletRequest req) {
167+
return extractRequiredParameter(req, "gainingRegistrarId");
168+
}
169+
170+
@Provides
171+
@Parameter("losingRegistrarId")
172+
static String provideLosingRegistrarId(HttpServletRequest req) {
173+
return extractRequiredParameter(req, "losingRegistrarId");
174+
}
175+
176+
@Provides
177+
@Parameter("bulkTransferDomainNames")
178+
static ImmutableList<String> provideBulkTransferDomainNames(
179+
Gson gson, @OptionalJsonPayload Optional<JsonElement> optionalJsonElement) {
180+
return optionalJsonElement
181+
.map(je -> ImmutableList.copyOf(gson.fromJson(je, new TypeToken<List<String>>() {})))
182+
.orElseThrow(
183+
() -> new BadRequestException("Missing POST body of bulk transfer domain names"));
184+
}
185+
186+
@Provides
187+
@Parameter("requestedByRegistrar")
188+
static boolean provideRequestedByRegistrar(HttpServletRequest req) {
189+
return extractBooleanParameter(req, "requestedByRegistrar");
190+
}
191+
192+
@Provides
193+
@Parameter("reason")
194+
static String provideReason(HttpServletRequest req) {
195+
return extractRequiredParameter(req, "reason");
196+
}
156197
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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.batch;
16+
17+
import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8;
18+
import static google.registry.flows.FlowUtils.marshalWithLenientRetry;
19+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
20+
import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
21+
import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
22+
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
23+
import static java.nio.charset.StandardCharsets.US_ASCII;
24+
25+
import com.google.common.collect.ImmutableList;
26+
import com.google.common.flogger.FluentLogger;
27+
import com.google.common.util.concurrent.RateLimiter;
28+
import google.registry.flows.EppController;
29+
import google.registry.flows.EppRequestSource;
30+
import google.registry.flows.PasswordOnlyTransportCredentials;
31+
import google.registry.flows.StatelessRequestSessionMetadata;
32+
import google.registry.model.ForeignKeyUtils;
33+
import google.registry.model.domain.Domain;
34+
import google.registry.model.eppcommon.ProtocolDefinition;
35+
import google.registry.model.eppcommon.StatusValue;
36+
import google.registry.model.eppoutput.EppOutput;
37+
import google.registry.request.Action;
38+
import google.registry.request.Parameter;
39+
import google.registry.request.Response;
40+
import google.registry.request.auth.Auth;
41+
import google.registry.request.lock.LockHandler;
42+
import google.registry.util.DateTimeUtils;
43+
import jakarta.inject.Inject;
44+
import jakarta.inject.Named;
45+
import java.util.Optional;
46+
import java.util.concurrent.Callable;
47+
import java.util.logging.Level;
48+
import org.joda.time.Duration;
49+
50+
/**
51+
* An action that transfers a set of domains from one registrar to another.
52+
*
53+
* <p>This should be used as part of the BTAPPA (Bulk Transfer After a Partial Portfolio
54+
* Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
55+
* to another, though it may be used in other situations as well.
56+
*
57+
* <p>This runs as a single-threaded idempotent action that runs a superuser domain transfer on each
58+
* domain to process. We go through the standard EPP process to make sure that we have an accurate
59+
* historical representation of events (rather than force-modifying the domains in place).
60+
*
61+
* <p>The body of the HTTP post request should be a JSON list of the domains to be transferred.
62+
* Because the list of domains to process can be quite large, this action should be called by a tool
63+
* that batches the list of domains into reasonable sizes if necessary.
64+
*
65+
* <p>Consider passing in an "maxQps" parameter based on the number of domains being transferred,
66+
* otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}.
67+
*/
68+
@Action(
69+
service = Action.Service.BACKEND,
70+
path = BulkDomainTransferAction.PATH,
71+
method = Action.Method.POST,
72+
auth = Auth.AUTH_ADMIN)
73+
public class BulkDomainTransferAction implements Runnable {
74+
75+
public static final String PATH = "/_dr/task/bulkDomainTransfer";
76+
77+
private static final String SUPERUSER_TRANSFER_XML_FORMAT =
78+
"""
79+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
80+
<command>
81+
<transfer op="request">
82+
<domain:transfer xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
83+
<domain:name>%DOMAIN_NAME%</domain:name>
84+
</domain:transfer>
85+
</transfer>
86+
<extension>
87+
<superuser:domainTransferRequest xmlns:superuser="urn:google:params:xml:ns:superuser-1.0">
88+
<superuser:renewalPeriod unit="y">0</superuser:renewalPeriod>
89+
<superuser:automaticTransferLength>0</superuser:automaticTransferLength>
90+
</superuser:domainTransferRequest>
91+
<metadata:metadata xmlns:metadata="urn:google:params:xml:ns:metadata-1.0">
92+
<metadata:reason>%REASON%</metadata:reason>
93+
<metadata:requestedByRegistrar>%REQUESTED_BY_REGISTRAR%</metadata:requestedByRegistrar>
94+
</metadata:metadata>
95+
</extension>
96+
<clTRID>BulkDomainTransferAction</clTRID>
97+
</command>
98+
</epp>
99+
""";
100+
101+
private static final String LOCK_NAME = "Domain bulk transfer";
102+
103+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
104+
105+
private final EppController eppController;
106+
private final LockHandler lockHandler;
107+
private final RateLimiter rateLimiter;
108+
private final ImmutableList<String> bulkTransferDomainNames;
109+
private final String gainingRegistrarId;
110+
private final String losingRegistrarId;
111+
private final boolean requestedByRegistrar;
112+
private final String reason;
113+
private final Response response;
114+
115+
private int successes = 0;
116+
private int alreadyTransferred = 0;
117+
private int pendingDelete = 0;
118+
private int missingDomains = 0;
119+
private int errors = 0;
120+
121+
@Inject
122+
BulkDomainTransferAction(
123+
EppController eppController,
124+
LockHandler lockHandler,
125+
@Named("standardRateLimiter") RateLimiter rateLimiter,
126+
@Parameter("bulkTransferDomainNames") ImmutableList<String> bulkTransferDomainNames,
127+
@Parameter("gainingRegistrarId") String gainingRegistrarId,
128+
@Parameter("losingRegistrarId") String losingRegistrarId,
129+
@Parameter("requestedByRegistrar") boolean requestedByRegistrar,
130+
@Parameter("reason") String reason,
131+
Response response) {
132+
this.eppController = eppController;
133+
this.lockHandler = lockHandler;
134+
this.rateLimiter = rateLimiter;
135+
this.bulkTransferDomainNames = bulkTransferDomainNames;
136+
this.gainingRegistrarId = gainingRegistrarId;
137+
this.losingRegistrarId = losingRegistrarId;
138+
this.requestedByRegistrar = requestedByRegistrar;
139+
this.reason = reason;
140+
this.response = response;
141+
}
142+
143+
@Override
144+
public void run() {
145+
response.setContentType(PLAIN_TEXT_UTF_8);
146+
Callable<Void> runner =
147+
() -> {
148+
try {
149+
runLocked();
150+
response.setStatus(SC_OK);
151+
} catch (Exception e) {
152+
logger.atSevere().withCause(e).log("Errored out during execution.");
153+
response.setStatus(SC_INTERNAL_SERVER_ERROR);
154+
response.setPayload(String.format("Errored out with cause: %s", e));
155+
}
156+
return null;
157+
};
158+
159+
if (!lockHandler.executeWithLocks(runner, null, Duration.standardHours(1), LOCK_NAME)) {
160+
// Send a 200-series status code to prevent this conflicting action from retrying.
161+
response.setStatus(SC_NO_CONTENT);
162+
response.setPayload("Could not acquire lock; already running?");
163+
}
164+
}
165+
166+
private void runLocked() {
167+
logger.atInfo().log("Attempting to transfer %d domains.", bulkTransferDomainNames.size());
168+
for (String domainName : bulkTransferDomainNames) {
169+
rateLimiter.acquire();
170+
tm().transact(() -> runTransferFlowInTransaction(domainName));
171+
}
172+
173+
String msg =
174+
String.format(
175+
"Finished; %d domains were successfully transferred, %d were previously transferred, %s"
176+
+ " were missing domains, %s are pending delete, and %d errored out.",
177+
successes, alreadyTransferred, missingDomains, pendingDelete, errors);
178+
logger.at(errors + missingDomains == 0 ? Level.INFO : Level.WARNING).log(msg);
179+
response.setPayload(msg);
180+
}
181+
182+
private void runTransferFlowInTransaction(String domainName) {
183+
if (shouldSkipDomain(domainName)) {
184+
return;
185+
}
186+
String xml =
187+
SUPERUSER_TRANSFER_XML_FORMAT
188+
.replace("%DOMAIN_NAME%", domainName)
189+
.replace("%REASON%", reason)
190+
.replace("%REQUESTED_BY_REGISTRAR%", String.valueOf(requestedByRegistrar));
191+
EppOutput output =
192+
eppController.handleEppCommand(
193+
new StatelessRequestSessionMetadata(
194+
gainingRegistrarId, ProtocolDefinition.getVisibleServiceExtensionUris()),
195+
new PasswordOnlyTransportCredentials(),
196+
EppRequestSource.TOOL,
197+
false,
198+
true,
199+
xml.getBytes(US_ASCII));
200+
if (output.isSuccess()) {
201+
logger.atInfo().log("Successfully transferred domain '%s'.", domainName);
202+
successes++;
203+
} else {
204+
logger.atWarning().log(
205+
"Failed transferring domain '%s' with error '%s'.",
206+
domainName, new String(marshalWithLenientRetry(output), US_ASCII));
207+
errors++;
208+
}
209+
}
210+
211+
private boolean shouldSkipDomain(String domainName) {
212+
Optional<Domain> maybeDomain =
213+
ForeignKeyUtils.loadResource(Domain.class, domainName, tm().getTransactionTime());
214+
if (maybeDomain.isEmpty()) {
215+
logger.atWarning().log("Domain '%s' was already deleted", domainName);
216+
missingDomains++;
217+
return true;
218+
}
219+
Domain domain = maybeDomain.get();
220+
String currentRegistrarId = domain.getCurrentSponsorRegistrarId();
221+
if (currentRegistrarId.equals(gainingRegistrarId)) {
222+
logger.atInfo().log("Domain '%s' was already transferred", domainName);
223+
alreadyTransferred++;
224+
return true;
225+
}
226+
if (!currentRegistrarId.equals(losingRegistrarId)) {
227+
logger.atWarning().log(
228+
"Domain '%s' had unexpected registrar '%s'", domainName, currentRegistrarId);
229+
errors++;
230+
return true;
231+
}
232+
if (domain.getStatusValues().contains(StatusValue.PENDING_DELETE)
233+
|| !domain.getDeletionTime().equals(DateTimeUtils.END_OF_TIME)) {
234+
logger.atWarning().log("Domain '%s' is in PENDING_DELETE", domainName);
235+
pendingDelete++;
236+
return true;
237+
}
238+
return false;
239+
}
240+
}

core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public class RemoveAllDomainContactsAction implements Runnable {
9393
EppController eppController,
9494
@Config("registryAdminClientId") String registryAdminClientId,
9595
LockHandler lockHandler,
96-
@Named("removeAllDomainContacts") RateLimiter rateLimiter,
96+
@Named("standardRateLimiter") RateLimiter rateLimiter,
9797
Response response) {
9898
this.eppController = eppController;
9999
this.registryAdminClientId = registryAdminClientId;

0 commit comments

Comments
 (0)