Page MenuHomePhabricator

D11403.id27428.diff
No OneTemporary

D11403.id27428.diff

diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -2046,6 +2046,7 @@
'PhabricatorObjectSelectorDialog' => 'view/control/PhabricatorObjectSelectorDialog.php',
'PhabricatorObjectUsesCredentialsEdgeType' => 'applications/transactions/edges/PhabricatorObjectUsesCredentialsEdgeType.php',
'PhabricatorOffsetPagedQuery' => 'infrastructure/query/PhabricatorOffsetPagedQuery.php',
+ 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php',
'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php',
'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php',
'PhabricatorOwnersConfigOptions' => 'applications/owners/config/PhabricatorOwnersConfigOptions.php',
@@ -2457,6 +2458,7 @@
'PhabricatorSubscribableInterface' => 'applications/subscriptions/interface/PhabricatorSubscribableInterface.php',
'PhabricatorSubscribedToObjectEdgeType' => 'applications/transactions/edges/PhabricatorSubscribedToObjectEdgeType.php',
'PhabricatorSubscribersQuery' => 'applications/subscriptions/query/PhabricatorSubscribersQuery.php',
+ 'PhabricatorSubscriptionTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php',
'PhabricatorSubscriptionsApplication' => 'applications/subscriptions/application/PhabricatorSubscriptionsApplication.php',
'PhabricatorSubscriptionsEditController' => 'applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php',
'PhabricatorSubscriptionsEditor' => 'applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php',
@@ -2520,6 +2522,8 @@
'PhabricatorTransformedFile' => 'applications/files/storage/PhabricatorTransformedFile.php',
'PhabricatorTranslation' => 'infrastructure/internationalization/translation/PhabricatorTranslation.php',
'PhabricatorTranslationsConfigOptions' => 'applications/config/option/PhabricatorTranslationsConfigOptions.php',
+ 'PhabricatorTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php',
+ 'PhabricatorTriggerClockTestCase' => 'infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php',
'PhabricatorTrivialTestCase' => 'infrastructure/testing/__tests__/PhabricatorTrivialTestCase.php',
'PhabricatorTwitchAuthProvider' => 'applications/auth/provider/PhabricatorTwitchAuthProvider.php',
'PhabricatorTwitterAuthProvider' => 'applications/auth/provider/PhabricatorTwitterAuthProvider.php',
@@ -5246,6 +5250,7 @@
'PhabricatorObjectRemarkupRule' => 'PhutilRemarkupRule',
'PhabricatorObjectUsesCredentialsEdgeType' => 'PhabricatorEdgeType',
'PhabricatorOffsetPagedQuery' => 'PhabricatorQuery',
+ 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock',
'PhabricatorOwnersApplication' => 'PhabricatorApplication',
'PhabricatorOwnersConfigOptions' => 'PhabricatorApplicationConfigOptions',
'PhabricatorOwnersController' => 'PhabricatorController',
@@ -5711,6 +5716,7 @@
'PhabricatorStorageSetupCheck' => 'PhabricatorSetupCheck',
'PhabricatorSubscribedToObjectEdgeType' => 'PhabricatorEdgeType',
'PhabricatorSubscribersQuery' => 'PhabricatorQuery',
+ 'PhabricatorSubscriptionTriggerClock' => 'PhabricatorTriggerClock',
'PhabricatorSubscriptionsApplication' => 'PhabricatorApplication',
'PhabricatorSubscriptionsEditController' => 'PhabricatorController',
'PhabricatorSubscriptionsEditor' => 'PhabricatorEditor',
@@ -5772,6 +5778,8 @@
'PhabricatorTransactionsApplication' => 'PhabricatorApplication',
'PhabricatorTransformedFile' => 'PhabricatorFileDAO',
'PhabricatorTranslationsConfigOptions' => 'PhabricatorApplicationConfigOptions',
+ 'PhabricatorTriggerClock' => 'Phobject',
+ 'PhabricatorTriggerClockTestCase' => 'PhabricatorTestCase',
'PhabricatorTrivialTestCase' => 'PhabricatorTestCase',
'PhabricatorTwitchAuthProvider' => 'PhabricatorOAuth2AuthProvider',
'PhabricatorTwitterAuthProvider' => 'PhabricatorOAuth1AuthProvider',
diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php
new file mode 100644
--- /dev/null
+++ b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * Triggers an event exactly once, at a specific epoch time.
+ */
+final class PhabricatorOneTimeTriggerClock
+ extends PhabricatorTriggerClock {
+
+ public function validateProperties(array $properties) {
+ PhutilTypeSpec::checkMap(
+ $properties,
+ array(
+ 'epoch' => 'int',
+ ));
+ }
+
+ public function getNextEventEpoch($last_epoch, $is_reschedule) {
+ if ($last_epoch) {
+ return null;
+ }
+
+ return $this->getProperty('epoch');
+ }
+
+}
diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php
new file mode 100644
--- /dev/null
+++ b/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * Triggers an event every month on the same day of the month, like the 12th
+ * of the month.
+ *
+ * If a given month does not have such a day (for instance, the clock triggers
+ * on the 30th of each month and the month in question is February, which never
+ * has a 30th day), it will trigger on the last day of the month instead.
+ *
+ * Choosing this strategy for subscriptions is predictable (it's easy to
+ * anticipate when a subscription period will end) and fair (billing
+ * periods always have nearly equal length). It also spreads subscriptions
+ * out evenly. If there are issues with billing, this provides an opportunity
+ * for them to be corrected after only a few customers are affected, instead of
+ * (for example) having every subscription fail all at once on the 1st of the
+ * month.
+ */
+final class PhabricatorSubscriptionTriggerClock
+ extends PhabricatorTriggerClock {
+
+ public function validateProperties(array $properties) {
+ PhutilTypeSpec::checkMap(
+ $properties,
+ array(
+ 'start' => 'int',
+ ));
+ }
+
+ public function getNextEventEpoch($last_epoch, $is_reschedule) {
+ $start_epoch = $this->getProperty('start');
+ if (!$last_epoch) {
+ $last_epoch = $start_epoch;
+ }
+
+ // Constructing DateTime objects like this implies UTC, so we don't need
+ // to set that explicitly.
+ $start = new DateTime('@'.$start_epoch);
+ $last = new DateTime('@'.$last_epoch);
+
+ $year = (int)$last->format('Y');
+ $month = (int)$last->format('n');
+
+ // Note that we're getting the day of the month from the start date, not
+ // from the last event date. This lets us schedule on March 31 after moving
+ // the date back to Feb 28.
+ $day = (int)$start->format('j');
+
+ // We trigger at the same time of day as the original event. Generally,
+ // this means that you should get invoiced at a reasonable local time in
+ // most cases, unless you subscribed at 1AM or something.
+ $hms = $start->format('G:i:s');
+
+ // Increment the month by 1.
+ $month = $month + 1;
+
+ // If we ran off the end of the calendar, set the month back to January
+ // and increment the year by 1.
+ if ($month > 12) {
+ $month = 1;
+ $year = $year + 1;
+ }
+
+ // Now, move the day backward until it falls in the correct month. If we
+ // pass an invalid date like "2014-2-31", it will internally be parsed
+ // as though we had passed "2014-3-3".
+ while (true) {
+ $next = new DateTime("{$year}-{$month}-{$day} {$hms} UTC");
+ if ($next->format('n') == $month) {
+ // The month didn't get corrected forward, so we're all set.
+ break;
+ } else {
+ // The month did get corrected forward, so back off a day.
+ $day--;
+ }
+ }
+
+ return (int)$next->format('U');
+ }
+
+}
diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
new file mode 100644
--- /dev/null
+++ b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
@@ -0,0 +1,74 @@
+<?php
+
+/**
+ * A trigger clock implements scheduling rules for an event.
+ *
+ * Two examples of triggered events are a subscription which bills on the 12th
+ * of every month, or a meeting reminder which sends an email 15 minutes before
+ * an event. A trigger clock contains the logic to figure out exactly when
+ * those times are.
+ *
+ * For example, it might schedule an event every hour, or every Thursday, or on
+ * the 15th of every month at 3PM, or only at a specific time.
+ */
+abstract class PhabricatorTriggerClock extends Phobject {
+
+ private $properties;
+
+ public function __construct(array $properties) {
+ $this->validateProperties($properties);
+ $this->properties = $properties;
+ }
+
+ public function getProperties() {
+ return $this->properties;
+ }
+
+ public function getProperty($key, $default = null) {
+ return idx($this->properties, $key, $default);
+ }
+
+
+ /**
+ * Validate clock configuration.
+ *
+ * @param map<string, wild> Map of clock properties.
+ * @return void
+ */
+ abstract public function validateProperties(array $properties);
+
+
+ /**
+ * Get the next occurrence of this event.
+ *
+ * This method takes two parameters: the last time this event occurred (or
+ * null if it has never triggered before) and a flag distinguishing between
+ * a normal reschedule (after a successful trigger) or an update because of
+ * a trigger change.
+ *
+ * If this event does not occur again, return `null` to stop it from being
+ * rescheduled. For example, a meeting reminder may be sent only once before
+ * the meeting.
+ *
+ * If this event does occur again, return the epoch timestamp of the next
+ * occurrence.
+ *
+ * When performing routine reschedules, the event must move forward in time:
+ * any timestamp you return must be later than the last event. For instance,
+ * if this event triggers an invoice, the next invoice date must be after
+ * the previous invoice date. This prevents an event from looping more than
+ * once per second.
+ *
+ * In contrast, after an update (not a routine reschedule), the next event
+ * may be scheduled at any time. For example, if a meeting is moved from next
+ * week to 3 minutes from now, the clock may reschedule the notification to
+ * occur 12 minutes ago. This will cause it to execute immediately.
+ *
+ * @param int|null Last time the event occurred, or null if it has never
+ * triggered before.
+ * @param bool True if this is a reschedule after a successful trigger.
+ * @return int|null Next event, or null to decline to reschedule.
+ */
+ abstract public function getNextEventEpoch($last_epoch, $is_reschedule);
+
+}
diff --git a/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php b/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php
@@ -0,0 +1,85 @@
+<?php
+
+final class PhabricatorTriggerClockTestCase extends PhabricatorTestCase {
+
+ public function testOneTimeTriggerClock() {
+ $now = PhabricatorTime::getNow();
+
+ $clock = new PhabricatorOneTimeTriggerClock(
+ array(
+ 'epoch' => $now,
+ ));
+
+ $this->assertEqual(
+ $now,
+ $clock->getNextEventEpoch(null, false),
+ pht('Should trigger at specified epoch.'));
+
+ $this->assertEqual(
+ null,
+ $clock->getNextEventEpoch(1, false),
+ pht('Should trigger only once.'));
+ }
+
+ public function testSubscriptionTriggerClock() {
+ $start = strtotime('2014-01-31 2:34:56 UTC');
+
+ $clock = new PhabricatorSubscriptionTriggerClock(
+ array(
+ 'start' => $start,
+ ));
+
+ $expect_list = array(
+ // This should be moved to the 28th of February.
+ '2014-02-28 2:34:56',
+
+ // In March, which has 31 days, it should move back to the 31st.
+ '2014-03-31 2:34:56',
+
+ // On months with only 30 days, it should occur on the 30th.
+ '2014-04-30 2:34:56',
+ '2014-05-31 2:34:56',
+ '2014-06-30 2:34:56',
+ '2014-07-31 2:34:56',
+ '2014-08-31 2:34:56',
+ '2014-09-30 2:34:56',
+ '2014-10-31 2:34:56',
+ '2014-11-30 2:34:56',
+ '2014-12-31 2:34:56',
+
+ // After billing on Dec 31 2014, it should wrap around to Jan 31 2015.
+ '2015-01-31 2:34:56',
+ '2015-02-28 2:34:56',
+ '2015-03-31 2:34:56',
+ '2015-04-30 2:34:56',
+ '2015-05-31 2:34:56',
+ '2015-06-30 2:34:56',
+ '2015-07-31 2:34:56',
+ '2015-08-31 2:34:56',
+ '2015-09-30 2:34:56',
+ '2015-10-31 2:34:56',
+ '2015-11-30 2:34:56',
+ '2015-12-31 2:34:56',
+ '2016-01-31 2:34:56',
+
+ // Finally, this should bill on leap day in 2016.
+ '2016-02-29 2:34:56',
+ '2016-03-31 2:34:56',
+ );
+
+ $last_epoch = null;
+ foreach ($expect_list as $cycle => $expect) {
+ $next_epoch = $clock->getNextEventEpoch(
+ $last_epoch,
+ ($last_epoch !== null));
+
+ $this->assertEqual(
+ $expect,
+ id(new DateTime('@'.$next_epoch))->format('Y-m-d g:i:s'),
+ pht('Billing cycle %s.', $cycle));
+
+ $last_epoch = $next_epoch;
+ }
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Sat, May 11, 6:57 PM (2 w, 6 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6286649
Default Alt Text
D11403.id27428.diff (13 KB)

Event Timeline