Skip to content

Commit 20ab873

Browse files
committed
Add a batch action to remove all contacts from domains
This implements the first part of Minimum Data Set phase 3, wherein we delete all contact data. This action is necessary to leave a permanent record on the domain (in the form of a domain history entry) documenting when the contacts were removed by the administrative user. Then, after this has finished removing all contact assocations, we can simply empty out or drop the Contact/ContactHistory tables and associated join tables.
1 parent 3c3303c commit 20ab873

File tree

6 files changed

+376
-0
lines changed

6 files changed

+376
-0
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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.base.Preconditions.checkState;
18+
import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8;
19+
import static google.registry.flows.FlowUtils.marshalWithLenientRetry;
20+
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
21+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
22+
import static google.registry.util.DateTimeUtils.END_OF_TIME;
23+
import static google.registry.util.ResourceUtils.readResourceUtf8;
24+
import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
25+
import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
26+
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
27+
import static java.nio.charset.StandardCharsets.US_ASCII;
28+
29+
import com.google.common.base.Ascii;
30+
import com.google.common.collect.ImmutableMap;
31+
import com.google.common.collect.ImmutableSet;
32+
import com.google.common.flogger.FluentLogger;
33+
import google.registry.config.RegistryConfig.Config;
34+
import google.registry.flows.EppController;
35+
import google.registry.flows.EppRequestSource;
36+
import google.registry.flows.PasswordOnlyTransportCredentials;
37+
import google.registry.flows.StatelessRequestSessionMetadata;
38+
import google.registry.model.common.FeatureFlag;
39+
import google.registry.model.contact.Contact;
40+
import google.registry.model.domain.DesignatedContact;
41+
import google.registry.model.domain.Domain;
42+
import google.registry.model.eppcommon.ProtocolDefinition;
43+
import google.registry.model.eppoutput.EppOutput;
44+
import google.registry.persistence.VKey;
45+
import google.registry.request.Action;
46+
import google.registry.request.Action.GaeService;
47+
import google.registry.request.Response;
48+
import google.registry.request.auth.Auth;
49+
import google.registry.request.lock.LockHandler;
50+
import jakarta.inject.Inject;
51+
import java.util.List;
52+
import java.util.concurrent.Callable;
53+
import java.util.logging.Level;
54+
import javax.annotation.Nullable;
55+
import org.joda.time.Duration;
56+
57+
/**
58+
* An action that removes all contacts from all active (non-deleted) domains.
59+
*
60+
* <p>This implements part 1 of phase 3 of the Minimum Dataset migration, wherein we remove all uses
61+
* of contact objects in preparation for later removing all contact data from the system.
62+
*
63+
* <p>This runs as a singly threaded, resumable action that loads batches of domains still
64+
* containing contacts, and runs a superuser domain update on each one to remove the contacts,
65+
* leaving behind a record recording that update.
66+
*/
67+
@Action(
68+
service = GaeService.BACKEND,
69+
path = RemoveAllDomainContactsAction.PATH,
70+
auth = Auth.AUTH_ADMIN)
71+
public class RemoveAllDomainContactsAction implements Runnable {
72+
73+
public static final String PATH = "/_dr/task/removeAllDomainContacts";
74+
private static final String LOCK_NAME = "Remove all domain contacts";
75+
private static final String CONTACT_FMT = "<domain:contact type=\"%s\">%s</domain:contact>";
76+
77+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
78+
private final EppController eppController;
79+
private final String registryAdminClientId;
80+
private final LockHandler lockHandler;
81+
private final Response response;
82+
private final String updateDomainXml;
83+
private int successes = 0;
84+
private int failures = 0;
85+
86+
private static final int BATCH_SIZE = 10000;
87+
88+
@Inject
89+
RemoveAllDomainContactsAction(
90+
EppController eppController,
91+
@Config("registryAdminClientId") String registryAdminClientId,
92+
LockHandler lockHandler,
93+
Response response) {
94+
this.eppController = eppController;
95+
this.registryAdminClientId = registryAdminClientId;
96+
this.lockHandler = lockHandler;
97+
this.response = response;
98+
this.updateDomainXml =
99+
readResourceUtf8(RemoveAllDomainContactsAction.class, "domain_remove_contacts.xml");
100+
}
101+
102+
@Override
103+
public void run() {
104+
checkState(
105+
tm().transact(() -> FeatureFlag.isActiveNow(MINIMUM_DATASET_CONTACTS_PROHIBITED)),
106+
"Minimum dataset migration must be completed prior to running this action");
107+
response.setContentType(PLAIN_TEXT_UTF_8);
108+
109+
Callable<Void> runner =
110+
() -> {
111+
try {
112+
runLocked();
113+
response.setStatus(SC_OK);
114+
} catch (Exception e) {
115+
logger.atSevere().withCause(e).log("Errored out during execution.");
116+
response.setStatus(SC_INTERNAL_SERVER_ERROR);
117+
response.setPayload(String.format("Errored out with cause: %s", e));
118+
}
119+
return null;
120+
};
121+
122+
if (!lockHandler.executeWithLocks(runner, null, Duration.standardHours(1), LOCK_NAME)) {
123+
// Send a 200-series status code to prevent this conflicting action from retrying.
124+
response.setStatus(SC_NO_CONTENT);
125+
response.setPayload("Could not acquire lock; already running?");
126+
}
127+
}
128+
129+
private void runLocked() {
130+
logger.atInfo().log("Removing contacts on all active domains.");
131+
132+
List<String> domainRepoIdsBatch;
133+
do {
134+
domainRepoIdsBatch =
135+
tm().<List<String>>transact(
136+
() ->
137+
tm().getEntityManager()
138+
.createQuery(
139+
"""
140+
SELECT repoId FROM Domain WHERE deletionTime = :end_of_time AND NOT (
141+
adminContact IS NULL AND billingContact IS NULL
142+
AND registrantContact IS NULL AND techContact IS NULL)
143+
""")
144+
.setParameter("end_of_time", END_OF_TIME)
145+
.setMaxResults(BATCH_SIZE)
146+
.getResultList());
147+
148+
domainRepoIdsBatch.forEach(this::runDomainUpdateFlow);
149+
} while (!domainRepoIdsBatch.isEmpty());
150+
String msg =
151+
String.format(
152+
"Finished; %d domains were successfully updated and %d errored out.",
153+
successes, failures);
154+
logger.at(failures == 0 ? Level.INFO : Level.WARNING).log(msg);
155+
response.setPayload(msg);
156+
}
157+
158+
private void runDomainUpdateFlow(String repoId) {
159+
// Create a new transaction that the flow's execution will be enlisted in that loads the domain
160+
// transactionally. This way we can ensure that nothing else has modified the domain in question
161+
// in the intervening period since the query above found it.
162+
boolean success = tm().transact(() -> runDomainUpdateFlowInner(repoId));
163+
if (success) {
164+
successes++;
165+
} else {
166+
failures++;
167+
}
168+
}
169+
170+
/**
171+
* Runs the actual domain update flow and returns whether the contact removals were successful.
172+
*/
173+
private boolean runDomainUpdateFlowInner(String repoId) {
174+
Domain domain = tm().loadByKey(VKey.create(Domain.class, repoId));
175+
if (!domain.getDeletionTime().equals(END_OF_TIME)) {
176+
// Domain has been deleted since the action began running; nothing further to be
177+
// done here.
178+
logger.atInfo().log("Nothing to process for deleted domain '%s'.", domain.getDomainName());
179+
return false;
180+
}
181+
logger.atInfo().log("Attempting to remove contacts on domain '%s'.", domain.getDomainName());
182+
183+
StringBuilder sb = new StringBuilder();
184+
ImmutableMap<VKey<? extends Contact>, Contact> contacts =
185+
tm().loadByKeys(
186+
domain.getContacts().stream()
187+
.map(DesignatedContact::getContactKey)
188+
.collect(ImmutableSet.toImmutableSet()));
189+
190+
// Collect all the (non-registrant) contacts referenced by the domain and compile an EPP XML
191+
// string that removes each one.
192+
for (DesignatedContact designatedContact : domain.getContacts()) {
193+
@Nullable Contact contact = contacts.get(designatedContact.getContactKey());
194+
if (contact == null) {
195+
logger.atWarning().log(
196+
"Domain '%s' referenced contact with repo ID '%s' that couldn't be" + " loaded.",
197+
domain.getDomainName(), designatedContact.getContactKey().getKey());
198+
continue;
199+
}
200+
sb.append(
201+
String.format(
202+
CONTACT_FMT,
203+
Ascii.toLowerCase(designatedContact.getType().name()),
204+
contact.getContactId()))
205+
.append("\n");
206+
}
207+
208+
String compiledXml =
209+
updateDomainXml
210+
.replace("%DOMAIN%", domain.getDomainName())
211+
.replace("%CONTACTS%", sb.toString());
212+
EppOutput output =
213+
eppController.handleEppCommand(
214+
new StatelessRequestSessionMetadata(
215+
registryAdminClientId, ProtocolDefinition.getVisibleServiceExtensionUris()),
216+
new PasswordOnlyTransportCredentials(),
217+
EppRequestSource.BACKEND,
218+
false,
219+
true,
220+
compiledXml.getBytes(US_ASCII));
221+
if (output.isSuccess()) {
222+
logger.atInfo().log(
223+
"Successfully removed contacts from domain '%s'.", domain.getDomainName());
224+
} else {
225+
logger.atWarning().log(
226+
"Failed removing contacts from domain '%s' with error %s.",
227+
domain.getDomainName(), new String(marshalWithLenientRetry(output), US_ASCII));
228+
}
229+
return output.isSuccess();
230+
}
231+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import google.registry.batch.DeleteProberDataAction;
2424
import google.registry.batch.ExpandBillingRecurrencesAction;
2525
import google.registry.batch.RelockDomainAction;
26+
import google.registry.batch.RemoveAllDomainContactsAction;
2627
import google.registry.batch.ResaveAllEppResourcesPipelineAction;
2728
import google.registry.batch.ResaveEntityAction;
2829
import google.registry.batch.SendExpiringCertificateNotificationEmailAction;
@@ -270,6 +271,8 @@ interface RequestComponent {
270271

271272
ReadinessProbeActionFrontend readinessProbeActionFrontend();
272273

274+
RemoveAllDomainContactsAction removeAllDomainContactsAction();
275+
273276
RdapAutnumAction rdapAutnumAction();
274277

275278
RdapDomainAction rdapDomainAction();

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import google.registry.batch.DeleteProberDataAction;
2424
import google.registry.batch.ExpandBillingRecurrencesAction;
2525
import google.registry.batch.RelockDomainAction;
26+
import google.registry.batch.RemoveAllDomainContactsAction;
2627
import google.registry.batch.ResaveAllEppResourcesPipelineAction;
2728
import google.registry.batch.ResaveEntityAction;
2829
import google.registry.batch.SendExpiringCertificateNotificationEmailAction;
@@ -153,6 +154,8 @@ public interface BackendRequestComponent {
153154

154155
RelockDomainAction relockDomainAction();
155156

157+
RemoveAllDomainContactsAction removeAllDomainContactsAction();
158+
156159
ResaveAllEppResourcesPipelineAction resaveAllEppResourcesPipelineAction();
157160

158161
ResaveEntityAction resaveEntityAction();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
2+
<command>
3+
<update>
4+
<domain:update xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
5+
<domain:name>%DOMAIN%</domain:name>
6+
<domain:rem>
7+
%CONTACTS%
8+
</domain:rem>
9+
<domain:chg>
10+
<domain:registrant/>
11+
</domain:chg>
12+
</domain:update>
13+
</update>
14+
<extension>
15+
<metadata:metadata xmlns:metadata="urn:google:params:xml:ns:metadata-1.0">
16+
<metadata:reason>Registry minimum data set phase 3: Removed all contacts from domain.</metadata:reason>
17+
<metadata:requestedByRegistrar>false</metadata:requestedByRegistrar>
18+
</metadata:metadata>
19+
</extension>
20+
<clTRID>ABC-12345</clTRID>
21+
</command>
22+
</epp>

0 commit comments

Comments
 (0)