diff --git a/java/google/registry/env/common/backend/WEB-INF/web.xml b/java/google/registry/env/common/backend/WEB-INF/web.xml
index 136a1d629..a2cf88fa8 100644
--- a/java/google/registry/env/common/backend/WEB-INF/web.xml
+++ b/java/google/registry/env/common/backend/WEB-INF/web.xml
@@ -265,6 +265,12 @@
/_dr/task/dnsRefreshForHostRename
+
+
+ backend-servlet
+ /_dr/task/expandRecurringBillingEvents
+
+
diff --git a/java/google/registry/model/common/Cursor.java b/java/google/registry/model/common/Cursor.java
index a57ec8919..92a104374 100644
--- a/java/google/registry/model/common/Cursor.java
+++ b/java/google/registry/model/common/Cursor.java
@@ -20,8 +20,6 @@ import static google.registry.model.common.EntityGroupRoot.getCrossTldKey;
import static google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.util.DateTimeUtils.START_OF_TIME;
-import com.google.common.annotations.VisibleForTesting;
-
import com.googlecode.objectify.Key;
import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Id;
@@ -69,7 +67,12 @@ public class Cursor extends ImmutableObject {
*/
RDE_UPLOAD_SFTP(Registry.class),
- /** Cursor for ensuring rolling transactional isolation of recurring billing expansion. */
+ /**
+ * Cursor for ensuring rolling transactional isolation of recurring billing expansion. The
+ * value of this cursor represents the exclusive upper bound on the range of billing times
+ * for which Recurring billing events have been expanded (i.e. the inclusive first billing time
+ * for the next expansion job).
+ */
RECURRING_BILLING(EntityGroupRoot.class);
/** See the definition of scope on {@link #getScopeClass}. */
@@ -120,16 +123,14 @@ public class Cursor extends ImmutableObject {
}
/** Creates a unique key for a given scope and cursor type. */
- @VisibleForTesting
- static Key createKey(CursorType cursorType, ImmutableObject scope) {
+ public static Key createKey(CursorType cursorType, ImmutableObject scope) {
Key extends ImmutableObject> scopeKey = Key.create(scope);
checkValidCursorTypeForScope(cursorType, scopeKey);
return Key.create(getCrossTldKey(), Cursor.class, generateId(cursorType, scopeKey));
}
/** Creates a unique key for a given global cursor type. */
- @VisibleForTesting
- static Key createGlobalKey(CursorType cursorType) {
+ public static Key createGlobalKey(CursorType cursorType) {
checkArgument(
cursorType.getScopeClass().equals(EntityGroupRoot.class),
"Cursor type is not a global cursor.");
diff --git a/java/google/registry/module/backend/BUILD b/java/google/registry/module/backend/BUILD
index 24b5ce0d3..66390f65a 100644
--- a/java/google/registry/module/backend/BUILD
+++ b/java/google/registry/module/backend/BUILD
@@ -14,11 +14,11 @@ java_library(
"//java/com/google/common/net",
"//java/google/registry/backup",
"//java/google/registry/bigquery",
+ "//java/google/registry/billing",
"//java/google/registry/config",
"//java/google/registry/cron",
"//java/google/registry/dns",
"//java/google/registry/dns/writer/api",
- "//java/google/registry/dns/writer/dnsupdate",
"//java/google/registry/export",
"//java/google/registry/export/sheet",
"//java/google/registry/flows",
@@ -35,6 +35,7 @@ java_library(
"//java/google/registry/util",
"//third_party/java/bouncycastle",
"//third_party/java/dagger",
+ "//third_party/java/joda_time",
"//third_party/java/jsr305_annotations",
"//third_party/java/jsr330_inject",
"//third_party/java/servlet/servlet_api",
diff --git a/java/google/registry/module/backend/BackendModule.java b/java/google/registry/module/backend/BackendModule.java
index cfa970d58..162eb0463 100644
--- a/java/google/registry/module/backend/BackendModule.java
+++ b/java/google/registry/module/backend/BackendModule.java
@@ -15,14 +15,20 @@
package google.registry.module.backend;
import static google.registry.model.registry.Registries.assertTldExists;
+import static google.registry.request.RequestParameters.extractOptionalDatetimeParameter;
import static google.registry.request.RequestParameters.extractRequiredParameter;
+import com.google.common.base.Optional;
+
import dagger.Module;
import dagger.Provides;
+import google.registry.billing.ExpandRecurringBillingEventsAction;
import google.registry.request.Parameter;
import google.registry.request.RequestParameters;
+import org.joda.time.DateTime;
+
import javax.servlet.http.HttpServletRequest;
/**
@@ -36,4 +42,11 @@ public class BackendModule {
static String provideTld(HttpServletRequest req) {
return assertTldExists(extractRequiredParameter(req, RequestParameters.PARAM_TLD));
}
+
+ @Provides
+ @Parameter("cursorTime")
+ static Optional provideCursorTime(HttpServletRequest req) {
+ return extractOptionalDatetimeParameter(
+ req, ExpandRecurringBillingEventsAction.PARAM_CURSOR_TIME);
+ }
}
diff --git a/java/google/registry/request/RequestParameters.java b/java/google/registry/request/RequestParameters.java
index c9320e3ca..1402c9e56 100644
--- a/java/google/registry/request/RequestParameters.java
+++ b/java/google/registry/request/RequestParameters.java
@@ -160,6 +160,25 @@ public final class RequestParameters {
}
}
+ /**
+ * Returns first request parameter associated with {@code name} parsed as an
+ * ISO 8601 timestamp, e.g. {@code 1984-12-18TZ},
+ * {@code 2000-01-01T16:20:00Z}.
+ *
+ * @throws BadRequestException if request parameter is present but not a valid {@link DateTime}.
+ */
+ public static Optional extractOptionalDatetimeParameter(
+ HttpServletRequest req, String name) {
+ String stringParam = req.getParameter(name);
+ try {
+ return isNullOrEmpty(stringParam)
+ ? Optional.absent()
+ : Optional.of(DateTime.parse(stringParam));
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException("Bad ISO 8601 timestamp: " + name);
+ }
+ }
+
/**
* Returns first request parameter associated with {@code name} parsed as an optional
* {@link InetAddress} (which might be IPv6).
diff --git a/javatests/google/registry/request/RequestParametersTest.java b/javatests/google/registry/request/RequestParametersTest.java
index 65862ea1c..84a5d278e 100644
--- a/javatests/google/registry/request/RequestParametersTest.java
+++ b/javatests/google/registry/request/RequestParametersTest.java
@@ -17,6 +17,7 @@ package google.registry.request;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.request.RequestParameters.extractBooleanParameter;
import static google.registry.request.RequestParameters.extractEnumParameter;
+import static google.registry.request.RequestParameters.extractOptionalDatetimeParameter;
import static google.registry.request.RequestParameters.extractOptionalParameter;
import static google.registry.request.RequestParameters.extractRequiredDatetimeParameter;
import static google.registry.request.RequestParameters.extractRequiredMaybeEmptyParameter;
@@ -184,6 +185,26 @@ public class RequestParametersTest {
extractRequiredDatetimeParameter(req, "timeParam");
}
+ @Test
+ public void testExtractOptionalDatetimeParameter_correctValue_works() throws Exception {
+ when(req.getParameter("timeParam")).thenReturn("2015-08-27T13:25:34.123Z");
+ assertThat(extractOptionalDatetimeParameter(req, "timeParam"))
+ .hasValue(DateTime.parse("2015-08-27T13:25:34.123Z"));
+ }
+
+ @Test
+ public void testExtractOptionalDatetimeParameter_badValue_throwsBadRequest() throws Exception {
+ when(req.getParameter("timeParam")).thenReturn("Tuesday at three o'clock");
+ thrown.expect(BadRequestException.class, "timeParam");
+ extractOptionalDatetimeParameter(req, "timeParam");
+ }
+
+ @Test
+ public void testExtractOptionalDatetimeParameter_empty_returnsAbsent() throws Exception {
+ when(req.getParameter("timeParam")).thenReturn("");
+ assertThat(extractOptionalDatetimeParameter(req, "timeParam")).isAbsent();
+ }
+
@Test
public void testExtractRequiredDatetimeParameter_noValue_throwsBadRequest() throws Exception {
thrown.expect(BadRequestException.class, "timeParam");