From 66975fa51bc38500687d4d4098bd9060b3d2cd3e Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 15 Jan 2015 15:57:45 -0800 Subject: [PATCH] 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 --- src/__phutil_library_map__.php | 8 ++ .../clock/PhabricatorOneTimeTriggerClock.php | 25 ++++++ .../PhabricatorSubscriptionTriggerClock.php | 81 ++++++++++++++++++ .../workers/clock/PhabricatorTriggerClock.php | 74 ++++++++++++++++ .../PhabricatorTriggerClockTestCase.php | 85 +++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php create mode 100644 src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php create mode 100644 src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php create mode 100644 src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 2e10cb708d..92a03691d6 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -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', diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php new file mode 100644 index 0000000000..db7618970e --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php @@ -0,0 +1,25 @@ + '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 index 0000000000..258036c69d --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php @@ -0,0 +1,81 @@ + '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 index 0000000000..400e6fad8c --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php @@ -0,0 +1,74 @@ +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 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 index 0000000000..ae1266db63 --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php @@ -0,0 +1,85 @@ + $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; + } + } + +}