Skip to content

Commit ae61cd4

Browse files
authored
Sometimes include deletion times in domain-list exports (#2602)
We only include the deletion time if the domain is in the 5-day PENDING_DELETE period after the 30 day REDEMPTION period. For all other domains, we just have an empty string as that field. This is behind a feature flag so that we can control when it is enabled
1 parent cc20f7d commit ae61cd4

File tree

5 files changed

+250
-53
lines changed

5 files changed

+250
-53
lines changed

core/src/main/java/google/registry/export/ExportDomainListsAction.java

Lines changed: 77 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import static com.google.common.base.Verify.verifyNotNull;
1818
import static google.registry.model.tld.Tlds.getTldsOfType;
1919
import static google.registry.persistence.PersistenceModule.TransactionIsolationLevel.TRANSACTION_REPEATABLE_READ;
20+
import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
2021
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
2122
import static google.registry.request.Action.Method.POST;
2223
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -28,6 +29,9 @@
2829
import com.google.common.net.MediaType;
2930
import google.registry.config.RegistryConfig.Config;
3031
import google.registry.gcs.GcsUtils;
32+
import google.registry.model.common.FeatureFlag;
33+
import google.registry.model.domain.rgp.GracePeriodStatus;
34+
import google.registry.model.eppcommon.StatusValue;
3135
import google.registry.model.tld.Tld;
3236
import google.registry.model.tld.Tld.TldType;
3337
import google.registry.request.Action;
@@ -38,8 +42,13 @@
3842
import java.io.OutputStream;
3943
import java.io.OutputStreamWriter;
4044
import java.io.Writer;
45+
import java.time.Instant;
4146
import java.util.List;
4247
import javax.inject.Inject;
48+
import org.hibernate.query.NativeQuery;
49+
import org.hibernate.query.TupleTransformer;
50+
import org.joda.time.DateTime;
51+
import org.joda.time.DateTimeZone;
4352

4453
/**
4554
* An action that exports the list of active domains on all real TLDs to Google Drive and GCS.
@@ -55,7 +64,21 @@
5564
public class ExportDomainListsAction implements Runnable {
5665

5766
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
58-
public static final String REGISTERED_DOMAINS_FILENAME = "registered_domains.txt";
67+
private static final String SELECT_DOMAINS_STATEMENT =
68+
"SELECT domainName FROM Domain WHERE tld = :tld AND deletionTime > :now ORDER by domainName";
69+
private static final String SELECT_DOMAINS_AND_DELETION_TIMES_STATEMENT =
70+
"""
71+
SELECT d.domain_name, d.deletion_time, d.statuses, gp.type FROM "Domain" d
72+
LEFT JOIN (SELECT type, domain_repo_id FROM "GracePeriod"
73+
WHERE type = 'REDEMPTION'
74+
AND expiration_time > CAST(:now AS timestamptz)) AS gp
75+
ON d.repo_id = gp.domain_repo_id
76+
WHERE d.tld = :tld
77+
AND d.deletion_time > CAST(:now AS timestamptz)
78+
ORDER BY d.domain_name""";
79+
80+
// This may be a CSV, but it is uses a .txt file extension for back-compatibility
81+
static final String REGISTERED_DOMAINS_FILENAME = "registered_domains.txt";
5982

6083
@Inject Clock clock;
6184
@Inject DriveConnection driveConnection;
@@ -68,47 +91,50 @@ public class ExportDomainListsAction implements Runnable {
6891
public void run() {
6992
ImmutableSet<String> realTlds = getTldsOfType(TldType.REAL);
7093
logger.atInfo().log("Exporting domain lists for TLDs %s.", realTlds);
94+
95+
boolean includeDeletionTimes =
96+
tm().transact(
97+
() ->
98+
FeatureFlag.isActiveNowOrElse(
99+
FeatureFlag.FeatureName.INCLUDE_PENDING_DELETE_DATE_FOR_DOMAINS, false));
71100
realTlds.forEach(
72101
tld -> {
73-
List<String> domains =
74-
tm().transact(
102+
List<String> domainsList =
103+
replicaTm()
104+
.transact(
75105
TRANSACTION_REPEATABLE_READ,
76-
() ->
77-
// Note that if we had "creationTime <= :now" in the condition (not
78-
// necessary as there is no pending creation, the order of deletionTime
79-
// and creationTime in the query would have been significant and it
80-
// should come after deletionTime. When Hibernate substitutes "now" it
81-
// will first validate that the **first** field that is to be compared
82-
// with it (deletionTime) is assignable from the substituted Java object
83-
// (click.nowUtc()). Since creationTime is a CreateAutoTimestamp, if it
84-
// comes first, we will need to substitute "now" with
85-
// CreateAutoTimestamp.create(clock.nowUtc()). This might look a bit
86-
// strange as the Java object type is clearly incompatible between the
87-
// two fields deletionTime (DateTime) and creationTime, yet they are
88-
// compared with the same "now". It is actually OK because in the end
89-
// Hibernate converts everything to SQL types (and Java field names to
90-
// SQL column names) to run the query. Both CreateAutoTimestamp and
91-
// DateTime are persisted as timestamp_z in SQL. It is only the
92-
// validation that compares the Java types, and only with the first
93-
// field that compares with the substituted value.
94-
tm().query(
95-
"SELECT domainName FROM Domain "
96-
+ "WHERE tld = :tld "
97-
+ "AND deletionTime > :now "
98-
+ "ORDER by domainName ASC",
99-
String.class)
106+
() -> {
107+
if (includeDeletionTimes) {
108+
// We want to include deletion times, but only for domains in the 5-day
109+
// PENDING_DELETE period after the REDEMPTION grace period. In order to
110+
// accomplish this without loading the entire list of domains, we use a
111+
// native query to join against the GracePeriod table to find
112+
// PENDING_DELETE domains that don't have a REDEMPTION grace period.
113+
return replicaTm()
114+
.getEntityManager()
115+
.createNativeQuery(SELECT_DOMAINS_AND_DELETION_TIMES_STATEMENT)
116+
.unwrap(NativeQuery.class)
117+
.setTupleTransformer(new DomainResultTransformer())
118+
.setParameter("tld", tld)
119+
.setParameter("now", replicaTm().getTransactionTime().toString())
120+
.getResultList();
121+
} else {
122+
return replicaTm()
123+
.query(SELECT_DOMAINS_STATEMENT, String.class)
100124
.setParameter("tld", tld)
101-
.setParameter("now", clock.nowUtc())
102-
.getResultList());
103-
String domainsList = Joiner.on("\n").join(domains);
125+
.setParameter("now", replicaTm().getTransactionTime())
126+
.getResultList();
127+
}
128+
});
104129
logger.atInfo().log(
105-
"Exporting %d domains for TLD %s to GCS and Drive.", domains.size(), tld);
106-
exportToGcs(tld, domainsList, gcsBucket, gcsUtils);
107-
exportToDrive(tld, domainsList, driveConnection);
130+
"Exporting %d domains for TLD %s to GCS and Drive.", domainsList.size(), tld);
131+
String domainsListOutput = Joiner.on('\n').join(domainsList);
132+
exportToGcs(tld, domainsListOutput, gcsBucket, gcsUtils);
133+
exportToDrive(tld, domainsListOutput, driveConnection);
108134
});
109135
}
110136

111-
protected static boolean exportToDrive(
137+
protected static void exportToDrive(
112138
String tldStr, String domains, DriveConnection driveConnection) {
113139
verifyNotNull(driveConnection, "Expecting non-null driveConnection");
114140
try {
@@ -131,12 +157,10 @@ protected static boolean exportToDrive(
131157
} catch (Throwable e) {
132158
logger.atSevere().withCause(e).log(
133159
"Error exporting registered domains for TLD %s to Drive, skipping...", tldStr);
134-
return false;
135160
}
136-
return true;
137161
}
138162

139-
protected static boolean exportToGcs(
163+
protected static void exportToGcs(
140164
String tld, String domains, String gcsBucket, GcsUtils gcsUtils) {
141165
BlobId blobId = BlobId.of(gcsBucket, tld + ".txt");
142166
try (OutputStream gcsOutput = gcsUtils.openOutputStream(blobId);
@@ -145,8 +169,22 @@ protected static boolean exportToGcs(
145169
} catch (Throwable e) {
146170
logger.atSevere().withCause(e).log(
147171
"Error exporting registered domains for TLD %s to GCS, skipping...", tld);
148-
return false;
149172
}
150-
return true;
173+
}
174+
175+
/** Transforms the multiple columns selected from SQL into the output line. */
176+
private static class DomainResultTransformer implements TupleTransformer<String> {
177+
@Override
178+
public String transformTuple(Object[] domainResult, String[] strings) {
179+
String domainName = (String) domainResult[0];
180+
Instant deletionInstant = (Instant) domainResult[1];
181+
DateTime deletionTime = new DateTime(deletionInstant.toEpochMilli(), DateTimeZone.UTC);
182+
String[] domainStatuses = (String[]) domainResult[2];
183+
String gracePeriodType = (String) domainResult[3];
184+
boolean inPendingDelete =
185+
ImmutableSet.copyOf(domainStatuses).contains(StatusValue.PENDING_DELETE.toString())
186+
&& !GracePeriodStatus.REDEMPTION.toString().equals(gracePeriodType);
187+
return String.format("%s,%s", domainName, inPendingDelete ? deletionTime : "");
188+
}
151189
}
152190
}

core/src/main/java/google/registry/model/common/FeatureFlag.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public enum FeatureName {
6666
TEST_FEATURE,
6767
MINIMUM_DATASET_CONTACTS_OPTIONAL,
6868
MINIMUM_DATASET_CONTACTS_PROHIBITED,
69+
INCLUDE_PENDING_DELETE_DATE_FOR_DOMAINS
6970
}
7071

7172
/** The name of the flag/feature. */
@@ -154,6 +155,15 @@ public FeatureStatus getStatus(DateTime time) {
154155
return status.getValueAtTime(time);
155156
}
156157

158+
/** Returns if the flag is active, or the default value if the flag does not exist. */
159+
public static boolean isActiveNowOrElse(FeatureName featureName, boolean defaultValue) {
160+
tm().assertInTransaction();
161+
return CACHE
162+
.get(featureName)
163+
.map(flag -> flag.getStatus(tm().getTransactionTime()).equals(ACTIVE))
164+
.orElse(defaultValue);
165+
}
166+
157167
/** Returns if the FeatureFlag with the given FeatureName is active now. */
158168
public static boolean isActiveNow(FeatureName featureName) {
159169
tm().assertInTransaction();

core/src/test/java/google/registry/export/ExportDomainListsActionTest.java

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616

1717
import static com.google.common.truth.Truth.assertThat;
1818
import static google.registry.export.ExportDomainListsAction.REGISTERED_DOMAINS_FILENAME;
19+
import static google.registry.model.common.FeatureFlag.FeatureName.INCLUDE_PENDING_DELETE_DATE_FOR_DOMAINS;
20+
import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
21+
import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE;
1922
import static google.registry.testing.DatabaseHelper.createTld;
2023
import static google.registry.testing.DatabaseHelper.persistActiveDomain;
2124
import static google.registry.testing.DatabaseHelper.persistDeletedDomain;
2225
import static google.registry.testing.DatabaseHelper.persistResource;
26+
import static google.registry.util.DateTimeUtils.START_OF_TIME;
2327
import static java.nio.charset.StandardCharsets.UTF_8;
2428
import static org.junit.jupiter.api.Assertions.assertThrows;
2529
import static org.mockito.ArgumentMatchers.eq;
@@ -31,8 +35,14 @@
3135
import com.google.cloud.storage.StorageException;
3236
import com.google.cloud.storage.contrib.nio.testing.LocalStorageHelper;
3337
import com.google.common.collect.ImmutableList;
38+
import com.google.common.collect.ImmutableSortedMap;
3439
import com.google.common.net.MediaType;
3540
import google.registry.gcs.GcsUtils;
41+
import google.registry.model.common.FeatureFlag;
42+
import google.registry.model.domain.Domain;
43+
import google.registry.model.domain.GracePeriod;
44+
import google.registry.model.domain.rgp.GracePeriodStatus;
45+
import google.registry.model.eppcommon.StatusValue;
3646
import google.registry.model.tld.Tld;
3747
import google.registry.model.tld.Tld.TldType;
3848
import google.registry.persistence.transaction.JpaTestExtensions;
@@ -56,7 +66,7 @@ class ExportDomainListsActionTest {
5666

5767
@RegisterExtension
5868
final JpaIntegrationTestExtension jpa =
59-
new JpaTestExtensions.Builder().buildIntegrationTestExtension();
69+
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
6070

6171
@BeforeEach
6272
void beforeEach() {
@@ -70,6 +80,7 @@ void beforeEach() {
7080
action.gcsUtils = gcsUtils;
7181
action.clock = clock;
7282
action.driveConnection = driveConnection;
83+
persistFeatureFlag(INACTIVE);
7384
}
7485

7586
private void verifyExportedToDrive(String folderId, String domains) throws Exception {
@@ -83,7 +94,7 @@ private void verifyExportedToDrive(String folderId, String domains) throws Excep
8394
}
8495

8596
@Test
86-
void test_outputsOnlyActiveDomains() throws Exception {
97+
void test_outputsOnlyActiveDomains_txt() throws Exception {
8798
persistActiveDomain("onetwo.tld");
8899
persistActiveDomain("rudnitzky.tld");
89100
persistDeletedDomain("mortuary.tld", DateTime.parse("2001-03-14T10:11:12Z"));
@@ -97,7 +108,22 @@ void test_outputsOnlyActiveDomains() throws Exception {
97108
}
98109

99110
@Test
100-
void test_outputsOnlyDomainsOnRealTlds() throws Exception {
111+
void test_outputsOnlyActiveDomains_csv() throws Exception {
112+
persistFeatureFlag(ACTIVE);
113+
persistActiveDomain("onetwo.tld");
114+
persistActiveDomain("rudnitzky.tld");
115+
persistDeletedDomain("mortuary.tld", DateTime.parse("2001-03-14T10:11:12Z"));
116+
action.run();
117+
BlobId existingFile = BlobId.of("outputbucket", "tld.txt");
118+
String tlds = new String(gcsUtils.readBytesFrom(existingFile), UTF_8);
119+
// Check that it only contains the active domains, not the dead one.
120+
assertThat(tlds).isEqualTo("onetwo.tld,\nrudnitzky.tld,");
121+
verifyExportedToDrive("brouhaha", "onetwo.tld,\nrudnitzky.tld,");
122+
verifyNoMoreInteractions(driveConnection);
123+
}
124+
125+
@Test
126+
void test_outputsOnlyDomainsOnRealTlds_txt() throws Exception {
101127
persistActiveDomain("onetwo.tld");
102128
persistActiveDomain("rudnitzky.tld");
103129
persistActiveDomain("wontgo.testtld");
@@ -116,7 +142,58 @@ void test_outputsOnlyDomainsOnRealTlds() throws Exception {
116142
}
117143

118144
@Test
119-
void test_outputsDomainsFromDifferentTldsToMultipleFiles() throws Exception {
145+
void test_outputsOnlyDomainsOnRealTlds_csv() throws Exception {
146+
persistFeatureFlag(ACTIVE);
147+
persistActiveDomain("onetwo.tld");
148+
persistActiveDomain("rudnitzky.tld");
149+
persistActiveDomain("wontgo.testtld");
150+
action.run();
151+
BlobId existingFile = BlobId.of("outputbucket", "tld.txt");
152+
String tlds = new String(gcsUtils.readBytesFrom(existingFile), UTF_8).trim();
153+
// Check that it only contains the domains on the real TLD, and not the test one.
154+
assertThat(tlds).isEqualTo("onetwo.tld,\nrudnitzky.tld,");
155+
// Make sure that the test TLD file wasn't written out.
156+
BlobId nonexistentFile = BlobId.of("outputbucket", "testtld.txt");
157+
assertThrows(StorageException.class, () -> gcsUtils.readBytesFrom(nonexistentFile));
158+
ImmutableList<String> ls = gcsUtils.listFolderObjects("outputbucket", "");
159+
assertThat(ls).containsExactly("tld.txt");
160+
verifyExportedToDrive("brouhaha", "onetwo.tld,\nrudnitzky.tld,");
161+
verifyNoMoreInteractions(driveConnection);
162+
}
163+
164+
@Test
165+
void test_outputIncludesDeletionTimes_forPendingDeletes_notRdemption() throws Exception {
166+
persistFeatureFlag(ACTIVE);
167+
// Domains pending delete (meaning the 5 day period, not counting the 30 day redemption period)
168+
// should include their pending deletion date
169+
persistActiveDomain("active.tld");
170+
Domain redemption = persistActiveDomain("redemption.tld");
171+
persistResource(
172+
redemption
173+
.asBuilder()
174+
.addStatusValue(StatusValue.PENDING_DELETE)
175+
.addGracePeriod(
176+
GracePeriod.createWithoutBillingEvent(
177+
GracePeriodStatus.REDEMPTION,
178+
redemption.getRepoId(),
179+
clock.nowUtc().plusDays(20),
180+
redemption.getCurrentSponsorRegistrarId()))
181+
.build());
182+
persistResource(
183+
persistActiveDomain("pendingdelete.tld")
184+
.asBuilder()
185+
.addStatusValue(StatusValue.PENDING_DELETE)
186+
.setDeletionTime(clock.nowUtc().plusDays(3))
187+
.build());
188+
189+
action.run();
190+
191+
verifyExportedToDrive(
192+
"brouhaha", "active.tld,\npendingdelete.tld,2020-02-05T02:02:02.000Z\nredemption.tld,");
193+
}
194+
195+
@Test
196+
void test_outputsDomainsFromDifferentTldsToMultipleFiles_txt() throws Exception {
120197
createTld("tldtwo");
121198
persistResource(Tld.get("tldtwo").asBuilder().setDriveFolderId("hooray").build());
122199

@@ -143,4 +220,43 @@ void test_outputsDomainsFromDifferentTldsToMultipleFiles() throws Exception {
143220
// tldthree does not have a drive id, so no export to drive is performed.
144221
verifyNoMoreInteractions(driveConnection);
145222
}
223+
224+
@Test
225+
void test_outputsDomainsFromDifferentTldsToMultipleFiles_csv() throws Exception {
226+
persistFeatureFlag(ACTIVE);
227+
createTld("tldtwo");
228+
persistResource(Tld.get("tldtwo").asBuilder().setDriveFolderId("hooray").build());
229+
230+
createTld("tldthree");
231+
// You'd think this test was written around Christmas, but it wasn't.
232+
persistActiveDomain("dasher.tld");
233+
persistActiveDomain("prancer.tld");
234+
persistActiveDomain("rudolph.tldtwo");
235+
persistActiveDomain("santa.tldtwo");
236+
persistActiveDomain("buddy.tldtwo");
237+
persistActiveDomain("cupid.tldthree");
238+
action.run();
239+
BlobId firstTldFile = BlobId.of("outputbucket", "tld.txt");
240+
String tlds = new String(gcsUtils.readBytesFrom(firstTldFile), UTF_8).trim();
241+
assertThat(tlds).isEqualTo("dasher.tld,\nprancer.tld,");
242+
BlobId secondTldFile = BlobId.of("outputbucket", "tldtwo.txt");
243+
String moreTlds = new String(gcsUtils.readBytesFrom(secondTldFile), UTF_8).trim();
244+
assertThat(moreTlds).isEqualTo("buddy.tldtwo,\nrudolph.tldtwo,\nsanta.tldtwo,");
245+
BlobId thirdTldFile = BlobId.of("outputbucket", "tldthree.txt");
246+
String evenMoreTlds = new String(gcsUtils.readBytesFrom(thirdTldFile), UTF_8).trim();
247+
assertThat(evenMoreTlds).isEqualTo("cupid.tldthree,");
248+
verifyExportedToDrive("brouhaha", "dasher.tld,\nprancer.tld,");
249+
verifyExportedToDrive("hooray", "buddy.tldtwo,\nrudolph.tldtwo,\nsanta.tldtwo,");
250+
// tldthree does not have a drive id, so no export to drive is performed.
251+
verifyNoMoreInteractions(driveConnection);
252+
}
253+
254+
private void persistFeatureFlag(FeatureFlag.FeatureStatus status) {
255+
persistResource(
256+
new FeatureFlag()
257+
.asBuilder()
258+
.setFeatureName(INCLUDE_PENDING_DELETE_DATE_FOR_DOMAINS)
259+
.setStatusMap(ImmutableSortedMap.of(START_OF_TIME, status))
260+
.build());
261+
}
146262
}

0 commit comments

Comments
 (0)