1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-25 06:50:55 +01:00

Implement "trigger clocks" for scheduling events

Summary:
Ref T6881. This will probably make more sense in a couple of diffs, but this is a class that implements scheduling/recurrence rules. Two rules are provided:

  - Trigger an event at a specific time (e.g., a meeting reminder notification).
  - Trigger an event on the Nth day of every month (e.g., a subscription bill).

At some point, we'll presumably add a rule for T2896 (maybe using the "RRULE" spec) so you can do stuff like "the second to last thursday of every month", etc., but we don't need that for now.

(The "Nth day of every month, or move it back if no such day exists" rule doesn't seem to be expressible with the "RRULE" format, so implementing that wouldn't give us a superset of this. I think this rule is correct and desirable for this purpose, though.)

Test Plan: Added and executed unit tests.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T6881

Differential Revision: https://secure.phabricator.com/D11403
This commit is contained in:
epriestley 2015-01-15 15:57:45 -08:00
parent b9788fed00
commit 66975fa51b
5 changed files with 273 additions and 0 deletions

View file

@ -2046,6 +2046,7 @@ phutil_register_library_map(array(
'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 @@ phutil_register_library_map(array(
'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 @@ phutil_register_library_map(array(
'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 @@ phutil_register_library_map(array(
'PhabricatorObjectRemarkupRule' => 'PhutilRemarkupRule',
'PhabricatorObjectUsesCredentialsEdgeType' => 'PhabricatorEdgeType',
'PhabricatorOffsetPagedQuery' => 'PhabricatorQuery',
'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock',
'PhabricatorOwnersApplication' => 'PhabricatorApplication',
'PhabricatorOwnersConfigOptions' => 'PhabricatorApplicationConfigOptions',
'PhabricatorOwnersController' => 'PhabricatorController',
@ -5711,6 +5716,7 @@ phutil_register_library_map(array(
'PhabricatorStorageSetupCheck' => 'PhabricatorSetupCheck',
'PhabricatorSubscribedToObjectEdgeType' => 'PhabricatorEdgeType',
'PhabricatorSubscribersQuery' => 'PhabricatorQuery',
'PhabricatorSubscriptionTriggerClock' => 'PhabricatorTriggerClock',
'PhabricatorSubscriptionsApplication' => 'PhabricatorApplication',
'PhabricatorSubscriptionsEditController' => 'PhabricatorController',
'PhabricatorSubscriptionsEditor' => 'PhabricatorEditor',
@ -5772,6 +5778,8 @@ phutil_register_library_map(array(
'PhabricatorTransactionsApplication' => 'PhabricatorApplication',
'PhabricatorTransformedFile' => 'PhabricatorFileDAO',
'PhabricatorTranslationsConfigOptions' => 'PhabricatorApplicationConfigOptions',
'PhabricatorTriggerClock' => 'Phobject',
'PhabricatorTriggerClockTestCase' => 'PhabricatorTestCase',
'PhabricatorTrivialTestCase' => 'PhabricatorTestCase',
'PhabricatorTwitchAuthProvider' => 'PhabricatorOAuth2AuthProvider',
'PhabricatorTwitterAuthProvider' => 'PhabricatorOAuth1AuthProvider',

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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);
}

View file

@ -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;
}
}
}